feat(GUI): improve analytics events (#1111)

* feat(GUI): improve analytics events

This commit adds more events to our current analytics.
Will further improve in a future commit.

Change-Type: patch
See: https://github.com/resin-io/etcher/issues/1100

* refactor(gui): use single function to set normal and dangerous settings

Change-Type: patch
Signed-off-by: Juan Cruz Viotti <jviotti@openmailbox.org>
This commit is contained in:
Ștefan Daniel Mihăilă 2017-03-10 21:18:49 +02:00 committed by Juan Cruz Viotti
parent 33e044806b
commit 34c85eb150
22 changed files with 477 additions and 82 deletions

View File

@ -30,6 +30,7 @@ const electron = require('electron');
const Bluebird = require('bluebird'); const Bluebird = require('bluebird');
const EXIT_CODES = require('../shared/exit-codes'); const EXIT_CODES = require('../shared/exit-codes');
const messages = require('../shared/messages'); const messages = require('../shared/messages');
const packageJSON = require('../../package.json');
const Store = require('./models/store'); const Store = require('./models/store');
@ -85,25 +86,46 @@ app.run(() => {
app.run((AnalyticsService, ErrorService, UpdateNotifierService, SelectionStateModel) => { app.run((AnalyticsService, ErrorService, UpdateNotifierService, SelectionStateModel) => {
AnalyticsService.logEvent('Application start'); AnalyticsService.logEvent('Application start');
if (UpdateNotifierService.shouldCheckForUpdates() && !process.env.ETCHER_DISABLE_UPDATES) { const shouldCheckForUpdates = UpdateNotifierService.shouldCheckForUpdates();
AnalyticsService.logEvent('Checking for updates');
if (!shouldCheckForUpdates || process.env.ETCHER_DISABLE_UPDATES) {
AnalyticsService.logEvent('Not checking for updates', {
shouldCheckForUpdates,
disableUpdatesEnvironmentVariable: process.env.ETCHER_DISABLE_UPDATES
});
return;
}
AnalyticsService.logEvent('Checking for updates', {
currentVersion: packageJSON.version
});
UpdateNotifierService.isLatestVersion().then((isLatestVersion) => { UpdateNotifierService.isLatestVersion().then((isLatestVersion) => {
if (isLatestVersion) {
AnalyticsService.logEvent('Update notification skipped', {
reason: 'Latest version'
});
return Bluebird.resolve();
}
// In case the internet connection is not good and checking the // In case the internet connection is not good and checking the
// latest published version takes too long, only show notify // latest published version takes too long, only show notify
// the user about the new version if he didn't start the flash // the user about the new version if he didn't start the flash
// process (e.g: selected an image), otherwise such interruption // process (e.g: selected an image), otherwise such interruption
// might be annoying. // might be annoying.
if (!isLatestVersion && !SelectionStateModel.hasImage()) { if (SelectionStateModel.hasImage()) {
AnalyticsService.logEvent('Update notification skipped', {
reason: 'Image selected'
});
return Bluebird.resolve();
}
AnalyticsService.logEvent('Notifying update'); AnalyticsService.logEvent('Notifying update');
return UpdateNotifierService.notify();
}
return Bluebird.resolve(); return UpdateNotifierService.notify();
}).catch(ErrorService.reportException); }).catch(ErrorService.reportException);
}
}); });
@ -158,11 +180,14 @@ app.run(($timeout, DriveScannerService, DrivesModel, ErrorService) => {
DriveScannerService.start(); DriveScannerService.start();
}); });
app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogService) => { app.run(($window, AnalyticsService, WarningModalService, ErrorService, FlashStateModel, OSDialogService) => {
let popupExists = false; let popupExists = false;
$window.addEventListener('beforeunload', (event) => { $window.addEventListener('beforeunload', (event) => {
if (!FlashStateModel.isFlashing() || popupExists) { if (!FlashStateModel.isFlashing() || popupExists) {
AnalyticsService.logEvent('Close application', {
isFlashing: FlashStateModel.isFlashing()
});
return; return;
} }
@ -172,6 +197,8 @@ app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogSe
// Don't open any more popups // Don't open any more popups
popupExists = true; popupExists = true;
AnalyticsService.logEvent('Close attempt while flashing');
OSDialogService.showWarning({ OSDialogService.showWarning({
confirmationLabel: 'Yes, quit', confirmationLabel: 'Yes, quit',
rejectionLabel: 'Cancel', rejectionLabel: 'Cancel',
@ -179,6 +206,7 @@ app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogSe
description: messages.warning.exitWhileFlashing() description: messages.warning.exitWhileFlashing()
}).then((confirmed) => { }).then((confirmed) => {
if (confirmed) { if (confirmed) {
AnalyticsService.logEvent('Close confirmed while flashing');
// This circumvents the 'beforeunload' event unlike // This circumvents the 'beforeunload' event unlike
// electron.remote.app.quit() which does not. // electron.remote.app.quit() which does not.
@ -186,11 +214,27 @@ app.run(($window, WarningModalService, ErrorService, FlashStateModel, OSDialogSe
} }
AnalyticsService.logEvent('Close rejected while flashing');
popupExists = false; popupExists = false;
}).catch(ErrorService.reportException); }).catch(ErrorService.reportException);
}); });
}); });
app.run(($rootScope, AnalyticsService) => {
$rootScope.$on('$stateChangeSuccess', (event, toState, toParams, fromState) => {
// Ignore first navigation
if (!fromState.name) {
return;
}
AnalyticsService.logEvent('Navigate', {
to: toState.name,
from: fromState.name
});
});
});
app.config(($urlRouterProvider) => { app.config(($urlRouterProvider) => {
$urlRouterProvider.otherwise('/main'); $urlRouterProvider.otherwise('/main');
}); });

View File

@ -25,7 +25,9 @@ module.exports = function(
DrivesModel, DrivesModel,
SelectionStateModel, SelectionStateModel,
WarningModalService, WarningModalService,
DriveConstraintsModel) { DriveConstraintsModel,
AnalyticsService
) {
/** /**
* @summary The drive selector state * @summary The drive selector state
@ -105,10 +107,17 @@ module.exports = function(
* }); * });
*/ */
this.toggleDrive = (drive) => { this.toggleDrive = (drive) => {
AnalyticsService.logEvent('Toggle drive', {
drive,
previouslySelected: SelectionStateModel.isCurrentDrive(drive.device)
});
return shouldChangeDriveSelectionState(drive).then((canChangeDriveSelectionState) => { return shouldChangeDriveSelectionState(drive).then((canChangeDriveSelectionState) => {
if (canChangeDriveSelectionState) { if (canChangeDriveSelectionState) {
SelectionStateModel.toggleSetDrive(drive.device); SelectionStateModel.toggleSetDrive(drive.device);
} }
}); });
}; };
@ -153,6 +162,9 @@ module.exports = function(
return shouldChangeDriveSelectionState(drive).then((canChangeDriveSelectionState) => { return shouldChangeDriveSelectionState(drive).then((canChangeDriveSelectionState) => {
if (canChangeDriveSelectionState) { if (canChangeDriveSelectionState) {
SelectionStateModel.setDrive(drive.device); SelectionStateModel.setDrive(drive.device);
AnalyticsService.logEvent('Drive selected (double click)');
this.closeModal(); this.closeModal();
} }
}); });

View File

@ -28,7 +28,8 @@ const DriveSelector = angular.module(MODULE_NAME, [
require('../../models/drives'), require('../../models/drives'),
require('../../models/selection-state'), require('../../models/selection-state'),
require('../../models/drive-constraints'), require('../../models/drive-constraints'),
require('../../utils/byte-size/byte-size') require('../../utils/byte-size/byte-size'),
require('../../modules/analytics')
]); ]);
DriveSelector.controller('DriveSelectorController', require('./controllers/drive-selector')); DriveSelector.controller('DriveSelectorController', require('./controllers/drive-selector'));

View File

@ -23,7 +23,8 @@
const angular = require('angular'); const angular = require('angular');
const MODULE_NAME = 'Etcher.Components.Modal'; const MODULE_NAME = 'Etcher.Components.Modal';
const Modal = angular.module(MODULE_NAME, [ const Modal = angular.module(MODULE_NAME, [
require('angular-ui-bootstrap') require('angular-ui-bootstrap'),
require('../../modules/analytics')
]); ]);
Modal.service('ModalService', require('./services/modal')); Modal.service('ModalService', require('./services/modal'));

View File

@ -18,7 +18,7 @@
const _ = require('lodash'); const _ = require('lodash');
module.exports = function($uibModal, $q) { module.exports = function($uibModal, $q, AnalyticsService) {
/** /**
* @summary Open a modal * @summary Open a modal
@ -44,6 +44,10 @@ module.exports = function($uibModal, $q) {
size: 'sm' size: 'sm'
}); });
AnalyticsService.logEvent('Open modal', {
template: options.template
});
const modal = $uibModal.open({ const modal = $uibModal.open({
animation: true, animation: true,
templateUrl: options.template, templateUrl: options.template,
@ -55,15 +59,27 @@ module.exports = function($uibModal, $q) {
return { return {
close: modal.close, close: modal.close,
result: $q((resolve, reject) => { result: $q((resolve, reject) => {
modal.result modal.result.then((value) => {
.then(resolve) AnalyticsService.logEvent('Modal accepted', {
.catch((error) => { value
});
resolve(value);
}).catch((error) => {
// Bootstrap doesn't 'resolve' these but cancels the dialog // Bootstrap doesn't 'resolve' these but cancels the dialog
if (error === 'escape key press' || error === 'backdrop click') { if (error === 'escape key press' || error === 'backdrop click') {
AnalyticsService.logEvent('Modal rejected', {
method: error
});
return resolve(); return resolve();
} }
AnalyticsService.logEvent('Modal rejected', {
value: error
});
return reject(error); return reject(error);
}); });
}) })

View File

@ -16,7 +16,7 @@
'use strict'; 'use strict';
module.exports = function($uibModalInstance, SettingsModel, UPDATE_NOTIFIER_SLEEP_DAYS, options) { module.exports = function($uibModalInstance, SettingsModel, AnalyticsService, UPDATE_NOTIFIER_SLEEP_DAYS, options) {
// We update this value in this controller since its the only place // We update this value in this controller since its the only place
// where we can be sure the modal was really presented to the user. // where we can be sure the modal was really presented to the user.
@ -56,6 +56,11 @@ module.exports = function($uibModalInstance, SettingsModel, UPDATE_NOTIFIER_SLEE
* UpdateNotifierController.closeModal(); * UpdateNotifierController.closeModal();
*/ */
this.closeModal = () => { this.closeModal = () => {
AnalyticsService.logEvent('Close update modal', {
sleepUpdateCheck: this.sleepUpdateCheck,
notifyVersion: options.version
});
$uibModalInstance.dismiss(); $uibModalInstance.dismiss();
}; };

View File

@ -26,7 +26,8 @@ const UpdateNotifier = angular.module(MODULE_NAME, [
require('../modal/modal'), require('../modal/modal'),
require('../../models/settings'), require('../../models/settings'),
require('../../utils/manifest-bind/manifest-bind'), require('../../utils/manifest-bind/manifest-bind'),
require('../../os/open-external/open-external') require('../../os/open-external/open-external'),
require('../../modules/analytics')
]); ]);
/** /**

View File

@ -24,9 +24,11 @@ const _ = require('lodash');
const angular = require('angular'); const angular = require('angular');
const username = require('username'); const username = require('username');
const isRunningInAsar = require('electron-is-running-in-asar'); const isRunningInAsar = require('electron-is-running-in-asar');
const app = require('electron').remote.app;
const errors = require('../../shared/errors'); const errors = require('../../shared/errors');
const os = require('os');
const packageJSON = require('../../../package.json'); const packageJSON = require('../../../package.json');
const arch = require('arch');
const utils = require('../../shared/utils');
// Force Mixpanel snippet to load Mixpanel locally // Force Mixpanel snippet to load Mixpanel locally
// instead of using a CDN for performance reasons // instead of using a CDN for performance reasons
@ -40,6 +42,26 @@ const analytics = angular.module(MODULE_NAME, [
require('../models/settings') require('../models/settings')
]); ]);
/**
* @summary Get host architecture
* @function
* @private
*
* @description
* We need this because node's os.arch() returns the process architecture
* See: https://github.com/nodejs/node-v0.x-archive/issues/2862
*
* @returns {String} Host architecture
*
* @example
* if (getHostArchitecture() === 'x64') {
* console.log('Host architecture is x64');
* }
*/
const getHostArchitecture = () => {
return _.includes([ 'ia32', 'x64' ], process.arch) ? arch().replace('x86', 'ia32') : process.arch;
};
// Mixpanel integration // Mixpanel integration
// https://github.com/kuhnza/angular-mixpanel // https://github.com/kuhnza/angular-mixpanel
@ -47,17 +69,16 @@ analytics.config(($mixpanelProvider) => {
$mixpanelProvider.apiKey('63e5fc4563e00928da67d1226364dd4c'); $mixpanelProvider.apiKey('63e5fc4563e00928da67d1226364dd4c');
$mixpanelProvider.superProperties({ $mixpanelProvider.superProperties({
electron: process.versions.electron,
/* eslint-disable camelcase */
distinct_id: username.sync(),
/* eslint-enable camelcase */
electron: app.getVersion(),
node: process.version, node: process.version,
arch: process.arch, arch: process.arch,
version: packageJSON.version version: packageJSON.version,
osPlatform: os.platform(),
hostArch: getHostArchitecture(),
osRelease: os.release(),
cpuCores: os.cpus().length,
totalMemory: os.totalmem(),
startFreeMemory: os.freemem()
}); });
}); });
@ -120,18 +141,14 @@ analytics.service('AnalyticsService', function($log, $window, $mixpanel, Setting
* }); * });
*/ */
this.logEvent = (message, data) => { this.logEvent = (message, data) => {
const flatStartCaseData = utils.makeFlatStartCaseObject(data);
if (SettingsModel.get('errorReporting') && isRunningInAsar()) { if (SettingsModel.get('errorReporting') && isRunningInAsar()) {
$mixpanel.track(message, flatStartCaseData);
// Clone data before passing it to `mixpanel.track`
// since this function mutates the object adding
// some custom private Mixpanel properties.
$mixpanel.track(message, _.clone(data));
} }
const debugMessage = _.attempt(() => { const debugMessage = _.attempt(() => {
if (data) { if (flatStartCaseData) {
return `${message} (${JSON.stringify(data)})`; return `${message} (${JSON.stringify(flatStartCaseData)})`;
} }
return message; return message;

View File

@ -24,7 +24,9 @@ const angular = require('angular');
const url = require('url'); const url = require('url');
const MODULE_NAME = 'Etcher.OS.OpenExternal'; const MODULE_NAME = 'Etcher.OS.OpenExternal';
const OSOpenExternal = angular.module(MODULE_NAME, []); const OSOpenExternal = angular.module(MODULE_NAME, [
require('../../modules/analytics')
]);
OSOpenExternal.service('OSOpenExternalService', require('./services/open-external')); OSOpenExternal.service('OSOpenExternalService', require('./services/open-external'));
OSOpenExternal.directive('osOpenExternal', require('./directives/open-external')); OSOpenExternal.directive('osOpenExternal', require('./directives/open-external'));

View File

@ -18,7 +18,7 @@
const electron = require('electron'); const electron = require('electron');
module.exports = function() { module.exports = function(AnalyticsService) {
/** /**
* @summary Open an external resource * @summary Open an external resource
@ -31,6 +31,10 @@ module.exports = function() {
* OSOpenExternalService.open('https://www.google.com'); * OSOpenExternalService.open('https://www.google.com');
*/ */
this.open = (url) => { this.open = (url) => {
AnalyticsService.logEvent('Open external link', {
url
});
if (url) { if (url) {
electron.shell.openExternal(url); electron.shell.openExternal(url);
} }

View File

@ -16,7 +16,7 @@
'use strict'; 'use strict';
module.exports = function(SelectionStateModel, AnalyticsService, ErrorService, DriveSelectorService) { module.exports = function(SelectionStateModel, AnalyticsService, ErrorService, DriveSelectorService, SettingsModel) {
/** /**
* @summary Open drive selector * @summary Open drive selector
@ -35,7 +35,8 @@ module.exports = function(SelectionStateModel, AnalyticsService, ErrorService, D
SelectionStateModel.setDrive(drive.device); SelectionStateModel.setDrive(drive.device);
AnalyticsService.logEvent('Select drive', { AnalyticsService.logEvent('Select drive', {
device: drive.device device: drive.device,
unsafeMode: SettingsModel.get('unsafeMode')
}); });
}).catch(ErrorService.reportException); }).catch(ErrorService.reportException);
}; };

View File

@ -36,11 +36,14 @@ module.exports = function(
* @function * @function
* @public * @public
* *
* @param {String} image - image path * @param {Object} image - image
* @param {Object} drive - drive * @param {Object} drive - drive
* *
* @example * @example
* FlashController.flashImageToDrive('rpi.img', { * FlashController.flashImageToDrive({
* path: 'rpi.img',
* size: 1000000000
* }, {
* device: '/dev/disk2', * device: '/dev/disk2',
* description: 'Foo', * description: 'Foo',
* size: 99999, * size: 99999,
@ -59,10 +62,11 @@ module.exports = function(
AnalyticsService.logEvent('Flash', { AnalyticsService.logEvent('Flash', {
image, image,
device: drive.device drive,
unmountOnSuccess: SettingsModel.get('unmountOnSuccess')
}); });
ImageWriterService.flash(image, drive).then(() => { ImageWriterService.flash(image.path, drive).then(() => {
if (FlashStateModel.wasLastFlashCancelled()) { if (FlashStateModel.wasLastFlashCancelled()) {
return; return;
} }
@ -83,7 +87,9 @@ module.exports = function(
} else { } else {
FlashErrorModalService.show(messages.error.genericFlashError()); FlashErrorModalService.show(messages.error.genericFlashError());
ErrorService.reportException(error); ErrorService.reportException(error);
AnalyticsService.logEvent('Flash error'); AnalyticsService.logEvent('Flash error', {
error
});
} }
}) })

View File

@ -115,11 +115,14 @@ module.exports = function(
* ImageSelectionController.openImageSelector(); * ImageSelectionController.openImageSelector();
*/ */
this.openImageSelector = () => { this.openImageSelector = () => {
AnalyticsService.logEvent('Open image selector');
OSDialogService.selectImage().then((image) => { OSDialogService.selectImage().then((image) => {
// Avoid analytics and selection state changes // Avoid analytics and selection state changes
// if no file was resolved from the dialog. // if no file was resolved from the dialog.
if (!image) { if (!image) {
AnalyticsService.logEvent('Image selector closed');
return; return;
} }
@ -136,8 +139,11 @@ module.exports = function(
* ImageSelectionController.reselectImage(); * ImageSelectionController.reselectImage();
*/ */
this.reselectImage = () => { this.reselectImage = () => {
AnalyticsService.logEvent('Reselect image', {
previousImage: SelectionStateModel.getImage()
});
this.openImageSelector(); this.openImageSelector();
AnalyticsService.logEvent('Reselect image');
}; };
}; };

View File

@ -23,7 +23,8 @@ module.exports = function(
SettingsModel, SettingsModel,
TooltipModalService, TooltipModalService,
ErrorService, ErrorService,
OSOpenExternalService OSOpenExternalService,
AnalyticsService
) { ) {
// Expose several modules to the template for convenience // Expose several modules to the template for convenience
@ -76,6 +77,10 @@ module.exports = function(
* MainController.showSelectedImageDetails() * MainController.showSelectedImageDetails()
*/ */
this.showSelectedImageDetails = () => { this.showSelectedImageDetails = () => {
AnalyticsService.logEvent('Show selected image tooltip', {
imagePath: SelectionStateModel.getImagePath()
});
return TooltipModalService.show({ return TooltipModalService.show({
title: 'Image File Name', title: 'Image File Name',
message: SelectionStateModel.getImagePath() message: SelectionStateModel.getImagePath()

View File

@ -83,7 +83,7 @@
percentage="main.state.getFlashState().percentage" percentage="main.state.getFlashState().percentage"
striped="{{ main.state.getFlashState().type == 'check' }}" striped="{{ main.state.getFlashState().type == 'check' }}"
ng-attr-active="{{ main.state.isFlashing() }}" ng-attr-active="{{ main.state.isFlashing() }}"
ng-click="flash.flashImageToDrive(main.selection.getImagePath(), main.selection.getDrive())" ng-click="flash.flashImageToDrive(main.selection.getImage(), main.selection.getDrive())"
ng-disabled="main.shouldFlashStepBeDisabled() || main.state.getLastFlashErrorCode()"> ng-disabled="main.shouldFlashStepBeDisabled() || main.state.getLastFlashErrorCode()">
<span ng-bind="flash.getProgressButtonLabel()"></span> <span ng-bind="flash.getProgressButtonLabel()"></span>
</progress-button> </progress-button>

View File

@ -17,8 +17,9 @@
'use strict'; 'use strict';
const os = require('os'); const os = require('os');
const _ = require('lodash');
module.exports = function(WarningModalService, SettingsModel, ErrorService) { module.exports = function(WarningModalService, SettingsModel, ErrorService, AnalyticsService) {
/** /**
* @summary Client platform * @summary Client platform
@ -53,34 +54,48 @@ module.exports = function(WarningModalService, SettingsModel, ErrorService) {
this.model = SettingsModel; this.model = SettingsModel;
/** /**
* @summary Enable a dangerous setting * @summary Toggle setting
* @function * @function
* @public * @public
* *
* @param {String} name - setting name * @description
* @param {Object} options - options * If warningOptions is given, it should be an object having `description` and `confirmationLabel`;
* @param {String} options.description - modal description * these will be used to present a user confirmation modal before enabling the setting.
* @param {String} options.confirmationLabel - modal confirmation label * If warningOptions is missing, no confirmation modal is displayed.
*
* @param {String} setting - setting key
* @param {Object} [options] - options
* @param {String} [options.description] - warning modal description
* @param {String} [options.confirmationLabel] - warning modal confirmation label
* @returns {Undefined} * @returns {Undefined}
* *
* @example * @example
* SettingsController.enableDangerousSetting('unsafeMode', { * SettingsController.toggle('unsafeMode', {
* description: 'Don\'t do this!', * description: 'Don\'t do this!',
* confirmationLabel: 'Do it!' * confirmationLabel: 'Do it!'
* }); * });
*/ */
this.enableDangerousSetting = (name, options) => { this.toggle = (setting, options) => {
if (!this.currentData[name]) {
this.model.set(name, false); const value = this.currentData[setting];
return this.refreshSettings(); const dangerous = !_.isUndefined(options);
AnalyticsService.logEvent('Toggle setting', {
setting,
value,
dangerous
});
if (!value || !dangerous) {
return this.model.set(setting, value);
} }
// Keep the checkbox unchecked until the user confirms // Keep the checkbox unchecked until the user confirms
this.currentData[name] = false; this.currentData[setting] = false;
return WarningModalService.display(options).then((userAccepted) => { return WarningModalService.display(options).then((userAccepted) => {
if (userAccepted) { if (userAccepted) {
this.model.set(name, true); this.model.set(setting, true);
this.refreshSettings(); this.refreshSettings();
} }
}).catch(ErrorService.reportException); }).catch(ErrorService.reportException);

View File

@ -26,7 +26,8 @@ const SettingsPage = angular.module(MODULE_NAME, [
require('angular-ui-router'), require('angular-ui-router'),
require('../../components/warning-modal/warning-modal'), require('../../components/warning-modal/warning-modal'),
require('../../models/settings'), require('../../models/settings'),
require('../../modules/error') require('../../modules/error'),
require('../../modules/analytics')
]); ]);
SettingsPage.controller('SettingsController', require('./controllers/settings')); SettingsPage.controller('SettingsController', require('./controllers/settings'));

View File

@ -5,7 +5,7 @@
<label> <label>
<input type="checkbox" <input type="checkbox"
ng-model="settings.currentData.errorReporting" ng-model="settings.currentData.errorReporting"
ng-change="settings.model.set('errorReporting', settings.currentData.errorReporting)"> ng-change="settings.toggle('errorReporting')">
<span>Report errors</span> <span>Report errors</span>
</label> </label>
@ -15,7 +15,7 @@
<label> <label>
<input type="checkbox" <input type="checkbox"
ng-model="settings.currentData.unmountOnSuccess" ng-model="settings.currentData.unmountOnSuccess"
ng-change="settings.model.set('unmountOnSuccess', settings.currentData.unmountOnSuccess)"> ng-change="settings.toggle('unmountOnSuccess')">
<!-- On Windows, "Unmounting" basically means "ejecting". --> <!-- On Windows, "Unmounting" basically means "ejecting". -->
<!-- On top of that, Windows users are usually not even --> <!-- On top of that, Windows users are usually not even -->
@ -34,7 +34,7 @@
<label> <label>
<input type="checkbox" <input type="checkbox"
ng-model="settings.currentData.validateWriteOnSuccess" ng-model="settings.currentData.validateWriteOnSuccess"
ng-change="settings.model.set('validateWriteOnSuccess', settings.currentData.validateWriteOnSuccess)"> ng-change="settings.toggle('validateWriteOnSuccess')">
<span>Validate write on success</span> <span>Validate write on success</span>
</label> </label>
@ -46,7 +46,7 @@
<label> <label>
<input type="checkbox" <input type="checkbox"
ng-model="settings.currentData.unsafeMode" ng-model="settings.currentData.unsafeMode"
ng-change="settings.enableDangerousSetting('unsafeMode', { ng-change="settings.toggle('unsafeMode', {
description: 'Are you sure you want to turn this on? You will be able to overwrite your system drives if you\'re not careful.', description: 'Are you sure you want to turn this on? You will be able to overwrite your system drives if you\'re not careful.',
confirmationLabel: 'Enable unsafe mode' confirmationLabel: 'Enable unsafe mode'
})"> })">

83
lib/shared/utils.js Normal file
View File

@ -0,0 +1,83 @@
/*
* Copyright 2017 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 flatten = require('flat').flatten;
const deepMapKeys = require('deep-map-keys');
/**
* @summary Create a flattened copy of the object with all keys transformed in start case
* @function
* @public
*
* @param {*} object - object to transform
* @returns {*} transformed object
*
* @example
* const object = makeFlatStartCaseObject({
* image: {
* size: 10000000000,
* recommendedSize: 10000000000
* }
* })
*
* console.log(object)
* > {
* > 'Image Size': 10000000000,
* > 'Image Recommended Size': 10000000000
* > }
*/
exports.makeFlatStartCaseObject = (object) => {
if (_.isUndefined(object)) {
return object;
}
// Transform primitives to objects
if (!_.isObject(object)) {
return {
Value: object
};
}
if (_.isArray(object)) {
return _.map(object, (property) => {
if (_.isObject(property)) {
return exports.makeFlatStartCaseObject(property);
}
return property;
});
}
const transformedKeysObject = deepMapKeys(object, (key) => {
// Preserve environment variables
const regex = /^[A-Z_]+$/;
if (regex.test(key)) {
return key;
}
return _.startCase(key);
});
return flatten(transformedKeysObject, {
delimiter: ' ',
safe: true
});
};

45
npm-shrinkwrap.json generated
View File

@ -64,6 +64,11 @@
"from": "any-promise@>=1.1.0 <2.0.0", "from": "any-promise@>=1.1.0 <2.0.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz" "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz"
}, },
"arch": {
"version": "2.1.0",
"from": "arch@2.1.0",
"resolved": "https://registry.npmjs.org/arch/-/arch-2.1.0.tgz"
},
"archive-type": { "archive-type": {
"version": "3.2.0", "version": "3.2.0",
"from": "archive-type@>=3.2.0 <4.0.0", "from": "archive-type@>=3.2.0 <4.0.0",
@ -233,6 +238,11 @@
"from": "cross-spawn-async@>=2.1.1 <3.0.0", "from": "cross-spawn-async@>=2.1.1 <3.0.0",
"resolved": "https://registry.npmjs.org/cross-spawn-async/-/cross-spawn-async-2.2.4.tgz" "resolved": "https://registry.npmjs.org/cross-spawn-async/-/cross-spawn-async-2.2.4.tgz"
}, },
"d": {
"version": "0.1.1",
"from": "d@>=0.1.1 <0.2.0",
"resolved": "https://registry.npmjs.org/d/-/d-0.1.1.tgz"
},
"debug": { "debug": {
"version": "2.6.0", "version": "2.6.0",
"from": "debug@>=2.2.0 <3.0.0", "from": "debug@>=2.2.0 <3.0.0",
@ -243,6 +253,11 @@
"from": "decamelize@>=1.1.1 <2.0.0", "from": "decamelize@>=1.1.1 <2.0.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
}, },
"deep-map-keys": {
"version": "1.2.0",
"from": "deep-map-keys@1.2.0",
"resolved": "https://registry.npmjs.org/deep-map-keys/-/deep-map-keys-1.2.0.tgz"
},
"defaults": { "defaults": {
"version": "1.0.3", "version": "1.0.3",
"from": "defaults@>=1.0.3 <2.0.0", "from": "defaults@>=1.0.3 <2.0.0",
@ -287,6 +302,26 @@
"from": "error-ex@>=1.2.0 <2.0.0", "from": "error-ex@>=1.2.0 <2.0.0",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.0.tgz" "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.0.tgz"
}, },
"es5-ext": {
"version": "0.10.12",
"from": "es5-ext@>=0.10.11 <0.11.0",
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.12.tgz"
},
"es6-iterator": {
"version": "2.0.0",
"from": "es6-iterator@>=2.0.0 <3.0.0",
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.0.tgz"
},
"es6-symbol": {
"version": "3.1.0",
"from": "es6-symbol@>=3.1.0 <3.2.0",
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.0.tgz"
},
"es6-weak-map": {
"version": "2.0.1",
"from": "es6-weak-map@>=2.0.1 <3.0.0",
"resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.1.tgz"
},
"escape-string-regexp": { "escape-string-regexp": {
"version": "1.0.5", "version": "1.0.5",
"from": "escape-string-regexp@>=1.0.2 <2.0.0", "from": "escape-string-regexp@>=1.0.2 <2.0.0",
@ -381,6 +416,11 @@
"from": "find-up@>=1.0.0 <2.0.0", "from": "find-up@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz" "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz"
}, },
"flat": {
"version": "2.0.1",
"from": "flat@2.0.1",
"resolved": "https://registry.npmjs.org/flat/-/flat-2.0.1.tgz"
},
"flexboxgrid": { "flexboxgrid": {
"version": "6.3.0", "version": "6.3.0",
"from": "flexboxgrid@>=6.3.0 <7.0.0", "from": "flexboxgrid@>=6.3.0 <7.0.0",
@ -465,6 +505,11 @@
"from": "is-arrayish@>=0.2.1 <0.3.0", "from": "is-arrayish@>=0.2.1 <0.3.0",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz"
}, },
"is-buffer": {
"version": "1.1.4",
"from": "is-buffer@>=1.1.0 <2.0.0",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.4.tgz"
},
"is-builtin-module": { "is-builtin-module": {
"version": "1.0.0", "version": "1.0.0",
"from": "is-builtin-module@>=1.0.0 <2.0.0", "from": "is-builtin-module@>=1.0.0 <2.0.0",

View File

@ -69,15 +69,18 @@
"angular-seconds-to-date": "^1.0.0", "angular-seconds-to-date": "^1.0.0",
"angular-ui-bootstrap": "^2.5.0", "angular-ui-bootstrap": "^2.5.0",
"angular-ui-router": "^0.4.2", "angular-ui-router": "^0.4.2",
"arch": "^2.1.0",
"archive-type": "^3.2.0", "archive-type": "^3.2.0",
"bluebird": "^3.0.5", "bluebird": "^3.0.5",
"bootstrap-sass": "^3.3.5", "bootstrap-sass": "^3.3.5",
"chalk": "^1.1.3", "chalk": "^1.1.3",
"deep-map-keys": "^1.2.0",
"drivelist": "^5.0.16", "drivelist": "^5.0.16",
"electron-is-running-in-asar": "^1.0.0", "electron-is-running-in-asar": "^1.0.0",
"etcher-image-write": "^9.0.1", "etcher-image-write": "^9.0.1",
"etcher-latest-version": "^1.0.0", "etcher-latest-version": "^1.0.0",
"file-tail": "^0.3.0", "file-tail": "^0.3.0",
"flat": "^2.0.1",
"flexboxgrid": "^6.3.0", "flexboxgrid": "^6.3.0",
"gzip-uncompressed-size": "^1.0.0", "gzip-uncompressed-size": "^1.0.0",
"immutable": "^3.8.1", "immutable": "^3.8.1",

127
tests/shared/utils.spec.js Normal file
View File

@ -0,0 +1,127 @@
/*
* Copyright 2017 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 m = require('mochainon');
const utils = require('../../lib/shared/utils');
describe('Shared: Utils', function() {
describe('.makeFlatStartCaseObject()', function() {
it('should return undefined if given undefined', function() {
m.chai.expect(utils.makeFlatStartCaseObject(undefined)).to.be.undefined;
});
it('should return flat object with start case keys if given nested object', function() {
const object = {
person: {
firstName: 'John',
lastName: 'Doe',
address: {
streetNumber: 13,
streetName: 'Elm'
}
}
};
m.chai.expect(utils.makeFlatStartCaseObject(object)).to.deep.equal({
'Person First Name': 'John',
'Person Last Name': 'Doe',
'Person Address Street Number': 13,
'Person Address Street Name': 'Elm'
});
});
it('should return an object with the key `value` if given `false`', function() {
m.chai.expect(utils.makeFlatStartCaseObject(false)).to.deep.equal({
Value: false
});
});
it('should return an object with the key `value` if given `null`', function() {
m.chai.expect(utils.makeFlatStartCaseObject(null)).to.deep.equal({
Value: null
});
});
it('should preserve environment variable', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
ETCHER_DISABLE_UPDATES: true
})).to.deep.equal({
ETCHER_DISABLE_UPDATES: true
});
});
it('should preserve environment variables inside objects', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
foo: {
FOO_BAR_BAZ: 3
}
})).to.deep.equal({
'Foo FOO_BAR_BAZ': 3
});
});
it('should insert space after key starting with number', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
foo: {
'1key': 1
}
})).to.deep.equal({
'Foo 1 Key': 1
});
});
it('should not modify start case keys', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
Foo: {
'Start Case Key': 42
}
})).to.deep.equal({
'Foo Start Case Key': 42
});
});
it('should not modify arrays', function() {
m.chai.expect(utils.makeFlatStartCaseObject([ 1, 2, {
nested: 3
} ])).to.deep.equal([ 1, 2, {
Nested: 3
} ]);
});
it('should not modify nested arrays', function() {
m.chai.expect(utils.makeFlatStartCaseObject({
values: [ 1, 2, {
nested: 3
} ]
})).to.deep.equal({
Values: [ 1, 2, {
Nested: 3
} ]
});
});
it('should leave nested arrays nested', function() {
m.chai.expect(utils.makeFlatStartCaseObject([ 1, 2, [ 3, 4 ] ])).to.deep.equal([ 1, 2, [ 3, 4 ] ]);
});
});
});