Bug 1321675 - Add / remove custom devices in RDM. r=gl draft
authorJ. Ryan Stinnett <jryans@gmail.com>
Fri, 03 Feb 2017 17:09:01 -0600
changeset 479660 f3f83f0c42714fe71ec50a7cbce0d60fc597c0a7
parent 479659 836e8c0a4b596a9d37287a09028b736c5fabbd7a
child 544744 813b1af20256bfdedf38165dc989be6b05ec6c2e
push id44319
push userbmo:jryans@gmail.com
push dateTue, 07 Feb 2017 02:36:30 +0000
reviewersgl
bugs1321675
milestone54.0a1
Bug 1321675 - Add / remove custom devices in RDM. r=gl MozReview-Commit-ID: E84zxk9n1AL
devtools/client/responsive.html/actions/devices.js
devtools/client/responsive.html/actions/index.js
devtools/client/responsive.html/app.js
devtools/client/responsive.html/components/device-adder.js
devtools/client/responsive.html/components/device-modal.js
devtools/client/responsive.html/components/viewport-dimension.js
devtools/client/responsive.html/components/viewport.js
devtools/client/responsive.html/index.css
devtools/client/responsive.html/reducers/devices.js
devtools/client/responsive.html/test/browser/browser.ini
devtools/client/responsive.html/test/browser/browser_device_custom.js
devtools/client/responsive.html/test/browser/head.js
devtools/client/shared/devices.js
--- a/devtools/client/responsive.html/actions/devices.js
+++ b/devtools/client/responsive.html/actions/devices.js
@@ -5,21 +5,23 @@
 "use strict";
 
 const {
   ADD_DEVICE,
   ADD_DEVICE_TYPE,
   LOAD_DEVICE_LIST_START,
   LOAD_DEVICE_LIST_ERROR,
   LOAD_DEVICE_LIST_END,
+  REMOVE_DEVICE,
   UPDATE_DEVICE_DISPLAYED,
   UPDATE_DEVICE_MODAL,
 } = require("./index");
+const { removeDeviceAssociation } = require("./viewports");
 
