Bug 1321675 - Add form to device modal for adding devices. r=gl draft
authorJ. Ryan Stinnett <jryans@gmail.com>
Mon, 30 Jan 2017 18:26:12 -0600
changeset 480190 ea287d8398aa4bb8a445289e34fea029bab72eb9
parent 468005 f7e1982a2582b14c5885d787b530f879da3a040e
child 480191 394827a8d15c1143592cef85ddb04004e776a382
push id44482
push userbmo:jryans@gmail.com
push dateTue, 07 Feb 2017 23:00:12 +0000
reviewersgl
bugs1321675
milestone54.0a1
Bug 1321675 - Add form to device modal for adding devices. r=gl MozReview-Commit-ID: 2CejxW5VuqK
devtools/client/locales/en-US/responsive.properties
devtools/client/responsive.html/actions/devices.js
devtools/client/responsive.html/actions/index.js
devtools/client/responsive.html/actions/viewports.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/device-selector.js
devtools/client/responsive.html/components/moz.build
devtools/client/responsive.html/components/resizable-viewport.js
devtools/client/responsive.html/components/viewport-toolbar.js
devtools/client/responsive.html/components/viewport.js
devtools/client/responsive.html/components/viewports.js
devtools/client/responsive.html/index.css
devtools/client/responsive.html/reducers/devices.js
devtools/client/responsive.html/reducers/viewports.js
devtools/client/responsive.html/test/unit/test_change_device.js
devtools/client/responsive.html/types.js
--- a/devtools/client/locales/en-US/responsive.properties
+++ b/devtools/client/locales/en-US/responsive.properties
@@ -77,8 +77,53 @@ responsive.noThrottling=No throttling
 # DevicePixelRatio (DPR) dropdown when is enabled.
 responsive.devicePixelRatio=Device Pixel Ratio
 
 # LOCALIZATION NOTE (responsive.autoDPR): tooltip for the DevicePixelRatio
 # (DPR) dropdown when is disabled because a device is selected.
 # The argument (%1$S) is the selected device (e.g. iPhone 6) that set
 # automatically the DPR value.
 responsive.autoDPR=DPR automatically set by %1$S