-const { getDevices } = require("devtools/client/shared/devices");
+const { addDevice, getDevices, removeDevice } = require("devtools/client/shared/devices");
 
 const Services = require("Services");
 const DISPLAYED_DEVICES_PREF = "devtools.responsive.html.displayedDeviceList";
 
 /**
  * Returns an object containing the user preference of displayed devices.
  *
  * @return {Object} containing two Sets:
@@ -66,42 +68,74 @@ function updatePreferredDevices(devices)
 
 module.exports = {
 
   // This function is only exported for testing purposes
   _loadPreferredDevices: loadPreferredDevices,
 
   updatePreferredDevices: updatePreferredDevices,
 
+  addCustomDevice(device) {
+    return function* (dispatch) {
+      // Add custom device to device storage
+      yield addDevice(device, "custom");
+      dispatch({
+        type: ADD_DEVICE,
+        device,
+        deviceType: "custom",
+      });
+    };
+  },
+
   addDevice(device, deviceType) {
     return {
       type: ADD_DEVICE,
       device,
       deviceType,
     };
   },
 
   addDeviceType(deviceType) {
     return {
       type: ADD_DEVICE_TYPE,
       deviceType,
     };
   },
 
+  removeCustomDevice(device) {
+    return function* (dispatch, getState) {
+      // Check if the custom device is currently associated with any viewports
+      let { viewports } = getState();
+      for (let viewport of viewports) {
+        if (viewport.device == device.name) {
+          dispatch(removeDeviceAssociation(viewport.id));
+        }
+      }
+
+      // Remove custom device from device storage
+      yield removeDevice(device, "custom");
+      dispatch({
+        type: REMOVE_DEVICE,
+        device,
+        deviceType: "custom",
+      });
+    };
+  },
+
   updateDeviceDisplayed(device, deviceType, displayed) {
     return {
       type: UPDATE_DEVICE_DISPLAYED,
       device,
       deviceType,
       displayed,
     };
   },
 
   loadDevices() {
-    return function* (dispatch, getState) {
+    return function* (dispatch) {
       dispatch({ type: LOAD_DEVICE_LIST_START });
       let preferredDevices = loadPreferredDevices();
       let devices;
 
       try {
         devices = yield getDevices();
       } catch (e) {
         console.error("Could not load device list: " + e);
@@ -120,17 +154,20 @@ module.exports = {
             displayed: preferredDevices.added.has(device.name) ||
               (device.featured && !(preferredDevices.removed.has(device.name))),
           });
 
           dispatch(module.exports.addDevice(newDevice, type));
         }
       }
 
-      dispatch(module.exports.addDeviceType("custom"));
+      // Add an empty "custom" type if it doesn't exist in device storage
+      if (!devices.TYPES.find(type => type == "custom")) {
+        dispatch(module.exports.addDeviceType("custom"));
+      }
 
       dispatch({ type: LOAD_DEVICE_LIST_END });
     };
   },
 
   updateDeviceModal(isOpen, modalOpenedFromViewport = null) {
     return {
       type: UPDATE_DEVICE_MODAL,
--- a/devtools/client/responsive.html/actions/index.js
+++ b/devtools/client/responsive.html/actions/index.js
@@ -48,16 +48,19 @@ createEnum([
   "LOAD_DEVICE_LIST_START",
 
   // Indicates that the device list loading action threw an error
   "LOAD_DEVICE_LIST_ERROR",
 
   // Indicates that the device list has been loaded successfully
   "LOAD_DEVICE_LIST_END",
 
+  // Remove a device.
+  "REMOVE_DEVICE",
+
   // Remove the viewport's device assocation.
   "REMOVE_DEVICE_ASSOCIATION",
 
   // Resize the viewport.
   "RESIZE_VIEWPORT",
 
   // Rotate the viewport.
   "ROTATE_VIEWPORT",
--- a/devtools/client/responsive.html/app.js
+++ b/devtools/client/responsive.html/app.js
@@ -6,16 +6,18 @@
 
 "use strict";
 
 const { createClass, createFactory, PropTypes, DOM: dom } =
   require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 
 const {
+  addCustomDevice,
+  removeCustomDevice,
   updateDeviceDisplayed,
   updateDeviceModal,
   updatePreferredDevices,
 } = require("./actions/devices");
 const { changeNetworkThrottling } = require("./actions/network-throttling");
 const { takeScreenshot } = require("./actions/screenshot");
 const { changeTouchSimulation } = require("./actions/touch-simulation");
 const {
@@ -39,16 +41,20 @@ let App = createClass({
     displayPixelRatio: Types.pixelRatio.value.isRequired,
     location: Types.location.isRequired,
     networkThrottling: PropTypes.shape(Types.networkThrottling).isRequired,
     screenshot: PropTypes.shape(Types.screenshot).isRequired,
     touchSimulation: PropTypes.shape(Types.touchSimulation).isRequired,
     viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
   },
 
+  onAddCustomDevice(device) {
+    this.props.dispatch(addCustomDevice(device));
+  },
+
   onBrowserMounted() {
     window.postMessage({ type: "browser-mounted" }, "*");
   },
 
   onChangeDevice(id, device, deviceType) {
     window.postMessage({
       type: "change-device",
       device,
@@ -94,16 +100,20 @@ let App = createClass({
   onDeviceListUpdate(devices) {
     updatePreferredDevices(devices);
   },
 
   onExit() {
     window.postMessage({ type: "exit" }, "*");
   },
 
+  onRemoveCustomDevice(device) {
+    this.props.dispatch(removeCustomDevice(device));
+  },
+
   onRemoveDeviceAssociation(id) {
     // TODO: Bug 1332754: Move messaging and logic into the action creator.
     window.postMessage({
       type: "remove-device-association",
     }, "*");
     this.props.dispatch(removeDeviceAssociation(id));
     this.props.dispatch(changeTouchSimulation(false));
     this.props.dispatch(changePixelRatio(id, 0));
@@ -136,24 +146,26 @@ let App = createClass({
       location,
       networkThrottling,
       screenshot,
       touchSimulation,
       viewports,
     } = this.props;
 
     let {
+      onAddCustomDevice,
       onBrowserMounted,
       onChangeDevice,
       onChangeNetworkThrottling,
       onChangePixelRatio,
       onChangeTouchSimulation,
       onContentResize,
       onDeviceListUpdate,
       onExit,
+      onRemoveCustomDevice,
       onRemoveDeviceAssociation,
       onResizeViewport,
       onRotateViewport,
       onScreenshot,
       onUpdateDeviceDisplayed,
       onUpdateDeviceModal,
     } = this;
 
@@ -199,17 +211,19 @@ let App = createClass({
         onRemoveDeviceAssociation,
         onRotateViewport,
         onResizeViewport,
         onUpdateDeviceModal,
       }),
       DeviceModal({
         devices,
         viewportTemplate: modalViewportTemplate,
+        onAddCustomDevice,
         onDeviceListUpdate,
+        onRemoveCustomDevice,
         onUpdateDeviceDisplayed,
         onUpdateDeviceModal,
       })
     );
   },
 
 });
 
--- a/devtools/client/responsive.html/components/device-adder.js
+++ b/devtools/client/responsive.html/components/device-adder.js
@@ -14,44 +14,85 @@ const Types = require("../types");
 const ViewportDimension = createFactory(require("./viewport-dimension"));
 
 module.exports = createClass({
   displayName: "DeviceAdder",
 
   propTypes: {
     devices: PropTypes.shape(Types.devices).isRequired,
     viewportTemplate: PropTypes.shape(Types.viewport).isRequired,
+    onAddCustomDevice: PropTypes.func.isRequired,
   },
 
   mixins: [ addons.PureRenderMixin ],
 
   getInitialState() {
     return {};
   },
 
+  componentWillReceiveProps(nextProps) {
+    let {
+      width,
+      height,
+    } = nextProps.viewportTemplate;
+
+    this.setState({
+      width,
+      height,
+    });
+  },
+
+  onChangeSize(width, height) {
+    this.setState({
+      width,
+      height,
+    });
+  },
+
   onDeviceAdderShow() {
     this.setState({
       deviceAdderDisplayed: true,
     });
   },
 
   onDeviceAdderSave() {
+    let {
+      devices,
+      onAddCustomDevice,
+    } = this.props;
+    if (!this.pixelRatioInput.checkValidity()) {
+      return;
+    }
+    if (devices.custom.find(device => device.name == this.nameInput.value)) {
+      this.nameInput.setCustomValidity("Device name already in use");
+      return;
+    }
     this.setState({
       deviceAdderDisplayed: false,
     });
+    onAddCustomDevice({
+      name: this.nameInput.value,
+      width: this.state.width,
+      height: this.state.height,
+      pixelRatio: parseFloat(this.pixelRatioInput.value),
+      userAgent: this.userAgentInput.value,
+      touch: this.touchInput.checked,
+    });
   },
 
   render() {
     let {
       devices,
       viewportTemplate,
     } = this.props;
 
     let {
       deviceAdderDisplayed,
+      height,
+      width,
     } = this.state;
 
     if (!deviceAdderDisplayed) {
       return dom.div(
         {
           id: "device-adder"
         },
         dom.button(
@@ -88,79 +129,103 @@ module.exports = createClass({
       });
     }
 
     return dom.div(
       {
         id: "device-adder"
       },
       dom.label(
-        {},
+        {
+          id: "device-adder-name",
+        },
         dom.span(
           {
             className: "device-adder-label",
           },
           getStr("responsive.deviceAdderName")
         ),
         dom.input({
           defaultValue: deviceName,
+          ref: input => {
+            this.nameInput = input;
+          },
         })
       ),
       dom.label(
-        {},
+        {
+          id: "device-adder-size",
+        },
         dom.span(
           {
             className: "device-adder-label"
           },
           getStr("responsive.deviceAdderSize")
         ),
         ViewportDimension({
           viewport: {
-            width: normalizedViewport.width,
-            height: normalizedViewport.height,
+            width,
+            height,
           },
+          onChangeSize: this.onChangeSize,
           onRemoveDeviceAssociation: () => {},
-          onResizeViewport: () => {},
         })
       ),
       dom.label(
-        {},
+        {
+          id: "device-adder-pixel-ratio",
+        },
         dom.span(
           {
             className: "device-adder-label"
           },
           getStr("responsive.deviceAdderPixelRatio")
         ),
         dom.input({
+          type: "number",
+          step: "any",
           defaultValue: normalizedViewport.pixelRatio,
+          ref: input => {
+            this.pixelRatioInput = input;
+          },
         })
       ),
       dom.label(
-        {},
+        {
+          id: "device-adder-user-agent",
+        },
         dom.span(
           {
             className: "device-adder-label"
           },
           getStr("responsive.deviceAdderUserAgent")
         ),
         dom.input({
           defaultValue: normalizedViewport.userAgent,
+          ref: input => {
+            this.userAgentInput = input;
+          },
         })
       ),
       dom.label(
-        {},
+        {
+          id: "device-adder-touch",
+        },
         dom.span(
           {
             className: "device-adder-label"
           },
           getStr("responsive.deviceAdderTouch")
         ),
         dom.input({
           type: "checkbox",
           defaultChecked: normalizedViewport.touch,
+          ref: input => {
+            this.touchInput = input;
+          },
         })
       ),
       dom.button(
         {
           id: "device-adder-save",
           onClick: this.onDeviceAdderSave,
         },
         getStr("responsive.deviceAdderSave")
--- a/devtools/client/responsive.html/components/device-modal.js
+++ b/devtools/client/responsive.html/components/device-modal.js
@@ -14,17 +14,19 @@ const Types = require("../types");
 const DeviceAdder = createFactory(require("./device-adder"));
 
 module.exports = createClass({
   displayName: "DeviceModal",
 
   propTypes: {
     devices: PropTypes.shape(Types.devices).isRequired,
     viewportTemplate: PropTypes.shape(Types.viewport).isRequired,
+    onAddCustomDevice: PropTypes.func.isRequired,
     onDeviceListUpdate: PropTypes.func.isRequired,
+    onRemoveCustomDevice: PropTypes.func.isRequired,
     onUpdateDeviceDisplayed: PropTypes.func.isRequired,
     onUpdateDeviceModal: PropTypes.func.isRequired,
   },
 
   mixins: [ addons.PureRenderMixin ],
 
   getInitialState() {
     return {};
@@ -106,16 +108,18 @@ module.exports = createClass({
       onUpdateDeviceModal(false);
     }
   },
 
   render() {
     let {
       devices,
       viewportTemplate,
+      onAddCustomDevice,
+      onRemoveCustomDevice,
       onUpdateDeviceModal,
     } = this.props;
 
     const sortedDevices = {};
     for (let type of devices.types) {
       sortedDevices[type] = Object.assign([], devices[type])
         .sort((a, b) => a.name.localeCompare(b.name));
     }
@@ -150,37 +154,53 @@ module.exports = createClass({
                 },
                 type
               ),
               sortedDevices[type].map(device => {
                 let details = getFormatStr(
                   "responsive.deviceDetails", device.width, device.height,
                   device.pixelRatio, device.userAgent, device.touch
                 );
+
+                let removeDeviceButton;
+                if (type == "custom") {
+                  removeDeviceButton = dom.button({
+                    className: "device-remove-button toolbar-button devtools-button",
+                    onClick: () => onRemoveCustomDevice(device),
+                  });
+                }
+
                 return dom.label(
                   {
                     className: "device-label",
                     key: device.name,
                     title: details,
                   },
                   dom.input({
                     className: "device-input-checkbox",
                     type: "checkbox",
                     value: device.name,
                     checked: this.state[device.name],
                     onChange: this.onDeviceCheckboxChange,
                   }),
-                  device.name
+                  dom.span(
+                    {
+                      className: "device-name",
+                    },
+                    device.name
+                  ),
+                  removeDeviceButton
                 );
               })
             );
           }),
           DeviceAdder({
             devices,
             viewportTemplate,
+            onAddCustomDevice,
           })
         ),
         dom.button(
           {
             id: "device-submit-button",
             onClick: this.onDeviceModalSubmit,
           },
           getStr("responsive.done")
--- a/devtools/client/responsive.html/components/viewport-dimension.js
+++ b/devtools/client/responsive.html/components/viewport-dimension.js
@@ -10,18 +10,18 @@ const { DOM: dom, createClass, PropTypes
 const Constants = require("../constants");
 const Types = require("../types");
 
 module.exports = createClass({
   displayName: "ViewportDimension",
 
   propTypes: {
     viewport: PropTypes.shape(Types.viewport).isRequired,
+    onChangeSize: PropTypes.func.isRequired,
     onRemoveDeviceAssociation: PropTypes.func.isRequired,
-    onResizeViewport: PropTypes.func.isRequired,
   },
 
   getInitialState() {
     let { width, height } = this.props.viewport;
 
     return {
       width,
       height,
@@ -113,18 +113,18 @@ module.exports = createClass({
       return;
     }
 
     // Change the device selector back to an unselected device
     // TODO: Bug 1332754: Logic like this probably belongs in the action creator.
     if (this.props.viewport.device) {
       this.props.onRemoveDeviceAssociation();
     }
-    this.props.onResizeViewport(parseInt(this.state.width, 10),
-                                parseInt(this.state.height, 10));
+    this.props.onChangeSize(parseInt(this.state.width, 10),
+                            parseInt(this.state.height, 10));
   },
 
   render() {
     let editableClass = "viewport-dimension-editable";
     let inputClass = "viewport-dimension-input";
 
     if (this.state.isEditing) {
       editableClass += " editing";
--- a/devtools/client/responsive.html/components/viewport.js
+++ b/devtools/client/responsive.html/components/viewport.js
@@ -86,18 +86,18 @@ module.exports = createClass({
     } = this;
 
     return dom.div(
       {
         className: "viewport",
       },
       ViewportDimension({
         viewport,
+        onChangeSize: onResizeViewport,
         onRemoveDeviceAssociation,
-        onResizeViewport,
       }),
       ResizableViewport({
         devices,
         location,
         screenshot,
         swapAfterMount,
         viewport,
         onBrowserMounted,
--- a/devtools/client/responsive.html/index.css
+++ b/devtools/client/responsive.html/index.css
@@ -491,22 +491,39 @@ select > option.divider {
   padding: 0 0 3px 23px;
 }
 
 .device-label {
   font-size: 11px;
   padding-bottom: 3px;
   display: flex;
   align-items: center;
+  /* Largest size without horizontal scrollbars */
+  max-width: 181px;
 }
 
 .device-input-checkbox {
   margin-right: 5px;
 }
 
+.device-name {
+  flex: 1;
+}
+
+.device-remove-button,
+.device-remove-button::before {
+  width: 12px;
+  height: 12px;
+}
+
+.device-remove-button::before {
+  background-image: url("./images/close.svg");
+  margin: -6px 0 0 -6px;
+}
+
 #device-submit-button {
   background-color: var(--theme-tab-toolbar-background);
   border-width: 1px 0 0 0;
   border-top-width: 1px;
   border-top-style: solid;
   border-top-color: var(--theme-splitter-color);
   color: var(--theme-body-color);
   width: 100%;
--- a/devtools/client/responsive.html/reducers/devices.js
+++ b/devtools/client/responsive.html/reducers/devices.js
@@ -5,16 +5,17 @@
 "use strict";
 
 const {
   ADD_DEVICE,
   ADD_DEVICE_TYPE,
   LOAD_DEVICE_LIST_START,
   LOAD_DEVICE_LIST_ERROR,
   LOAD_DEVICE_LIST_END,
+  REMOVE_DEVICE,
   UPDATE_DEVICE_DISPLAYED,
   UPDATE_DEVICE_MODAL,
 } = require("../actions/index");
 
 const Types = require("../types");
 
 const INITIAL_DEVICES = {
   types: [],
@@ -65,16 +66,28 @@ let reducers = {
   },
 
   [LOAD_DEVICE_LIST_END](devices, action) {
     return Object.assign({}, devices, {
       listState: Types.deviceListState.LOADED,
     });
   },
 
+  [REMOVE_DEVICE](devices, { device, deviceType }) {
+    let index = devices[deviceType].indexOf(device);
+    if (index < 0) {
+      return devices;
+    }
+    let list = [...devices[deviceType]];
+    list.splice(index, 1);
+    return Object.assign({}, devices, {
+      [deviceType]: list
+    });
+  },
+
   [UPDATE_DEVICE_MODAL](devices, { isOpen, modalOpenedFromViewport }) {
     return Object.assign({}, devices, {
       isModalOpen: isOpen,
       modalOpenedFromViewport,
     });
   },
 
 };