+
+# LOCALIZATION NOTE (responsive.customDeviceName): Default value in a form to
+# add a custom device based on an arbitrary size (no association to an existing
+# device).
+responsive.customDeviceName=Custom Device
+
+# LOCALIZATION NOTE (responsive.customDeviceNameFromBase): Default value in a
+# form to add a custom device based on the properties of another.  %1$S is the
+# name of the device we're staring from, such as "Apple iPhone 6".
+responsive.customDeviceNameFromBase=%1$S (Custom)
+
+# LOCALIZATION NOTE (responsive.addDevice): Button text that reveals a form to
+# be used for adding custom devices.
+responsive.addDevice=Add Device
+
+# LOCALIZATION NOTE (responsive.deviceAdderName): Label of form field for the
+# name of a new device.  The available width is very low, so you might see
+# overlapping text if the length is much longer than 5 or so characters.
+responsive.deviceAdderName=Name
+
+# LOCALIZATION NOTE (responsive.deviceAdderSize): Label of form field for the
+# size of a new device.  The available width is very low, so you might see
+# overlapping text if the length is much longer than 5 or so characters.
+responsive.deviceAdderSize=Size
+
+# LOCALIZATION NOTE (responsive.deviceAdderPixelRatio): Label of form field for
+# the devicePixelRatio of a new device.  The available width is very low, so you
+# might see overlapping text if the length is much longer than 5 or so
+# characters.
+responsive.deviceAdderPixelRatio=DPR
+
+# LOCALIZATION NOTE (responsive.deviceAdderUserAgent): Label of form field for
+# the user agent of a new device.  The available width is very low, so you might
+# see overlapping text if the length is much longer than 5 or so characters.
+responsive.deviceAdderUserAgent=UA
+
+# LOCALIZATION NOTE (responsive.deviceAdderTouch): Label of form field for the
+# touch input support of a new device.  The available width is very low, so you
+# might see overlapping text if the length is much longer than 5 or so
+# characters.
+responsive.deviceAdderTouch=Touch
+
+# LOCALIZATION NOTE (responsive.deviceAdderSave): Button text that submits a
+# form to add a new device.
+responsive.deviceAdderSave=Save
--- a/devtools/client/responsive.html/actions/devices.js
+++ b/devtools/client/responsive.html/actions/devices.js
@@ -6,17 +6,17 @@
 
 const {
   ADD_DEVICE,
   ADD_DEVICE_TYPE,
   LOAD_DEVICE_LIST_START,
   LOAD_DEVICE_LIST_ERROR,
   LOAD_DEVICE_LIST_END,
   UPDATE_DEVICE_DISPLAYED,
-  UPDATE_DEVICE_MODAL_OPEN,
+  UPDATE_DEVICE_MODAL,
 } = require("./index");
 
 const { getDevices } = require("devtools/client/shared/devices");
 
 const Services = require("Services");
 const DISPLAYED_DEVICES_PREF = "devtools.responsive.html.displayedDeviceList";
 
 /**
@@ -92,17 +92,17 @@ module.exports = {
       device,
       deviceType,
       displayed,
     };
   },
 
   loadDevices() {
     return function* (dispatch, getState) {
-      yield dispatch({ type: LOAD_DEVICE_LIST_START });
+      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);
         dispatch({ type: LOAD_DEVICE_LIST_ERROR });
@@ -119,20 +119,24 @@ module.exports = {
           let newDevice = Object.assign({}, device, {
             displayed: preferredDevices.added.has(device.name) ||
               (device.featured && !(preferredDevices.removed.has(device.name))),
           });
 
           dispatch(module.exports.addDevice(newDevice, type));
         }
       }
+
+      dispatch(module.exports.addDeviceType("custom"));
+
       dispatch({ type: LOAD_DEVICE_LIST_END });
     };
   },
 
-  updateDeviceModalOpen(isOpen) {
+  updateDeviceModal(isOpen, modalOpenedFromViewport = null) {
     return {
-      type: UPDATE_DEVICE_MODAL_OPEN,
+      type: UPDATE_DEVICE_MODAL,
       isOpen,
+      modalOpenedFromViewport,
     };
   },
 
 };
--- a/devtools/client/responsive.html/actions/index.js
+++ b/devtools/client/responsive.html/actions/index.js
@@ -66,12 +66,12 @@ createEnum([
   "TAKE_SCREENSHOT_START",
 
   // Indicates when the screenshot action ends.
   "TAKE_SCREENSHOT_END",
 
   // Update the device display state in the device selector.
   "UPDATE_DEVICE_DISPLAYED",
 
-  // Update the device modal open state.
-  "UPDATE_DEVICE_MODAL_OPEN",
+  // Update the device modal state.
+  "UPDATE_DEVICE_MODAL",
 
 ], module.exports);
--- a/devtools/client/responsive.html/actions/viewports.js
+++ b/devtools/client/responsive.html/actions/viewports.js
@@ -22,21 +22,22 @@ module.exports = {
     return {
       type: ADD_VIEWPORT,
     };
   },
 
   /**
    * Change the viewport device.
    */