--- a/devtools/client/responsive.html/test/browser/browser.ini
+++ b/devtools/client/responsive.html/test/browser/browser.ini
@@ -12,16 +12,17 @@ support-files =
   !/devtools/client/commandline/test/helpers.js
   !/devtools/client/framework/test/shared-head.js
   !/devtools/client/framework/test/shared-redux-head.js
   !/devtools/client/inspector/test/shared-head.js
   !/devtools/client/shared/test/test-actor.js
   !/devtools/client/shared/test/test-actor-registry.js
 
 [browser_device_change.js]
+[browser_device_custom.js]
 [browser_device_modal_error.js]
 [browser_device_modal_exit.js]
 [browser_device_modal_submit.js]
 [browser_device_width.js]
 [browser_dpr_change.js]
 [browser_exit_button.js]
 [browser_frame_script_active.js]
 [browser_menu_item_01.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/test/browser/browser_device_custom.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test adding and removing custom devices via the modal.
+
+const device = {
+  name: "Test Device",
+  width: 400,
+  height: 570,
+  pixelRatio: 1.5,
+  userAgent: "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0",
+  touch: true,
+  firefoxOS: false,
+  os: "android",
+};
+
+const TEST_URL = "data:text/html;charset=utf-8,";
+const Types = require("devtools/client/responsive.html/types");
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+  let { toolWindow } = ui;
+  let { store, document } = toolWindow;
+  let React = toolWindow.require("devtools/client/shared/vendor/react");
+  let { Simulate } = React.addons.TestUtils;
+
+  // Wait until the viewport has been added and the device list has been loaded
+  yield waitUntilState(store, state => state.viewports.length == 1
+    && state.devices.listState == Types.deviceListState.LOADED);
+
+  let deviceSelector = document.querySelector(".viewport-device-selector");
+  let submitButton = document.querySelector("#device-submit-button");
+
+  openDeviceModal(ui);
+
+  info("Reveal device adder form, check that defaults match the viewport");
+  let adderShow = document.querySelector("#device-adder-show");
+  Simulate.click(adderShow);
+  testDeviceAdder(ui, {
+    name: "Custom Device",
+    width: 320,
+    height: 480,
+    pixelRatio: window.devicePixelRatio,
+    userAgent: navigator.userAgent,
+    touch: false,
+  });
+
+  info("Fill out device adder form and save");
+  setDeviceAdder(ui, device);
+  let adderSave = document.querySelector("#device-adder-save");
+  let saved = waitUntilState(store, state => state.devices.custom.length == 1);
+  Simulate.click(adderSave);
+  yield saved;
+
+  info("Enable device in modal");
+  let deviceCb = [...document.querySelectorAll(".device-input-checkbox")].find(cb => {
+    return cb.value == device.name;
+  });
+  ok(deviceCb, "Custom device checkbox added to modal");
+  deviceCb.click();
+  Simulate.click(submitButton);
+
+  info("Look for custom device in device selector");
+  let selectorOption = [...deviceSelector.options].find(opt => opt.value == device.name);
+  ok(selectorOption, "Custom device option added to device selector");
+});
+
+addRDMTask(TEST_URL, function* ({ ui }) {
+  let { toolWindow } = ui;
+  let { store, document } = toolWindow;
+  let React = toolWindow.require("devtools/client/shared/vendor/react");
+  let { Simulate } = React.addons.TestUtils;
+
+  // Wait until the viewport has been added and the device list has been loaded
+  yield waitUntilState(store, state => state.viewports.length == 1
+    && state.devices.listState == Types.deviceListState.LOADED);
+
+  let deviceSelector = document.querySelector(".viewport-device-selector");
+  let submitButton = document.querySelector("#device-submit-button");
+
+  info("Select existing device from the selector");
+  yield selectDevice(ui, "Test Device");
+
+  openDeviceModal(ui);
+
+  info("Reveal device adder form, check that defaults are based on selected device");
+  let adderShow = document.querySelector("#device-adder-show");
+  Simulate.click(adderShow);
+  testDeviceAdder(ui, Object.assign({}, device, {
+    name: "Test Device (Custom)",
+  }));
+
+  info("Remove previously added custom device");
+  let deviceRemoveButton = document.querySelector(".device-remove-button");
+  let removed = waitUntilState(store, state => state.devices.custom.length == 0);
+  Simulate.click(deviceRemoveButton);
+  yield removed;
+  Simulate.click(submitButton);
+
+  info("Ensure custom device was removed from device selector");
+  yield waitUntilState(store, state => state.viewports[0].device == "");
+  is(deviceSelector.value, "", "Device selector reset to no device");
+  let selectorOption = [...deviceSelector.options].find(opt => opt.value == device.name);
+  ok(!selectorOption, "Custom device option removed from device selector");
+});
+
+function testDeviceAdder(ui, expected) {
+  let { document } = ui.toolWindow;
+
+  let nameInput = document.querySelector("#device-adder-name input");
+  let [ widthInput, heightInput ] = document.querySelectorAll("#device-adder-size input");
+  let pixelRatioInput = document.querySelector("#device-adder-pixel-ratio input");
+  let userAgentInput = document.querySelector("#device-adder-user-agent input");
+  let touchInput = document.querySelector("#device-adder-touch input");
+
+  is(nameInput.value, expected.name, "Device name matches");
+  is(parseInt(widthInput.value, 10), expected.width, "Width matches");
+  is(parseInt(heightInput.value, 10), expected.height, "Height matches");
+  is(parseFloat(pixelRatioInput.value), expected.pixelRatio,
+     "devicePixelRatio matches");
+  is(userAgentInput.value, expected.userAgent, "User agent matches");
+  is(touchInput.checked, expected.touch, "Touch matches");
+}
+
+function setDeviceAdder(ui, value) {
+  let { toolWindow } = ui;
+  let { document } = ui.toolWindow;
+  let React = toolWindow.require("devtools/client/shared/vendor/react");
+  let { Simulate } = React.addons.TestUtils;
+
+  let nameInput = document.querySelector("#device-adder-name input");
+  let [ widthInput, heightInput ] = document.querySelectorAll("#device-adder-size input");
+  let pixelRatioInput = document.querySelector("#device-adder-pixel-ratio input");
+  let userAgentInput = document.querySelector("#device-adder-user-agent input");
+  let touchInput = document.querySelector("#device-adder-touch input");
+
+  nameInput.value = value.name;
+  Simulate.change(nameInput);
+  widthInput.value = value.width;
+  Simulate.change(widthInput);
+  Simulate.blur(widthInput);
+  heightInput.value = value.height;
+  Simulate.change(heightInput);
+  Simulate.blur(heightInput);
+  pixelRatioInput.value = value.pixelRatio;
+  Simulate.change(pixelRatioInput);
+  userAgentInput.value = value.userAgent;
+  Simulate.change(userAgentInput);
+  touchInput.checked = value.touch;
+  Simulate.change(touchInput);
+}
--- a/devtools/client/responsive.html/test/browser/head.js
+++ b/devtools/client/responsive.html/test/browser/head.js
@@ -55,16 +55,17 @@ Services.prefs.setCharPref("devtools.dev
 Services.prefs.setBoolPref("devtools.responsive.html.enabled", true);
 
 registerCleanupFunction(() => {
   flags.testing = false;
   Services.prefs.clearUserPref("devtools.devices.url");
   Services.prefs.clearUserPref("devtools.responsive.html.enabled");
   Services.prefs.clearUserPref("devtools.responsive.html.displayedDeviceList");
   asyncStorage.removeItem("devtools.devices.url_cache");
+  asyncStorage.removeItem("devtools.devices.local");
 });
 
 // This depends on the "devtools.responsive.html.enabled" pref
 const { ResponsiveUIManager } = require("resource://devtools/client/responsivedesign/responsivedesign.jsm");
 
 /**
  * Open responsive design mode for the given tab.
  */
@@ -237,40 +238,30 @@ function openDeviceModal({ toolWindow })
   info("Opening device modal through device selector.");
   select.value = OPEN_DEVICE_MODAL_VALUE;
   Simulate.change(select);
   ok(modal.classList.contains("opened") && !modal.classList.contains("closed"),
     "The device modal is displayed.");
 }
 
 function changeSelectValue({ toolWindow }, selector, value) {
+  let { document } = toolWindow;
+  let React = toolWindow.require("devtools/client/shared/vendor/react");
+  let { Simulate } = React.addons.TestUtils;
+
   info(`Selecting ${value} in ${selector}.`);
 
-  return new Promise(resolve => {
-    let select = toolWindow.document.querySelector(selector);
-    isnot(select, null, `selector "${selector}" should match an existing element.`);
-
-    let option = [...select.options].find(o => o.value === String(value));
-    isnot(option, undefined, `value "${value}" should match an existing option.`);
+  let select = document.querySelector(selector);
+  isnot(select, null, `selector "${selector}" should match an existing element.`);
 
-    let event = new toolWindow.UIEvent("change", {
-      view: toolWindow,
-      bubbles: true,
-      cancelable: true
-    });
+  let option = [...select.options].find(o => o.value === String(value));
+  isnot(option, undefined, `value "${value}" should match an existing option.`);
 
-    select.addEventListener("change", () => {
-      is(select.value, value,
-        `Select's option with value "${value}" should be selected.`);
-      resolve();
-    }, { once: true });
-
-    select.value = value;
-    select.dispatchEvent(event);
-  });
+  select.value = value;
+  Simulate.change(select);
 }
 
 const selectDevice = (ui, value) => Promise.all([
   once(ui, "device-changed"),
   changeSelectValue(ui, ".viewport-device-selector", value)
 ]);
 
 const selectDPR = (ui, value) =>
--- a/devtools/client/shared/devices.js
+++ b/devtools/client/shared/devices.js
@@ -4,85 +4,111 @@
 
 "use strict";
 
 const { getJSON } = require("devtools/client/shared/getjson");
 
 const DEVICES_URL = "devtools.devices.url";
 const { LocalizationHelper } = require("devtools/shared/l10n");
 const L10N = new LocalizationHelper("devtools/client/locales/device.properties");
+const { Task } = require("devtools/shared/task");
+
+loader.lazyRequireGetter(this, "asyncStorage", "devtools/shared/async-storage");
+
+const LOCAL_DEVICES = "devtools.devices.local";
 
 /* This is a catalog of common web-enabled devices and their properties,
  * intended for (mobile) device emulation.
  *
  * The properties of a device are:
  * - name: brand and model(s).
  * - width: viewport width.
  * - height: viewport height.
  * - pixelRatio: ratio from viewport to physical screen pixels.
  * - userAgent: UA string of the device's browser.
  * - touch: whether it has a touch screen.
- * - firefoxOS: whether Firefox OS is supported.
+ * - os: default OS, such as "ios", "fxos", "android".
  *
  * The device types are:
  *   ["phones", "tablets", "laptops", "televisions", "consoles", "watches"].
  *
+ * To propose new devices for the shared catalog, check out the repo at
+ * https://github.com/mozilla/simulated-devices and file a pull request.
+ *
  * You can easily add more devices to this catalog from your own code (e.g. an
  * addon) like so:
  *
  *   var myPhone = { name: "My Phone", ... };
  *   require("devtools/client/shared/devices").addDevice(myPhone, "phones");
  */
 
 // Local devices catalog that addons can add to.