-  changeDevice(id, device) {
+  changeDevice(id, device, deviceType) {
     return {
       type: CHANGE_DEVICE,
       id,
       device,
+      deviceType,
     };
   },
 
   /**
    * Change the viewport pixel ratio.
    */
   changePixelRatio(id, pixelRatio = 0) {
     return {
--- a/devtools/client/responsive.html/app.js
+++ b/devtools/client/responsive.html/app.js
@@ -7,17 +7,17 @@
 "use strict";
 
 const { createClass, createFactory, PropTypes, DOM: dom } =
   require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 
 const {
   updateDeviceDisplayed,
-  updateDeviceModalOpen,
+  updateDeviceModal,
   updatePreferredDevices,
 } = require("./actions/devices");
 const { changeNetworkThrottling } = require("./actions/network-throttling");
 const { takeScreenshot } = require("./actions/screenshot");
 const { changeTouchSimulation } = require("./actions/touch-simulation");
 const {
   changeDevice,
   changePixelRatio,
@@ -43,22 +43,22 @@ let App = createClass({
     touchSimulation: PropTypes.shape(Types.touchSimulation).isRequired,
     viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
   },
 
   onBrowserMounted() {
     window.postMessage({ type: "browser-mounted" }, "*");
   },
 
-  onChangeDevice(id, device) {
+  onChangeDevice(id, device, deviceType) {
     window.postMessage({
       type: "change-device",
       device,
     }, "*");
-    this.props.dispatch(changeDevice(id, device.name));
+    this.props.dispatch(changeDevice(id, device.name, deviceType));
     this.props.dispatch(changeTouchSimulation(device.touch));
     this.props.dispatch(changePixelRatio(id, device.pixelRatio));
   },
 
   onChangeNetworkThrottling(enabled, profile) {
     window.postMessage({
       type: "change-network-throtting",
       enabled,
@@ -120,18 +120,18 @@ let App = createClass({
   onScreenshot() {
     this.props.dispatch(takeScreenshot());
   },
 
   onUpdateDeviceDisplayed(device, deviceType, displayed) {
     this.props.dispatch(updateDeviceDisplayed(device, deviceType, displayed));
   },
 
-  onUpdateDeviceModalOpen(isOpen) {
-    this.props.dispatch(updateDeviceModalOpen(isOpen));
+  onUpdateDeviceModal(isOpen, modalOpenedFromViewport) {
+    this.props.dispatch(updateDeviceModal(isOpen, modalOpenedFromViewport));
   },
 
   render() {
     let {
       devices,
       displayPixelRatio,
       location,
       networkThrottling,
@@ -149,27 +149,32 @@ let App = createClass({
       onContentResize,
       onDeviceListUpdate,
       onExit,
       onRemoveDevice,
       onResizeViewport,
       onRotateViewport,
       onScreenshot,
       onUpdateDeviceDisplayed,
-      onUpdateDeviceModalOpen,
+      onUpdateDeviceModal,
     } = this;
 
     let selectedDevice = "";
     let selectedPixelRatio = { value: 0 };
 
     if (viewports.length) {
       selectedDevice = viewports[0].device;
       selectedPixelRatio = viewports[0].pixelRatio;
     }
 
+    let deviceAdderViewportTemplate = {};
+    if (devices.modalOpenedFromViewport !== null) {
+      deviceAdderViewportTemplate = viewports[devices.modalOpenedFromViewport];
+    }
+
     return dom.div(
       {
         id: "app",
       },
       GlobalToolbar({
         devices,
         displayPixelRatio,
         networkThrottling,
@@ -189,22 +194,23 @@ let App = createClass({
         screenshot,
         viewports,
         onBrowserMounted,
         onChangeDevice,
         onContentResize,
         onRemoveDevice,
         onRotateViewport,
         onResizeViewport,
-        onUpdateDeviceModalOpen,
+        onUpdateDeviceModal,
       }),
       DeviceModal({
+        deviceAdderViewportTemplate,
         devices,
         onDeviceListUpdate,
         onUpdateDeviceDisplayed,
-        onUpdateDeviceModalOpen,
+        onUpdateDeviceModal,
       })
     );
   },
 
 });
 
 module.exports = connect(state => state)(App);
new file mode 100644
--- /dev/null
+++ b/devtools/client/responsive.html/components/device-adder.js
@@ -0,0 +1,170 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* eslint-env browser */
+
+"use strict";
+
+const { DOM: dom, createClass, createFactory, PropTypes, addons } =
+  require("devtools/client/shared/vendor/react");
+
+const { getFormatStr, getStr } = require("../utils/l10n");
+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,
+  },
+
+  mixins: [ addons.PureRenderMixin ],
+
+  getInitialState() {
+    return {};
+  },
+
+  onDeviceAdderShow() {
+    this.setState({
+      deviceAdderDisplayed: true,
+    });
+  },
+
+  onDeviceAdderSave() {
+    this.setState({
+      deviceAdderDisplayed: false,
+    });
+  },
+
+  render() {
+    let {
+      devices,
+      viewportTemplate,
+    } = this.props;
+
+    let {
+      deviceAdderDisplayed,
+    } = this.state;
+
+    if (!deviceAdderDisplayed) {
+      return dom.div(
+        {
+          id: "device-adder"
+        },
+        dom.button(
+          {
+            id: "device-adder-show",
+            onClick: this.onDeviceAdderShow,
+          },
+          getStr("responsive.addDevice")
+        )
+      );
+    }
+
+    // If a device is currently selected, fold its attributes into a single object for use
+    // as the starting values of the form.  If no device is selected, use the values for
+    // the current window.
+    let deviceName;
+    let normalizedViewport = Object.assign({}, viewportTemplate);
+    if (viewportTemplate.device) {
+      let device = devices[viewportTemplate.deviceType].find(d => {
+        return d.name == viewportTemplate.device;
+      });
+      deviceName = getFormatStr("responsive.customDeviceNameFromBase", device.name);
+      Object.assign(normalizedViewport, {
+        pixelRatio: device.pixelRatio,
+        userAgent: device.userAgent,
+        touch: device.touch,
+      });
+    } else {
+      deviceName = getStr("responsive.customDeviceName");
+      Object.assign(normalizedViewport, {
+        pixelRatio: window.devicePixelRatio,
+        userAgent: navigator.userAgent,
+        touch: false,
+      });
+    }
+
+    return dom.div(
+      {
+        id: "device-adder"
+      },
+      dom.label(
+        {},
+        dom.span(
+          {
+            className: "device-adder-label",
+          },
+          getStr("responsive.deviceAdderName")
+        ),
+        dom.input({
+          defaultValue: deviceName,
+        })
+      ),
+      dom.label(
+        {},
+        dom.span(
+          {
+            className: "device-adder-label"
+          },
+          getStr("responsive.deviceAdderSize")
+        ),
+        ViewportDimension({
+          viewport: {
+            width: normalizedViewport.width,
+            height: normalizedViewport.height,
+          },
+          onRemoveDevice: () => {},
+          onResizeViewport: () => {},
+        })
+      ),
+      dom.label(
+        {},
+        dom.span(
+          {
+            className: "device-adder-label"
+          },
+          getStr("responsive.deviceAdderPixelRatio")
+        ),
+        dom.input({
+          defaultValue: normalizedViewport.pixelRatio,
+        })
+      ),
+      dom.label(
+        {},
+        dom.span(
+          {
+            className: "device-adder-label"
+          },
+          getStr("responsive.deviceAdderUserAgent")
+        ),
+        dom.input({
+          defaultValue: normalizedViewport.userAgent,
+        })
+      ),
+      dom.label(
+        {},
+        dom.span(
+          {
+            className: "device-adder-label"
+          },
+          getStr("responsive.deviceAdderTouch")
+        ),
+        dom.input({
+          defaultChecked: normalizedViewport.touch,
+          type: "checkbox",
+        })
+      ),
+      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
@@ -1,29 +1,32 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 /* eslint-env browser */
 
 "use strict";
 
-const { DOM: dom, createClass, PropTypes, addons } =
+const { DOM: dom, createClass, createFactory, PropTypes, addons } =
   require("devtools/client/shared/vendor/react");
+
 const { getStr } = require("../utils/l10n");
 const Types = require("../types");
+const DeviceAdder = createFactory(require("./device-adder"));
 
 module.exports = createClass({
   displayName: "DeviceModal",
 
   propTypes: {
+    deviceAdderViewportTemplate: PropTypes.shape(Types.viewport).isRequired,
     devices: PropTypes.shape(Types.devices).isRequired,
     onDeviceListUpdate: PropTypes.func.isRequired,
     onUpdateDeviceDisplayed: PropTypes.func.isRequired,
-    onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+    onUpdateDeviceModal: PropTypes.func.isRequired,
   },
 
   mixins: [ addons.PureRenderMixin ],
 
   getInitialState() {
     return {};
   },
 
@@ -58,17 +61,17 @@ module.exports = createClass({
     });
   },
 
   onDeviceModalSubmit() {
     let {
       devices,
       onDeviceListUpdate,
       onUpdateDeviceDisplayed,
-      onUpdateDeviceModalOpen,
+      onUpdateDeviceModal,
     } = this.props;
 
     let preferredDevices = {
       "added": new Set(),
       "removed": new Set(),
     };
 
     for (let type of devices.types) {
@@ -83,36 +86,37 @@ module.exports = createClass({
 
         if (this.state[device.name] != device.displayed) {
           onUpdateDeviceDisplayed(device, type, this.state[device.name]);
         }
       }
     }
 
     onDeviceListUpdate(preferredDevices);
-    onUpdateDeviceModalOpen(false);
+    onUpdateDeviceModal(false);
   },
 
   onKeyDown(event) {
     if (!this.props.devices.isModalOpen) {
       return;
     }
     // Escape keycode
     if (event.keyCode === 27) {
       let {
-        onUpdateDeviceModalOpen
+        onUpdateDeviceModal
       } = this.props;
-      onUpdateDeviceModalOpen(false);
+      onUpdateDeviceModal(false);
     }
   },
 
   render() {
     let {
+      deviceAdderViewportTemplate,
       devices,
-      onUpdateDeviceModalOpen,
+      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));
     }
 
@@ -123,17 +127,17 @@ module.exports = createClass({
       },
       dom.div(
         {
           className: "device-modal container",
         },
         dom.button({
           id: "device-close-button",
           className: "toolbar-button devtools-button",
-          onClick: () => onUpdateDeviceModalOpen(false),
+          onClick: () => onUpdateDeviceModal(false),
         }),
         dom.div(
           {
             className: "device-modal-content",
           },
           devices.types.map(type => {
             return dom.div(
               {
@@ -158,27 +162,31 @@ module.exports = createClass({
                     value: device.name,
                     checked: this.state[device.name],
                     onChange: this.onDeviceCheckboxChange,
                   }),
                   device.name
                 );
               })
             );
+          }),
+          DeviceAdder({
+            devices,
+            viewportTemplate: deviceAdderViewportTemplate,
           })
         ),
         dom.button(
           {
             id: "device-submit-button",
             onClick: this.onDeviceModalSubmit,
           },
           getStr("responsive.done")
         )
       ),
       dom.div(
         {
           className: "modal-overlay",
-          onClick: () => onUpdateDeviceModalOpen(false),
+          onClick: () => onUpdateDeviceModal(false),
         }
       )
     );
   },
 });
--- a/devtools/client/responsive.html/components/device-selector.js
+++ b/devtools/client/responsive.html/components/device-selector.js
@@ -12,40 +12,42 @@ const Types = require("../types");
 const OPEN_DEVICE_MODAL_VALUE = "OPEN_DEVICE_MODAL";
 
 module.exports = createClass({
   displayName: "DeviceSelector",
 
   propTypes: {
     devices: PropTypes.shape(Types.devices).isRequired,
     selectedDevice: PropTypes.string.isRequired,
+    viewportId: PropTypes.number.isRequired,
     onChangeDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
-    onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+    onUpdateDeviceModal: PropTypes.func.isRequired,
   },
 
   mixins: [ addons.PureRenderMixin ],
 
   onSelectChange({ target }) {
     let {
       devices,
+      viewportId,
       onChangeDevice,
       onResizeViewport,
-      onUpdateDeviceModalOpen,
+      onUpdateDeviceModal,
     } = this.props;
 
     if (target.value === OPEN_DEVICE_MODAL_VALUE) {
-      onUpdateDeviceModalOpen(true);
+      onUpdateDeviceModal(true, viewportId);
       return;
     }
     for (let type of devices.types) {
       for (let device of devices[type]) {
         if (device.name === target.value) {
           onResizeViewport(device.width, device.height);
-          onChangeDevice(device);
+          onChangeDevice(device, type);
           return;
         }
       }
     }
   },
 
   render() {
     let {
--- a/devtools/client/responsive.html/components/moz.build
+++ b/devtools/client/responsive.html/components/moz.build
@@ -1,16 +1,17 @@
 # -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 DevToolsModules(
     'browser.js',
+    'device-adder.js',
     'device-modal.js',
     'device-selector.js',
     'dpr-selector.js',
     'global-toolbar.js',
     'network-throttling-selector.js',
     'resizable-viewport.js',
     'viewport-dimension.js',
     'viewport-toolbar.js',
--- a/devtools/client/responsive.html/components/resizable-viewport.js
+++ b/devtools/client/responsive.html/components/resizable-viewport.js
@@ -28,17 +28,17 @@ module.exports = createClass({
     swapAfterMount: PropTypes.bool.isRequired,
     viewport: PropTypes.shape(Types.viewport).isRequired,
     onBrowserMounted: PropTypes.func.isRequired,
     onChangeDevice: PropTypes.func.isRequired,
     onContentResize: PropTypes.func.isRequired,
     onRemoveDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
-    onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+    onUpdateDeviceModal: PropTypes.func.isRequired,
   },
 
   getInitialState() {
     return {
       isResizing: false,
       lastClientX: 0,
       lastClientY: 0,
       ignoreX: false,
@@ -130,17 +130,17 @@ module.exports = createClass({
       screenshot,
       swapAfterMount,
       viewport,
       onBrowserMounted,
       onChangeDevice,
       onContentResize,
       onResizeViewport,
       onRotateViewport,
-      onUpdateDeviceModalOpen,
+      onUpdateDeviceModal,
     } = this.props;
 
     let resizeHandleClass = "viewport-resize-handle";
     if (screenshot.isCapturing) {
       resizeHandleClass += " hidden";
     }
 
     let contentClass = "viewport-content";
@@ -149,21 +149,21 @@ module.exports = createClass({
     }
 
     return dom.div(
       {
         className: "resizable-viewport",
       },
       ViewportToolbar({
         devices,
-        selectedDevice: viewport.device,
+        viewport,
         onChangeDevice,
         onResizeViewport,
         onRotateViewport,
-        onUpdateDeviceModalOpen,
+        onUpdateDeviceModal,
       }),
       dom.div(
         {
           className: contentClass,
           style: {
             width: viewport.width + "px",
             height: viewport.height + "px",
           },
--- a/devtools/client/responsive.html/components/viewport-toolbar.js
+++ b/devtools/client/responsive.html/components/viewport-toolbar.js
@@ -11,45 +11,46 @@ const { getStr } = require("../utils/l10
 const Types = require("../types");
 const DeviceSelector = createFactory(require("./device-selector"));
 
 module.exports = createClass({
   displayName: "ViewportToolbar",
 
   propTypes: {
     devices: PropTypes.shape(Types.devices).isRequired,
-    selectedDevice: PropTypes.string.isRequired,
+    viewport: PropTypes.shape(Types.viewport).isRequired,
     onChangeDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
-    onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+    onUpdateDeviceModal: PropTypes.func.isRequired,
   },
 
   mixins: [ addons.PureRenderMixin ],
 
   render() {
     let {
       devices,
-      selectedDevice,
+      viewport,
       onChangeDevice,
       onResizeViewport,
       onRotateViewport,
-      onUpdateDeviceModalOpen,
+      onUpdateDeviceModal,
     } = this.props;
 
     return dom.div(
       {
         className: "viewport-toolbar container",
       },
       DeviceSelector({
         devices,
-        selectedDevice,
+        selectedDevice: viewport.device,
+        viewportId: viewport.id,
         onChangeDevice,
         onResizeViewport,
-        onUpdateDeviceModalOpen,
+        onUpdateDeviceModal,
       }),
       dom.button({
         className: "viewport-rotate-button toolbar-button devtools-button",
         onClick: onRotateViewport,
         title: getStr("responsive.rotate"),
       })
     );
   },
--- a/devtools/client/responsive.html/components/viewport.js
+++ b/devtools/client/responsive.html/components/viewport.js
@@ -22,26 +22,26 @@ module.exports = createClass({
     swapAfterMount: PropTypes.bool.isRequired,
     viewport: PropTypes.shape(Types.viewport).isRequired,
     onBrowserMounted: PropTypes.func.isRequired,
     onChangeDevice: PropTypes.func.isRequired,
     onContentResize: PropTypes.func.isRequired,
     onRemoveDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
-    onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+    onUpdateDeviceModal: PropTypes.func.isRequired,
   },
 
-  onChangeDevice(device) {
+  onChangeDevice(device, deviceType) {
     let {
       viewport,
       onChangeDevice,
     } = this.props;
 
-    onChangeDevice(viewport.id, device);
+    onChangeDevice(viewport.id, device, deviceType);
   },
 
   onRemoveDevice() {
     let {
       viewport,
       onRemoveDevice,
     } = this.props;
 
@@ -70,17 +70,17 @@ module.exports = createClass({
     let {
       devices,
       location,
       screenshot,
       swapAfterMount,
       viewport,
       onBrowserMounted,
       onContentResize,
-      onUpdateDeviceModalOpen,
+      onUpdateDeviceModal,
     } = this.props;
 
     let {
       onChangeDevice,
       onRemoveDevice,
       onRotateViewport,
       onResizeViewport,
     } = this;
@@ -101,14 +101,14 @@ module.exports = createClass({
         swapAfterMount,
         viewport,
         onBrowserMounted,
         onChangeDevice,
         onContentResize,
         onRemoveDevice,
         onResizeViewport,
         onRotateViewport,
-        onUpdateDeviceModalOpen,
+        onUpdateDeviceModal,
       })
     );
   },
 
 });
--- a/devtools/client/responsive.html/components/viewports.js
+++ b/devtools/client/responsive.html/components/viewports.js
@@ -20,32 +20,32 @@ module.exports = createClass({
     screenshot: PropTypes.shape(Types.screenshot).isRequired,
     viewports: PropTypes.arrayOf(PropTypes.shape(Types.viewport)).isRequired,
     onBrowserMounted: PropTypes.func.isRequired,
     onChangeDevice: PropTypes.func.isRequired,
     onContentResize: PropTypes.func.isRequired,
     onRemoveDevice: PropTypes.func.isRequired,
     onResizeViewport: PropTypes.func.isRequired,
     onRotateViewport: PropTypes.func.isRequired,
-    onUpdateDeviceModalOpen: PropTypes.func.isRequired,
+    onUpdateDeviceModal: PropTypes.func.isRequired,
   },
 
   render() {
     let {
       devices,
       location,
       screenshot,
       viewports,
       onBrowserMounted,
       onChangeDevice,
       onContentResize,
       onRemoveDevice,
       onResizeViewport,
       onRotateViewport,
-      onUpdateDeviceModalOpen,
+      onUpdateDeviceModal,
     } = this.props;
 
     return dom.div(
       {
         id: "viewports",
       },
       viewports.map((viewport, i) => {
         return Viewport({
@@ -56,15 +56,15 @@ module.exports = createClass({
           swapAfterMount: i == 0,
           viewport,
           onBrowserMounted,
           onChangeDevice,
           onContentResize,
           onRemoveDevice,
           onResizeViewport,
           onRotateViewport,
-          onUpdateDeviceModalOpen,
+          onUpdateDeviceModal,
         });
       })
     );
   },
 
 });
--- a/devtools/client/responsive.html/index.css
+++ b/devtools/client/responsive.html/index.css
@@ -33,17 +33,18 @@
     url("./images/select-arrow.svg#dark-selected");
 }
 
 * {
   box-sizing: border-box;
 }
 
 #root,
-html, body {
+html,
+body {
   height: 100%;
   margin: 0;
 }
 
 #app {
   /* Center the viewports container */
   display: flex;
   align-items: center;
@@ -361,16 +362,17 @@ select > option.divider {
 .viewport-dimension-input {
   color: var(--theme-body-color-inactive);
   transition: all 0.25s ease;
 }
 
 .viewport-dimension-editable.editing,
 .viewport-dimension-input.editing {
   color: var(--viewport-active-color);
+  outline: none;
 }
 
 .viewport-dimension-editable.editing {
   border-bottom: 1px solid var(--theme-selection-background);
 }
 
 .viewport-dimension-editable.editing.invalid {
   border-bottom: 1px solid #d92215;
@@ -514,8 +516,65 @@ select > option.divider {
 #device-submit-button:hover {
   background-color: var(--toolbar-tab-hover);
 }
 
 #device-submit-button:hover:active {
   background-color: var(--submit-button-active-background-color);
   color: var(--submit-button-active-color);
 }
+
+/**
+ * Device Adder
+ */
+
+#device-adder {
+  display: flex;
+  flex-direction: column;
+}
+
+#device-adder button {
+  background-color: var(--theme-tab-toolbar-background);
+  border: 1px solid var(--theme-splitter-color);
+  border-radius: 2px;
+  color: var(--theme-body-color);
+  margin: 0 auto;
+}
+
+#device-adder label {
+  display: flex;
+  margin-bottom: 5px;
+  align-items: center;
+}
+
+#device-adder label > input,
+#device-adder label > .viewport-dimension {
+  width: 130px;
+  margin: 0;
+}
+
+#device-adder input {
+  background: transparent;
+  border: none;
+  text-align: center;
+  color: var(--theme-body-color-inactive);
+  transition: all 0.25s ease;
+}
+
+#device-adder input:focus {
+  color: var(--viewport-active-color);
+}
+
+#device-adder label > input:focus,
+#device-adder label > .viewport-dimension:focus  {
+  border-bottom: 1px solid var(--theme-selection-background);
+  outline: none;
+}
+
+.device-adder-label {
+  display: inline-block;
+  margin-right: 5px;
+  width: 35px;
+}
+
+#device-adder #device-adder-save {
+  margin-top: 5px;
+}
--- a/devtools/client/responsive.html/reducers/devices.js
+++ b/devtools/client/responsive.html/reducers/devices.js
@@ -6,24 +6,25 @@
 
 const {
   ADD_DEVICE,
   ADD_DEVICE_TYPE,
   LOAD_DEVICE_LIST_START,
   LOAD_DEVICE_LIST_ERROR,
   LOAD_DEVICE_LIST_END,
   UPDATE_DEVICE_DISPLAYED,
-  UPDATE_DEVICE_MODAL_OPEN,
+  UPDATE_DEVICE_MODAL,
 } = require("../actions/index");
 
 const Types = require("../types");
 
 const INITIAL_DEVICES = {
   types: [],
   isModalOpen: false,
+  modalOpenedFromViewport: null,
   listState: Types.deviceListState.INITIALIZED,
 };
 
 let reducers = {
 
   [ADD_DEVICE](devices, { device, deviceType }) {
     return Object.assign({}, devices, {
       [deviceType]: [...devices[deviceType], device],
@@ -64,19 +65,20 @@ let reducers = {
   },
 
   [LOAD_DEVICE_LIST_END](devices, action) {
     return Object.assign({}, devices, {
       listState: Types.deviceListState.LOADED,
     });
   },
 
-  [UPDATE_DEVICE_MODAL_OPEN](devices, { isOpen }) {
+  [UPDATE_DEVICE_MODAL](devices, { isOpen, modalOpenedFromViewport }) {
     return Object.assign({}, devices, {
       isModalOpen: isOpen,
+      modalOpenedFromViewport,
     });
   },
 
 };
 
 module.exports = function (devices = INITIAL_DEVICES, action) {
   let reducer = reducers[action.type];
   if (!reducer) {
--- a/devtools/client/responsive.html/reducers/viewports.js
+++ b/devtools/client/responsive.html/reducers/viewports.js
@@ -14,16 +14,17 @@ const {
 } = require("../actions/index");
 
 let nextViewportId = 0;
 
 const INITIAL_VIEWPORTS = [];
 const INITIAL_VIEWPORT = {
   id: nextViewportId++,
   device: "",
+  deviceType: "",
   width: 320,
   height: 480,
   pixelRatio: {
     value: 0,
   },
 };
 
 let reducers = {
@@ -31,24 +32,25 @@ let reducers = {
   [ADD_VIEWPORT](viewports) {
     // For the moment, there can be at most one viewport.
     if (viewports.length === 1) {
       return viewports;
     }
     return [...viewports, Object.assign({}, INITIAL_VIEWPORT)];
   },
 
-  [CHANGE_DEVICE](viewports, { id, device }) {
+  [CHANGE_DEVICE](viewports, { id, device, deviceType }) {
     return viewports.map(viewport => {
       if (viewport.id !== id) {
         return viewport;
       }
 
       return Object.assign({}, viewport, {
         device,
+        deviceType,
       });
     });
   },
 
   [CHANGE_PIXEL_RATIO](viewports, { id, pixelRatio }) {
     return viewports.map(viewport => {
       if (viewport.id !== id) {
         return viewport;
@@ -65,16 +67,17 @@ let reducers = {
   [REMOVE_DEVICE](viewports, { id }) {
     return viewports.map(viewport => {
       if (viewport.id !== id) {
         return viewport;
       }
 
       return Object.assign({}, viewport, {
         device: "",
+        deviceType: "",
       });
     });
   },
 
   [RESIZE_VIEWPORT](viewports, { id, width, height }) {
     return viewports.map(viewport => {
       if (viewport.id !== id) {
         return viewport;
--- a/devtools/client/responsive.html/test/unit/test_change_device.js
+++ b/devtools/client/responsive.html/test/unit/test_change_device.js
@@ -29,14 +29,14 @@ add_task(function* () {
     "firefoxOS": true,
     "os": "fxos"
   }, "phones"));
   dispatch(addViewport());
 
   let viewport = getState().viewports[0];
   equal(viewport.device, "", "Default device is unselected");
 
-  dispatch(changeDevice(0, "Firefox OS Flame"));
+  dispatch(changeDevice(0, "Firefox OS Flame", "phones"));
 
   viewport = getState().viewports[0];
   equal(viewport.device, "Firefox OS Flame",
     "Changed to Firefox OS Flame device");
 });
--- a/devtools/client/responsive.html/types.js
+++ b/devtools/client/responsive.html/types.js
@@ -84,16 +84,19 @@ exports.devices = {
   consoles: PropTypes.arrayOf(PropTypes.shape(device)),
 
   // An array of watch devices
   watches: PropTypes.arrayOf(PropTypes.shape(device)),
 
   // Whether or not the device modal is open
   isModalOpen: PropTypes.bool,
 
+  // Viewport id that triggered the modal to open
+  modalOpenedFromViewport: PropTypes.number,
+
   // Device list state, possible values are exported above in an enum
   listState: PropTypes.oneOf(Object.keys(exports.deviceListState)),
 
 };
 
 /* VIEWPORT */
 
 /**
@@ -135,16 +138,19 @@ exports.touchSimulation = {
 exports.viewport = {
 
   // The id of the viewport
   id: PropTypes.number,
 
   // The currently selected device applied to the viewport
   device: PropTypes.string,
 
+  // The currently selected device type applied to the viewport
+  deviceType: PropTypes.string,
+
   // The width of the viewport
   width: PropTypes.number,
 
   // The height of the viewport
   height: PropTypes.number,
 
   // The devicePixelRatio of the viewport
   pixelRatio: PropTypes.shape(pixelRatio),