-let localDevices = {};
+let localDevices;
+let localDevicesLoaded = false;
+
+// Load local devices from storage.
+let loadLocalDevices = Task.async(function* () {
+  if (localDevicesLoaded) {
+    return;
+  }
+  let devicesJSON = yield asyncStorage.getItem(LOCAL_DEVICES);
+  if (!devicesJSON) {
+    devicesJSON = "{}";
+  }
+  localDevices = JSON.parse(devicesJSON);
+  localDevicesLoaded = true;
+});
 
 // Add a device to the local catalog.
-function addDevice(device, type = "phones") {
+let addDevice = Task.async(function* (device, type = "phones") {
+  yield loadLocalDevices();
   let list = localDevices[type];
   if (!list) {
     list = localDevices[type] = [];
   }
   list.push(device);
-}
+  yield asyncStorage.setItem(LOCAL_DEVICES, JSON.stringify(localDevices));
+});
 exports.addDevice = addDevice;
 
 // Remove a device from the local catalog.
 // returns `true` if the device is removed, `false` otherwise.
-function removeDevice(device, type = "phones") {
+let removeDevice = Task.async(function* (device, type = "phones") {
+  yield loadLocalDevices();
   let list = localDevices[type];
   if (!list) {
     return false;
   }
 
   let index = list.findIndex(item => device);
 
   if (index === -1) {
     return false;
   }
 
   list.splice(index, 1);
+  yield asyncStorage.setItem(LOCAL_DEVICES, JSON.stringify(localDevices));
 
   return true;
-}
+});
 exports.removeDevice = removeDevice;
 
 // Get the complete devices catalog.
-function getDevices() {
+let getDevices = Task.async(function* () {
   // Fetch common devices from Mozilla's CDN.
-  return getJSON(DEVICES_URL).then(devices => {
-    for (let type in localDevices) {
-      if (!devices[type]) {
-        devices.TYPES.push(type);
-        devices[type] = [];
-      }
-      devices[type] = localDevices[type].concat(devices[type]);
+  let devices = yield getJSON(DEVICES_URL);
+  yield loadLocalDevices();
+  for (let type in localDevices) {
+    if (!devices[type]) {
+      devices.TYPES.push(type);
+      devices[type] = [];
     }
-    return devices;
-  });
-}
+    devices[type] = localDevices[type].concat(devices[type]);
+  }
+  return devices;
+});
 exports.getDevices = getDevices;
 
 // Get the localized string for a device type.
 function getDeviceString(deviceType) {
   return L10N.getStr("device." + deviceType);
 }
 exports.getDeviceString = getDeviceString;