Custom request panel draft
authorRicky Chien <rchien@mozilla.com>
Fri, 27 Jan 2017 17:25:41 +0800
changeset 470112 a924e2be35d1ec640bc3ecb175142b3719fdb31c
parent 468391 3beb66073c97547c77cf7095dae5c15da835ab36
child 470113 ea438fd5a11c630e4c834fb4dcfa721f262f4ff1
child 470137 12b9142469cbc5f05b07554b0ffd0e47d5f7e122
push id43945
push userbmo:rchien@mozilla.com
push dateFri, 03 Feb 2017 08:43:24 +0000
milestone54.0a1
Custom request panel MozReview-Commit-ID: 3UqCRNAvuvy
devtools/client/netmonitor/actions/requests.js
devtools/client/netmonitor/actions/selection.js
devtools/client/netmonitor/constants.js
devtools/client/netmonitor/custom-request-view.js
devtools/client/netmonitor/events.js
devtools/client/netmonitor/moz.build
devtools/client/netmonitor/netmonitor-view.js
devtools/client/netmonitor/netmonitor.xul
devtools/client/netmonitor/reducers/requests.js
devtools/client/netmonitor/request-utils.js
devtools/client/netmonitor/requests-menu-view.js
devtools/client/netmonitor/shared/components/custom-request-panel.js
devtools/client/netmonitor/shared/components/moz.build
devtools/client/netmonitor/sidebar-view.js
devtools/client/netmonitor/test/browser.ini
devtools/client/netmonitor/test/browser_net_resend.js
devtools/client/netmonitor/test/browser_net_resend_cors.js
devtools/client/themes/netmonitor.css
--- a/devtools/client/netmonitor/actions/requests.js
+++ b/devtools/client/netmonitor/actions/requests.js
@@ -1,20 +1,26 @@
 /* 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/. */
 
+/* globals NetMonitorController */
 "use strict";
 
+const { getSelectedRequest } = require("../selectors/index");
 const {
   ADD_REQUEST,
-  UPDATE_REQUEST,
+  CLEAR_REQUESTS,
   CLONE_SELECTED_REQUEST,
   REMOVE_SELECTED_CUSTOM_REQUEST,
-  CLEAR_REQUESTS,
+  SEND_CUSTOM_REQUEST,
+  UPDATE_REQUEST,
+  UPDATE_SELECTED_REQUEST,
+  UPDATE_SELECTED_REQUEST_HEADER,
+  UPDATE_SELECTED_REQUEST_QUERY,
 } = require("../constants");
 
 function addRequest(id, data, batch) {
   return {
     type: ADD_REQUEST,
     id,
     data,
     meta: { batch },
@@ -25,27 +31,88 @@ function updateRequest(id, data, batch) 
   return {
     type: UPDATE_REQUEST,
     id,
     data,
     meta: { batch },
   };
 }
 
+function updateSelectedRequest(data, batch) {
+  return {
+    type: UPDATE_SELECTED_REQUEST,
+    data,
+    meta: { batch },
+  };
+}
+
+function updateSelectedRequestHeader(data, batch) {
+  return {
+    type: UPDATE_SELECTED_REQUEST_HEADER,
+    data,
+    meta: { batch },
+  };
+}
+
+function updateSelectedRequestQuery(data, batch) {
+  return {
+    type: UPDATE_SELECTED_REQUEST_QUERY,
+    data,
+    meta: { batch },
+  };
+}
+
 /**
  * Clone the currently selected request, set the "isCustom" attribute.
  * Used by the "Edit and Resend" feature.
  */
 function cloneSelectedRequest() {
   return {
     type: CLONE_SELECTED_REQUEST
   };
 }
 
 /**
+ * Send a new HTTP request using the data in the custom request form.
+ */
+function sendCustomRequest() {
+  if (!NetMonitorController.supportsCustomRequest) {
+    return cloneSelectedRequest();
+  }
+
+  return (dispatch, getState) => {
+    const state = getState();
+    const selected = getSelectedRequest(state);
+
+    if (!selected) {
+      // avoid consistent-return eslint error by async return the action
+      () => cloneSelectedRequest();
+    }
+
+    // Send a new HTTP request using the data in the custom request form
+    let data = {
+      url: selected.url,
+      method: selected.method,
+      httpVersion: selected.httpVersion,
+    };
+    if (selected.requestHeaders) {
+      data.headers = selected.requestHeaders.headers;
+    }
+    if (selected.requestPostData) {
+      data.body = selected.requestPostData.postData.text;
+    }
+
+    NetMonitorController.webConsoleClient.sendHTTPRequest(data, (response) => ({
+      type: SEND_CUSTOM_REQUEST,
+      id: response.eventActor.actor,
+    }));
+  };
+}
+
+/**
  * Remove a request from the list. Supports removing only cloned requests with a
  * "isCustom" attribute. Other requests never need to be removed.
  */
 function removeSelectedCustomRequest() {
   return {
     type: REMOVE_SELECTED_CUSTOM_REQUEST
   };
 }
@@ -53,13 +120,17 @@ function removeSelectedCustomRequest() {
 function clearRequests() {
   return {
     type: CLEAR_REQUESTS
   };
 }
 
 module.exports = {
   addRequest,
-  updateRequest,
+  clearRequests,
   cloneSelectedRequest,
   removeSelectedCustomRequest,
-  clearRequests,
+  sendCustomRequest,
+  updateRequest,
+  updateSelectedRequest,
+  updateSelectedRequestHeader,
+  updateSelectedRequestQuery,
 };
--- a/devtools/client/netmonitor/actions/selection.js
+++ b/devtools/client/netmonitor/actions/selection.js
@@ -1,28 +1,16 @@
 /* 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/. */
 
 "use strict";
 
 const { getDisplayedRequests } = require("../selectors/index");
-const { SELECT_REQUEST, PRESELECT_REQUEST } = require("../constants");
-
-/**
- * When a new request with a given id is added in future, select it immediately.
- * Used by the "Edit and Resend" feature, where we know in advance the ID of the
- * request, at a time when it wasn't sent yet.
- */
-function preselectRequest(id) {
-  return {
-    type: PRESELECT_REQUEST,
-    id
-  };
-}
+const { SELECT_REQUEST } = require("../constants");
 
 /**
  * Select request with a given id.
  */
 function selectRequest(id) {
   return {
     type: SELECT_REQUEST,
     id
@@ -56,12 +44,11 @@ function selectDelta(delta) {
 
     const newIndex = Math.min(Math.max(0, selIndex + delta), requests.size - 1);
     const newItem = requests.get(newIndex);
     dispatch(selectRequest(newItem.id));
   };
 }
 
 module.exports = {
-  preselectRequest,
   selectRequest,
   selectDelta,
 };
--- a/devtools/client/netmonitor/constants.js
+++ b/devtools/client/netmonitor/constants.js
@@ -16,24 +16,27 @@ const actionTypes = {
   BATCH_ACTIONS: "BATCH_ACTIONS",
   BATCH_ENABLE: "BATCH_ENABLE",
   CLEAR_REQUESTS: "CLEAR_REQUESTS",
   CLEAR_TIMING_MARKERS: "CLEAR_TIMING_MARKERS",
   CLONE_SELECTED_REQUEST: "CLONE_SELECTED_REQUEST",
   ENABLE_REQUEST_FILTER_TYPE_ONLY: "ENABLE_REQUEST_FILTER_TYPE_ONLY",
   OPEN_SIDEBAR: "OPEN_SIDEBAR",
   OPEN_STATISTICS: "OPEN_STATISTICS",
-  PRESELECT_REQUEST: "PRESELECT_REQUEST",
   REMOVE_SELECTED_CUSTOM_REQUEST: "REMOVE_SELECTED_CUSTOM_REQUEST",
   SELECT_REQUEST: "SELECT_REQUEST",
   SELECT_DETAILS_PANEL_TAB: "SELECT_DETAILS_PANEL_TAB",
+  SEND_CUSTOM_REQUEST: "SEND_CUSTOM_REQUEST",
   SET_REQUEST_FILTER_TEXT: "SET_REQUEST_FILTER_TEXT",
   SORT_BY: "SORT_BY",
   TOGGLE_REQUEST_FILTER_TYPE: "TOGGLE_REQUEST_FILTER_TYPE",
   UPDATE_REQUEST: "UPDATE_REQUEST",
+  UPDATE_SELECTED_REQUEST: "UPDATE_SELECTED_REQUEST",
+  UPDATE_SELECTED_REQUEST_HEADER: "UPDATE_SELECTED_REQUEST_HEADER",
+  UPDATE_SELECTED_REQUEST_QUERY: "UPDATE_SELECTED_REQUEST_QUERY",
   WATERFALL_RESIZE: "WATERFALL_RESIZE",
 };
 
 // Descriptions for what this frontend is currently doing.
 const ACTIVITY_TYPE = {
   // Standing by and handling requests normally.
   NONE: 0,
 
deleted file mode 100644
--- a/devtools/client/netmonitor/custom-request-view.js
+++ /dev/null
@@ -1,222 +0,0 @@
-/* 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/. */
-
-/* globals window, dumpn, gNetwork, $, EVENTS, NetMonitorView */
-
-"use strict";
-
-const { Task } = require("devtools/shared/task");
-const { writeHeaderText,
-        getKeyWithEvent,
-        getUrlQuery,
-        parseQueryString } = require("./request-utils");
-const Actions = require("./actions/index");
-
-/**
- * Functions handling the custom request view.
- */
-function CustomRequestView() {
-  dumpn("CustomRequestView was instantiated");
-}
-
-CustomRequestView.prototype = {
-  /**
-   * Initialization function, called when the network monitor is started.
-   */
-  initialize: function () {
-    dumpn("Initializing the CustomRequestView");
-
-    this.updateCustomRequestEvent = getKeyWithEvent(this.onUpdate.bind(this));
-    $("#custom-pane").addEventListener("input",
-      this.updateCustomRequestEvent);
-  },
-
-  /**
-   * Destruction function, called when the network monitor is closed.
-   */
-  destroy: function () {
-    dumpn("Destroying the CustomRequestView");
-
-    $("#custom-pane").removeEventListener("input",
-      this.updateCustomRequestEvent);
-  },
-
-  /**
-   * Populates this view with the specified data.
-   *
-   * @param object data
-   *        The data source (this should be the attachment of a request item).
-   * @return object
-   *        Returns a promise that resolves upon population the view.
-   */
-  populate: Task.async(function* (data) {
-    $("#custom-url-value").value = data.url;
-    $("#custom-method-value").value = data.method;
-    this.updateCustomQuery(data.url);
-
-    if (data.requestHeaders) {
-      let headers = data.requestHeaders.headers;
-      $("#custom-headers-value").value = writeHeaderText(headers);
-    }
-    if (data.requestPostData) {
-      let postData = data.requestPostData.postData.text;
-      $("#custom-postdata-value").value = yield gNetwork.getString(postData);
-    }
-
-    window.emit(EVENTS.CUSTOMREQUESTVIEW_POPULATED);
-  }),
-
-  /**
-   * Handle user input in the custom request form.
-   *
-   * @param object field
-   *        the field that the user updated.
-   */
-  onUpdate: function (field) {
-    let selectedItem = NetMonitorView.RequestsMenu.selectedItem;
-    let store = NetMonitorView.RequestsMenu.store;
-    let value;
-
-    switch (field) {
-      case "method":
-        value = $("#custom-method-value").value.trim();
-        store.dispatch(Actions.updateRequest(selectedItem.id, { method: value }));
-        break;
-      case "url":
-        value = $("#custom-url-value").value;
-        this.updateCustomQuery(value);
-        store.dispatch(Actions.updateRequest(selectedItem.id, { url: value }));
-        break;
-      case "query":
-        let query = $("#custom-query-value").value;
-        this.updateCustomUrl(query);
-        value = $("#custom-url-value").value;
-        store.dispatch(Actions.updateRequest(selectedItem.id, { url: value }));
-        break;
-      case "body":
-        value = $("#custom-postdata-value").value;
-        store.dispatch(Actions.updateRequest(selectedItem.id, {
-          requestPostData: {
-            postData: { text: value }
-          }
-        }));
-        break;
-      case "headers":
-        let headersText = $("#custom-headers-value").value;
-        value = parseHeadersText(headersText);
-        store.dispatch(Actions.updateRequest(selectedItem.id, {
-          requestHeaders: { headers: value }
-        }));
-        break;
-    }
-  },
-
-  /**
-   * Update the query string field based on the url.
-   *
-   * @param object url
-   *        The URL to extract query string from.
-   */
-  updateCustomQuery: function (url) {
-    const paramsArray = parseQueryString(getUrlQuery(url));
-
-    if (!paramsArray) {
-      $("#custom-query").hidden = true;
-      return;
-    }
-
-    $("#custom-query").hidden = false;
-    $("#custom-query-value").value = writeQueryText(paramsArray);
-  },
-
-  /**
-   * Update the url based on the query string field.
-   *
-   * @param object queryText
-   *        The contents of the query string field.
-   */
-  updateCustomUrl: function (queryText) {
-    let params = parseQueryText(queryText);
-    let queryString = writeQueryString(params);
-
-    let url = $("#custom-url-value").value;
-    let oldQuery = getUrlQuery(url);
-    let path = url.replace(oldQuery, queryString);
-
-    $("#custom-url-value").value = path;
-  }
-};
-
-/**
- * Parse text representation of multiple HTTP headers.
- *
- * @param string text
- *        Text of headers
- * @return array
- *         Array of headers info {name, value}
- */
-function parseHeadersText(text) {
-  return parseRequestText(text, "\\S+?", ":");
-}
-
-/**
- * Parse readable text list of a query string.
- *
- * @param string text
- *        Text of query string representation
- * @return array
- *         Array of query params {name, value}
- */
-function parseQueryText(text) {
-  return parseRequestText(text, ".+?", "=");
-}
-
-/**
- * Parse a text representation of a name[divider]value list with
- * the given name regex and divider character.
- *
- * @param string text
- *        Text of list
- * @return array
- *         Array of headers info {name, value}
- */
-function parseRequestText(text, namereg, divider) {
-  let regex = new RegExp("(" + namereg + ")\\" + divider + "\\s*(.+)");
-  let pairs = [];
-
-  for (let line of text.split("\n")) {
-    let matches;
-    if (matches = regex.exec(line)) { // eslint-disable-line
-      let [, name, value] = matches;
-      pairs.push({name: name, value: value});
-    }
-  }
-  return pairs;
-}
-
-/**
- * Write out a list of query params into a chunk of text
- *
- * @param array params
- *        Array of query params {name, value}
- * @return string
- *         List of query params in text format
- */
-function writeQueryText(params) {
-  return params.map(({name, value}) => name + "=" + value).join("\n");
-}
-
-/**
- * Write out a list of query params into a query string
- *
- * @param array params
- *        Array of query  params {name, value}
- * @return string
- *         Query string that can be appended to a url.
- */
-function writeQueryString(params) {
-  return params.map(({name, value}) => name + "=" + value).join("&");
-}
-
-exports.CustomRequestView = CustomRequestView;
--- a/devtools/client/netmonitor/events.js
+++ b/devtools/client/netmonitor/events.js
@@ -57,19 +57,16 @@ const EVENTS = {
 
   // When the image response thumbnail is displayed in the UI.
   RESPONSE_IMAGE_THUMBNAIL_DISPLAYED:
     "NetMonitor:ResponseImageThumbnailAvailable",
 
   // Fired when Sidebar has finished being populated.
   SIDEBAR_POPULATED: "NetMonitor:SidebarPopulated",
 
-  // Fired when CustomRequestView has finished being populated.
-  CUSTOMREQUESTVIEW_POPULATED: "NetMonitor:CustomRequestViewPopulated",
-
   // Fired when charts have been displayed in the PerformanceStatisticsView.
   PLACEHOLDER_CHARTS_DISPLAYED: "NetMonitor:PlaceholderChartsDisplayed",
   PRIMED_CACHE_CHART_DISPLAYED: "NetMonitor:PrimedChartsDisplayed",
   EMPTY_CACHE_CHART_DISPLAYED: "NetMonitor:EmptyChartsDisplayed",
 
   // Fired once the NetMonitorController establishes a connection to the debug
   // target.
   CONNECTED: "connected",
--- a/devtools/client/netmonitor/moz.build
+++ b/devtools/client/netmonitor/moz.build
@@ -10,17 +10,16 @@ DIRS += [
     'reducers',
     'selectors',
     'shared',
     'utils',
 ]
 
 DevToolsModules(
     'constants.js',
-    'custom-request-view.js',
     'events.js',
     'filter-predicates.js',
     'l10n.js',
     'netmonitor-controller.js',
     'netmonitor-view.js',
     'panel.js',
     'prefs.js',
     'request-list-context-menu.js',
--- a/devtools/client/netmonitor/netmonitor-view.js
+++ b/devtools/client/netmonitor/netmonitor-view.js
@@ -6,26 +6,28 @@
 /* globals $, gStore, NetMonitorController, dumpn */
 
 "use strict";
 
 const { testing: isTesting } = require("devtools/shared/flags");
 const { Task } = require("devtools/shared/task");
 const { ViewHelpers } = require("devtools/client/shared/widgets/view-helpers");
 const { RequestsMenuView } = require("./requests-menu-view");
-const { CustomRequestView } = require("./custom-request-view");
 const { ToolbarView } = require("./toolbar-view");
 const { SidebarView } = require("./sidebar-view");
 const { StatisticsView } = require("./statistics-view");
 const { ACTIVITY_TYPE } = require("./constants");
 const { Prefs } = require("./prefs");
 const { createFactory } = require("devtools/client/shared/vendor/react");
 const Actions = require("./actions/index");
 const ReactDOM = require("devtools/client/shared/vendor/react-dom");
 const Provider = createFactory(require("devtools/client/shared/vendor/react-redux").Provider);
+
+// components
+const CustomRequestPanel = createFactory(require("./shared/components/custom-request-panel"));
 const DetailsPanel = createFactory(require("./shared/components/details-panel"));
 
 // ms
 const WDA_DEFAULT_VERIFY_INTERVAL = 50;
 
 // Use longer timeout during testing as the tests need this process to succeed
 // and two seconds is quite short on slow debug builds. The timeout here should
 // be at least equal to the general mochitest timeout of 45 seconds so that this
@@ -40,23 +42,28 @@ var NetMonitorView = {
   /**
    * Initializes the network monitor view.
    */
   initialize: function () {
     this._initializePanes();
 
     this.Toolbar.initialize(gStore);
     this.RequestsMenu.initialize(gStore);
-    this.CustomRequest.initialize();
     this.Statistics.initialize(gStore);
 
+    this.customRequestPanel = $("#react-custom-request-panel-hook");
     this.detailsPanel = $("#react-details-panel-hook");
 
     ReactDOM.render(Provider(
       { store: gStore },
+      CustomRequestPanel(),
+    ), this.customRequestPanel);
+
+    ReactDOM.render(Provider(
+      { store: gStore },
       DetailsPanel({ toolbox: NetMonitorController._toolbox }),
     ), this.detailsPanel);
 
     // Store watcher here is for observing the statisticsOpen state change.
     // It should be removed once we migrate to react and apply react/redex binding.
     this.unsubscribeStore = gStore.subscribe(storeWatcher(
       false,
       () => gStore.getState().ui.statisticsOpen,
@@ -66,18 +73,18 @@ var NetMonitorView = {
 
   /**
    * Destroys the network monitor view.
    */
   destroy: function () {
     this._isDestroyed = true;
     this.Toolbar.destroy();
     this.RequestsMenu.destroy();
-    this.CustomRequest.destroy();
     this.Statistics.destroy();
+    ReactDOM.unmountComponentAtNode(this.customRequestPanel);
     ReactDOM.unmountComponentAtNode(this.detailsPanel);
     this.unsubscribeStore();
 
     this._destroyPanes();
   },
 
   /**
    * Initializes the UI for all the displayed panes.
@@ -263,12 +270,11 @@ function storeWatcher(initialValue, redu
 }
 
 /**
  * Preliminary setup for the NetMonitorView object.
  */
 NetMonitorView.Toolbar = new ToolbarView();
 NetMonitorView.Sidebar = new SidebarView();
 NetMonitorView.RequestsMenu = new RequestsMenuView();
-NetMonitorView.CustomRequest = new CustomRequestView();
 NetMonitorView.Statistics = new StatisticsView();
 
 exports.NetMonitorView = NetMonitorView;
--- a/devtools/client/netmonitor/netmonitor.xul
+++ b/devtools/client/netmonitor/netmonitor.xul
@@ -30,75 +30,19 @@
                   class="devtools-main-content">
         </html:div>
 
         <splitter id="network-inspector-view-splitter"
                   class="devtools-side-splitter"/>
 
         <deck id="details-pane"
               hidden="true">
-          <vbox id="custom-pane"
-                class="tabpanel-content">
-            <hbox align="baseline">
-              <label data-localization="content=netmonitor.custom.newRequest"
-                     class="plain tabpanel-summary-label
-                            custom-header"/>
-              <hbox flex="1" pack="end"
-                    class="devtools-toolbarbutton-group">
-                <button id="custom-request-send-button"
-                        class="devtools-toolbarbutton"
-                        data-localization="label=netmonitor.custom.send"/>
-                <button id="custom-request-close-button"
-                        class="devtools-toolbarbutton"
-                        data-localization="label=netmonitor.custom.cancel"/>
-              </hbox>
-            </hbox>
-            <hbox id="custom-method-and-url"
-                  class="tabpanel-summary-container"
-                  align="center">
-              <textbox id="custom-method-value"
-                       data-key="method"/>
-              <textbox id="custom-url-value"
-                       flex="1"
-                       data-key="url"/>
-            </hbox>
-            <vbox id="custom-query"
-                  class="tabpanel-summary-container custom-section">
-              <label class="plain tabpanel-summary-label"
-                     data-localization="content=netmonitor.custom.query"/>
-              <textbox id="custom-query-value"
-                       class="tabpanel-summary-input"
-                       multiline="true"
-                       rows="4"
-                       wrap="off"
-                       data-key="query"/>
-            </vbox>
-            <vbox id="custom-headers"
-                  class="tabpanel-summary-container custom-section">
-              <label class="plain tabpanel-summary-label"
-                     data-localization="content=netmonitor.custom.headers"/>
-              <textbox id="custom-headers-value"
-                       class="tabpanel-summary-input"
-                       multiline="true"
-                       rows="8"
-                       wrap="off"
-                       data-key="headers"/>
-            </vbox>
-            <vbox id="custom-postdata"
-                  class="tabpanel-summary-container custom-section">
-              <label class="plain tabpanel-summary-label"
-                     data-localization="content=netmonitor.custom.postData"/>
-              <textbox id="custom-postdata-value"
-                       class="tabpanel-summary-input"
-                       multiline="true"
-                       rows="6"
-                       wrap="off"
-                       data-key="body"/>
-            </vbox>
-          </vbox>
+          <html:div xmlns="http://www.w3.org/1999/xhtml"
+                    id="react-custom-request-panel-hook"
+                    class="tabpanel-content"/>
           <html:div xmlns="http://www.w3.org/1999/xhtml"
                     id="react-details-panel-hook"/>
         </deck>
       </hbox>
 
     </vbox>
 
     <html:div id="network-statistics-view">
--- a/devtools/client/netmonitor/reducers/requests.js
+++ b/devtools/client/netmonitor/reducers/requests.js
@@ -1,25 +1,31 @@
 /* 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/. */
 
 "use strict";
 
 const I = require("devtools/client/shared/vendor/immutable");
-const { getUrlDetails } = require("../request-utils");
+const {
+  getUrlDetails,
+  getUrlQuery,
+} = require("../request-utils");
 const {
   ADD_REQUEST,
-  UPDATE_REQUEST,
   CLEAR_REQUESTS,
+  CLONE_SELECTED_REQUEST,
+  OPEN_SIDEBAR,
+  REMOVE_SELECTED_CUSTOM_REQUEST,
   SELECT_REQUEST,
-  PRESELECT_REQUEST,
-  CLONE_SELECTED_REQUEST,
-  REMOVE_SELECTED_CUSTOM_REQUEST,
-  OPEN_SIDEBAR,
+  SEND_CUSTOM_REQUEST,
+  UPDATE_REQUEST,
+  UPDATE_SELECTED_REQUEST,
+  UPDATE_SELECTED_REQUEST_HEADER,
+  UPDATE_SELECTED_REQUEST_QUERY,
 } = require("../constants");
 
 const Request = I.Record({
   id: null,
   // Set to true in case of a request that's being edited as part of "edit and resend"
   isCustom: false,
   // Request properties - at the beginning, they are unknown and are gradually filled in
   startedMillis: undefined,
@@ -38,16 +44,18 @@ const Request = I.Record({
   securityState: undefined,
   securityInfo: undefined,
   mimeType: "text/plain",
   contentSize: undefined,
   transferredSize: undefined,
   totalTime: undefined,
   eventTimings: undefined,
   headersSize: undefined,
+  // this state only appears when user edit the custom requst form
+  customQueryValue: undefined,
   requestHeaders: undefined,
   requestHeadersFromUploadStream: undefined,
   requestCookies: undefined,
   requestPostData: undefined,
   responseHeaders: undefined,
   responseCookies: undefined,
   responseContent: undefined,
   responseContentDataUri: undefined,
@@ -76,27 +84,116 @@ const UPDATE_PROPS = [
   "securityState",
   "securityInfo",
   "mimeType",
   "contentSize",
   "transferredSize",
   "totalTime",
   "eventTimings",
   "headersSize",
+  "customQueryValue",
   "requestHeaders",
   "requestHeadersFromUploadStream",
   "requestCookies",
   "requestPostData",
   "responseHeaders",
   "responseCookies",
   "responseContent",
   "responseContentDataUri",
   "formDataSections",
 ];
 
+function updateRequest(state, action) {
+  let { requests, lastEndedMillis } = state;
+
+  let updatedRequest = requests.get(action.id);
+  if (!updatedRequest) {
+    return state;
+  }
+
+  updatedRequest = updatedRequest.withMutations(request => {
+    for (let [key, value] of Object.entries(action.data)) {
+      if (!UPDATE_PROPS.includes(key)) {
+        continue;
+      }
+
+      request[key] = value;
+
+      switch (key) {
+        case "url":
+          // Compute the additional URL details
+          request.urlDetails = getUrlDetails(value);
+          break;
+        case "totalTime":
+          const endedMillis = request.startedMillis + value;
+          lastEndedMillis = Math.max(lastEndedMillis, endedMillis);
+          break;
+        case "requestPostData":
+          request.requestHeadersFromUploadStream = {
+            headers: [],
+            headersSize: 0,
+          };
+          break;
+      }
+    }
+  });
+
+  return state.withMutations(st => {
+    st.requests = requests.set(updatedRequest.id, updatedRequest);
+    st.lastEndedMillis = lastEndedMillis;
+  });
+}
+
+/**
+ * Remove the currently selected custom request.
+ */
+function closeCustomRequest(state) {
+  let { requests, selectedId } = state;
+
+  if (!selectedId) {
+    return state;
+  }
+
+  let removedRequest = requests.get(selectedId);
+
+  // Only custom requests can be removed
+  if (!removedRequest || !removedRequest.isCustom) {
+    return state;
+  }
+
+  return state.withMutations(st => {
+    st.requests = requests.delete(removedRequest);
+    st.selectedId = null;
+  });
+}
+
+/**
+ * Parse a text representation of a name[divider]value list with
+ * the given name regex and divider character.
+ *
+ * @param {string} text - Text of list
+ * @return {array} array of headers info {name, value}
+ */
+function parseRequestText(text, namereg, divider) {
+  let regex = new RegExp(`(${namereg})\\${divider}\\s*(.+)`);
+  let pairs = [];
+
+  for (let line of text.split("\n")) {
+    let matches = regex.exec(line);
+    if (matches) {
+      let [, name, value] = matches;
+      pairs.push({
+        name,
+        value,
+      });
+    }
+  }
+  return pairs;
+}
+
 function requestsReducer(state = new Requests(), action) {
   switch (action.type) {
     case ADD_REQUEST: {
       return state.withMutations(st => {
         let newRequest = new Request(Object.assign(
           { id: action.id },
           action.data,
           { urlDetails: getUrlDetails(action.data.url) }
@@ -114,66 +211,107 @@ function requestsReducer(state = new Req
 
         // Select the request if it was preselected and there is no other selection
         if (st.preselectedId && st.preselectedId === action.id) {
           st.selectedId = st.selectedId || st.preselectedId;
           st.preselectedId = null;
         }
       });
     }
+    case UPDATE_REQUEST: {
+      return updateRequest(state, action);
+    }
+    case UPDATE_SELECTED_REQUEST: {
+      let { selectedId } = state;
 
-    case UPDATE_REQUEST: {
-      let { requests, lastEndedMillis } = state;
+      if (!selectedId) {
+        return state;
+      }
 
-      let updatedRequest = requests.get(action.id);
-      if (!updatedRequest) {
+      action.id = selectedId;
+      return updateRequest(state, action);
+    }
+    case UPDATE_SELECTED_REQUEST_HEADER: {
+      let { requests, selectedId } = state;
+
+      if (!selectedId) {
         return state;
       }
 
-      updatedRequest = updatedRequest.withMutations(request => {
-        for (let [key, value] of Object.entries(action.data)) {
-          if (!UPDATE_PROPS.includes(key)) {
-            continue;
+      let request = requests.get(selectedId);
+      let customHeadersValue = action.data.requestHeaders.customHeadersValue;
+      // Parse text representation of multiple HTTP headers
+      let headersArray = parseRequestText(customHeadersValue, "\\S+?", ":");
+      // do not update headers while headers string is not parsable
+      if (headersArray.length != customHeadersValue.split("\n").length) {
+        let paramsAction = Object.assign({}, action, {
+          id: selectedId,
+          data: {
+            requestHeaders: {
+              customHeadersValue,
+              headers: request.requestHeaders.headers,
+            }
           }
-
-          request[key] = value;
+        });
+        return updateRequest(state, paramsAction);
+      }
 
-          switch (key) {
-            case "url":
-              // Compute the additional URL details
-              request.urlDetails = getUrlDetails(value);
-              break;
-            case "totalTime":
-              const endedMillis = request.startedMillis + value;
-              lastEndedMillis = Math.max(lastEndedMillis, endedMillis);
-              break;
-            case "requestPostData":
-              request.requestHeadersFromUploadStream = {
-                headers: [],
-                headersSize: 0,
-              };
-              break;
+      let newAction = Object.assign({}, action, {
+        id: selectedId,
+        data: {
+          requestHeaders: {
+            customHeadersValue: null,
+            headers: headersArray,
           }
         }
       });
+      return updateRequest(state, newAction);
+    }
+    case UPDATE_SELECTED_REQUEST_QUERY: {
+      let { requests, selectedId } = state;
 
-      return state.withMutations(st => {
-        st.requests = requests.set(updatedRequest.id, updatedRequest);
-        st.lastEndedMillis = lastEndedMillis;
+      if (!selectedId) {
+        return state;
+      }
+
+      let customQueryValue = action.data.customQueryValue;
+      // Parse readable text list of a query string
+      let queryArray = customQueryValue ?
+        parseRequestText(customQueryValue, ".+?", "=") : [];
+      // do not update url while params string is not parsable
+      // update request.params to show current user inputs
+      if (!customQueryValue || queryArray.length != customQueryValue.split("\n").length) {
+        let queryAction = Object.assign({}, action, {
+          id: selectedId,
+          data: {
+            customQueryValue,
+          }
+        });
+        return updateRequest(state, queryAction);
+      }
+
+      let request = requests.get(selectedId);
+      // Write out a list of query params into a query string
+      let queryString = queryArray.map(({name, value}) => name + "=" + value).join("&");
+      let url = request.url.replace(getUrlQuery(request.url), queryString);
+      let newAction = Object.assign({}, action, {
+        id: selectedId,
+        data: {
+          customQueryValue: null,
+          url,
+        }
       });
+      return updateRequest(state, newAction);
     }
     case CLEAR_REQUESTS: {
       return new Requests();
     }
     case SELECT_REQUEST: {
       return state.set("selectedId", action.id);
     }
-    case PRESELECT_REQUEST: {
-      return state.set("preselectedId", action.id);
-    }
     case CLONE_SELECTED_REQUEST: {
       let { requests, selectedId } = state;
 
       if (!selectedId) {
         return state;
       }
 
       let clonedRequest = requests.get(selectedId);
@@ -192,32 +330,24 @@ function requestsReducer(state = new Req
       });
 
       return state.withMutations(st => {
         st.requests = requests.set(newRequest.id, newRequest);
         st.selectedId = newRequest.id;
       });
     }
     case REMOVE_SELECTED_CUSTOM_REQUEST: {
-      let { requests, selectedId } = state;
-
-      if (!selectedId) {
-        return state;
-      }
-
-      // Only custom requests can be removed
-      let removedRequest = requests.get(selectedId);
-      if (!removedRequest || !removedRequest.isCustom) {
-        return state;
-      }
-
-      return state.withMutations(st => {
-        st.requests = requests.delete(selectedId);
-        st.selectedId = null;
-      });
+      return closeCustomRequest(state);
+    }
+    case SEND_CUSTOM_REQUEST: {
+      // When a new request with a given id is added in future, select it immediately.
+      // where we know in advance the ID of the request, at a time when it
+      // wasn't sent yet.
+      state.set("preselectedId", action.id);
+      return closeCustomRequest(state);
     }
     case OPEN_SIDEBAR: {
       if (!action.open) {
         return state.set("selectedId", null);
       }
 
       if (!state.selectedId && !state.requests.isEmpty()) {
         return state.set("selectedId", state.requests.first().id);
--- a/devtools/client/netmonitor/request-utils.js
+++ b/devtools/client/netmonitor/request-utils.js
@@ -1,46 +1,19 @@
 /* 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-disable mozilla/reject-some-requires */
 
 "use strict";
 
-const { KeyCodes } = require("devtools/client/shared/keycodes");
 const { Task } = require("devtools/shared/task");
 
 /**
- * Helper method to get a wrapped function which can be bound to as
- * an event listener directly and is executed only when data-key is
- * present in event.target.
- *
- * @param {function} callback - function to execute execute when data-key
- *                              is present in event.target.
- * @param {bool} onlySpaceOrReturn - flag to indicate if callback should only
- *                                   be called when the space or return button
- *                                   is pressed
- * @return {function} wrapped function with the target data-key as the first argument
- *                    and the event as the second argument.
- */
-function getKeyWithEvent(callback, onlySpaceOrReturn) {
-  return function (event) {
-    let key = event.target.getAttribute("data-key");
-    let filterKeyboardEvent = !onlySpaceOrReturn ||
-                              event.keyCode === KeyCodes.DOM_VK_SPACE ||
-                              event.keyCode === KeyCodes.DOM_VK_RETURN;
-
-    if (key && filterKeyboardEvent) {
-      callback(key);
-    }
-  };
-}
-
-/**
  * Extracts any urlencoded form data sections (e.g. "?foo=bar&baz=42") from a
  * POST request.
  *
  * @param {object} headers - the "requestHeaders".
  * @param {object} uploadHeaders - the "requestHeadersFromUploadStream".
  * @param {object} postData - the "requestPostData".
  * @param {function} getString - callback to retrieve a string from a LongStringGrip.
  * @return {array} a promise list that is resolved with the extracted form data.
@@ -249,17 +222,16 @@ function parseQueryString(query) {
     return {
       name: param[0] ? decodeUnicodeUrl(param[0]) : "",
       value: param[1] ? decodeUnicodeUrl(param[1]) : "",
     };
   });
 }
 
 module.exports = {
-  getKeyWithEvent,
   getFormDataSections,
   fetchHeaders,
   formDataURI,
   writeHeaderText,
   decodeUnicodeUrl,
   getAbbreviatedMimeType,
   getUrlBaseName,
   getUrlQuery,
--- a/devtools/client/netmonitor/requests-menu-view.js
+++ b/devtools/client/netmonitor/requests-menu-view.js
@@ -134,62 +134,41 @@ RequestsMenuView.prototype = {
               { formDataSections },
               true,
             ));
           });
         }
       },
     ));
 
-    this.sendCustomRequestEvent = this.sendCustomRequest.bind(this);
-    this.closeCustomRequestEvent = this.closeCustomRequest.bind(this);
-
     this._summary = $("#requests-menu-network-summary-button");
     this._summary.setAttribute("label", L10N.getStr("networkMenu.empty"));
 
     this.onResize = this.onResize.bind(this);
     this._splitter = $("#network-inspector-view-splitter");
     this._splitter.addEventListener("mouseup", this.onResize);
     window.addEventListener("resize", this.onResize);
 
     this.tooltip = new HTMLTooltip(NetMonitorController._toolbox.doc, { type: "arrow" });
 
     this.mountPoint = $("#network-table");
     ReactDOM.render(createElement(Provider,
       { store: this.store },
       RequestList()
     ), this.mountPoint);
-
-    window.once("connected", this._onConnect.bind(this));
-  },
-
-  _onConnect() {
-    if (NetMonitorController.supportsCustomRequest) {
-      $("#custom-request-send-button")
-        .addEventListener("click", this.sendCustomRequestEvent);
-      $("#custom-request-close-button")
-        .addEventListener("click", this.closeCustomRequestEvent);
-    }
   },
 
   /**
    * Destruction function, called when the network monitor is closed.
    */
   destroy() {
     dumpn("Destroying the RequestsMenuView");
 
     Prefs.filters = getActiveFilters(this.store.getState());
 
-    // this.flushRequestsTask.disarm();
-
-    $("#custom-request-send-button")
-      .removeEventListener("click", this.sendCustomRequestEvent);
-    $("#custom-request-close-button")
-      .removeEventListener("click", this.closeCustomRequestEvent);
-
     this._splitter.removeEventListener("mouseup", this.onResize);
     window.removeEventListener("resize", this.onResize);
 
     this.tooltip.destroy();
 
     ReactDOM.unmountComponentAtNode(this.mountPoint);
   },
 
@@ -421,53 +400,12 @@ RequestsMenuView.prototype = {
     // Allow requests to settle down first.
     setNamedTimeout("resize-events", RESIZE_REFRESH_RATE, () => {
       const waterfallHeaderEl = $("#requests-menu-waterfall-header-box");
       if (waterfallHeaderEl) {
         const { width } = waterfallHeaderEl.getBoundingClientRect();
         this.store.dispatch(Actions.resizeWaterfall(width));
       }
     });
-  },
-
-  /**
-   * Create a new custom request form populated with the data from
-   * the currently selected request.
-   */
-  cloneSelectedRequest() {
-    this.store.dispatch(Actions.cloneSelectedRequest());
-  },
-
-  /**
-   * Send a new HTTP request using the data in the custom request form.
-   */
-  sendCustomRequest: function () {
-    let selected = getSelectedRequest(this.store.getState());
-
-    let data = {
-      url: selected.url,
-      method: selected.method,
-      httpVersion: selected.httpVersion,
-    };
-    if (selected.requestHeaders) {
-      data.headers = selected.requestHeaders.headers;
-    }
-    if (selected.requestPostData) {
-      data.body = selected.requestPostData.postData.text;
-    }
-
-    NetMonitorController.webConsoleClient.sendHTTPRequest(data, response => {
-      let id = response.eventActor.actor;
-      this.store.dispatch(Actions.preselectRequest(id));
-    });
-
-    this.closeCustomRequest();
-  },
-
-  /**
-   * Remove the currently selected custom request.
-   */
-  closeCustomRequest() {
-    this.store.dispatch(Actions.removeSelectedCustomRequest());
-  },
+  }
 };
 
 exports.RequestsMenuView = RequestsMenuView;
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/shared/components/custom-request-panel.js
@@ -0,0 +1,215 @@
+/* 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/. */
+
+"use strict";
+
+const {
+  DOM,
+  PropTypes,
+} = require("devtools/client/shared/vendor/react");
+const { connect } = require("devtools/client/shared/vendor/react-redux");
+const { L10N } = require("../../l10n");
+const Actions = require("../../actions/index");
+const { getSelectedRequest } = require("../../selectors/index");
+const {
+  getUrlQuery,
+  parseQueryString,
+  writeHeaderText,
+} = require("../../request-utils");
+
+const {
+  button,
+  div,
+  input,
+  label,
+  textarea,
+} = DOM;
+
+const CUSTOM_CANCEL = L10N.getStr("netmonitor.custom.cancel");
+const CUSTOM_HEADERS = L10N.getStr("netmonitor.custom.headers");
+const CUSTOM_NEW_REQUEST = L10N.getStr("netmonitor.custom.newRequest");
+const CUSTOM_POSTDATA = L10N.getStr("netmonitor.custom.postData");
+const CUSTOM_QUERY = L10N.getStr("netmonitor.custom.query");
+const CUSTOM_SEND = L10N.getStr("netmonitor.custom.send");
+
+function CustomRequestPanel({
+  removeSelectedCustomRequest,
+  request,
+  sendCustomRequest,
+  updateBody,
+  updateHeaders,
+  updateMethod,
+  updateQuery,
+  updateUrl,
+}) {
+  let method = null;
+  let customQueryValue = null;
+  let requestHeaders = null;
+  let requestPostData = null;
+  let url = null;
+
+  if (request) {
+    method = request.method || request.method;
+    customQueryValue = request.customQueryValue || customQueryValue;
+    requestHeaders = request.requestHeaders || requestHeaders;
+    requestPostData = request.requestPostData || requestPostData;
+    url = request.url || url;
+  }
+
+  let headers = "";
+  if (requestHeaders) {
+    headers = requestHeaders.customHeadersValue ?
+      requestHeaders.customHeadersValue : writeHeaderText(requestHeaders.headers);
+  }
+  let queryArray = url ? parseQueryString(getUrlQuery(url)) : [];
+  let params = customQueryValue ? customQueryValue : writeQueryText(queryArray);
+  let postData = requestPostData && requestPostData.postData.text ?
+    requestPostData.postData.text : "";
+
+  return div({ className: "custom-request-panel" },
+    div({ className: "tabpanel-summary-container custom-request" },
+      label({ className: "tabpanel-summary-label custom-header" },
+        CUSTOM_NEW_REQUEST
+      ),
+      button({
+        className: "devtools-toolbarbutton, tool-button",
+        id: "custom-request-send-button",
+        onClick: sendCustomRequest,
+      },
+        CUSTOM_SEND
+      ),
+      button({
+        className: "devtools-toolbarbutton, tool-button",
+        id: "custom-request-close-button",
+        onClick: removeSelectedCustomRequest,
+      },
+        CUSTOM_CANCEL
+      ),
+    ),
+    div({
+      className: "tabpanel-summary-container custom-method-and-url",
+      id: "custom-method-and-url",
+    },
+      input({
+        className: "custom-method-value",
+        id: "custom-method-value",
+        onChange: updateMethod,
+        value: method || "GET",
+      }),
+      input({
+        className: "custom-url-value",
+        id: "custom-url-value",
+        onChange: updateUrl,
+        value: url || "http://",
+      }),
+    ),
+    // hide query field when there is no params
+    params ? div({
+      className: "tabpanel-summary-container custom-section",
+      id: "custom-query",
+    },
+      label({ className: "tabpanel-summary-label" }, CUSTOM_QUERY),
+      textarea({
+        className: "tabpanel-summary-input",
+        id: "custom-query-value",
+        onChange: updateQuery,
+        rows: 4,
+        value: params,
+        wrap: "off",
+      })
+    ) : null,
+    div({
+      id: "custom-headers",
+      className: "tabpanel-summary-container custom-section",
+    },
+      label({ className: "tabpanel-summary-label" }, CUSTOM_HEADERS),
+      textarea({
+        className: "tabpanel-summary-input",
+        id: "custom-headers-value",
+        onChange: updateHeaders,
+        rows: 8,
+        value: headers,
+        wrap: "off",
+      })
+    ),
+    div({
+      id: "custom-postdata",
+      className: "tabpanel-summary-container custom-section",
+    },
+      label({ className: "tabpanel-summary-label" }, CUSTOM_POSTDATA),
+      textarea({
+        className: "tabpanel-summary-input",
+        id: "custom-postdata-value",
+        onChange: updateBody,
+        rows: 6,
+        value: postData,
+        wrap: "off",
+      })
+    ),
+  );
+}
+
+CustomRequestPanel.displayName = "CustomRequestPanel";
+
+CustomRequestPanel.propTypes = {
+  request: PropTypes.object,
+  removeSelectedCustomRequest: PropTypes.func,
+  sendCustomRequest: PropTypes.func,
+  updateBody: PropTypes.func,
+  updateHeaders: PropTypes.func,
+  updateMethod: PropTypes.func,
+  updateQuery: PropTypes.func,
+  updateUrl: PropTypes.func,
+};
+
+/**
+ * Write out a list of query params into a chunk of text
+ *
+ * @param {array} params
+ *        Array of query params {name, value}
+ * @return {string}
+ *         List of query params in text format
+ */
+function writeQueryText(params) {
+  return params ?
+    params.map(({name, value}) => name + "=" + value).join("\n") : "";
+}
+
+module.exports = connect(
+  state => {
+    const request = getSelectedRequest(state);
+    return { request };
+  },
+  dispatch => ({
+    removeSelectedCustomRequest: () => dispatch(Actions.removeSelectedCustomRequest()),
+    sendCustomRequest: () => dispatch(Actions.sendCustomRequest()),
+    updateBody: (evt) => {
+      let text = evt.target.value;
+      dispatch(Actions.updateSelectedRequest({
+        requestPostData: {
+          postData: { text }
+        }
+      }));
+    },
+    updateHeaders: (evt) => {
+      let customHeadersValue = evt.target.value;
+      dispatch(Actions.updateSelectedRequestHeader({
+        requestHeaders: { customHeadersValue }
+      }));
+    },
+    updateMethod: (evt) => {
+      let method = evt.target.value.trim();
+      dispatch(Actions.updateSelectedRequest({ method }));
+    },
+    // Update the url based on the query string field
+    updateQuery: (evt) => {
+      let customQueryValue = evt.target.value;
+      dispatch(Actions.updateSelectedRequestQuery({ customQueryValue }));
+    },
+    updateUrl: (evt) => {
+      let url = evt.target.value;
+      dispatch(Actions.updateSelectedRequest({ url }));
+    },
+  })
+)(CustomRequestPanel);
--- a/devtools/client/netmonitor/shared/components/moz.build
+++ b/devtools/client/netmonitor/shared/components/moz.build
@@ -1,14 +1,15 @@
 # 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(
     'cookies-panel.js',
+    'custom-request-panel.js',
     'details-panel.js',
     'editor.js',
     'headers-panel.js',
     'params-panel.js',
     'preview-panel.js',
     'properties-view.js',
     'response-panel.js',
     'security-panel.js',
--- a/devtools/client/netmonitor/sidebar-view.js
+++ b/devtools/client/netmonitor/sidebar-view.js
@@ -33,20 +33,16 @@ SidebarView.prototype = {
    * @param object data
    *        The data source (this should be the attachment of a request item).
    * @return object
    *        Returns a promise that resolves upon population of the subview.
    */
   populate: Task.async(function* (data) {
     let isCustom = data.isCustom;
 
-    if (isCustom) {
-      yield NetMonitorView.CustomRequest.populate(data);
-    }
-
     $("#details-pane").selectedIndex = isCustom ? 0 : 1;
 
     window.emit(EVENTS.SIDEBAR_POPULATED);
   })
 
 };
 
 exports.SidebarView = SidebarView;
--- a/devtools/client/netmonitor/test/browser.ini
+++ b/devtools/client/netmonitor/test/browser.ini
@@ -125,16 +125,17 @@ skip-if = (os == 'linux' && debug && bit
 [browser_net_prefs-and-l10n.js]
 [browser_net_prefs-reload.js]
 [browser_net_raw_headers.js]
 [browser_net_reload-button.js]
 [browser_net_reload-markers.js]
 [browser_net_req-resp-bodies.js]
 [browser_net_resend_cors.js]
 [browser_net_resend_headers.js]
+[browser_net_resend.js]
 [browser_net_security-details.js]
 [browser_net_security-error.js]
 [browser_net_security-icon-click.js]
 [browser_net_security-redirect.js]
 [browser_net_security-state.js]
 [browser_net_security-tab-deselect.js]
 [browser_net_security-tab-visibility.js]
 [browser_net_security-warnings.js]
--- a/devtools/client/netmonitor/test/browser_net_resend.js
+++ b/devtools/client/netmonitor/test/browser_net_resend.js
@@ -12,50 +12,49 @@ const ADD_HEADER = "Test-header: true";
 const ADD_UA_HEADER = "User-Agent: Custom-Agent";
 const ADD_POSTDATA = "&t3=t4";
 
 add_task(function* () {
   let { tab, monitor } = yield initNetMonitor(POST_DATA_URL);
   info("Starting test... ");
 
   let { panelWin } = monitor;
-  let { document, EVENTS, NetMonitorView } = panelWin;
+  let { document, gStore, EVENTS, NetMonitorView, windowRequire } = panelWin;
+  let Actions = windowRequire("devtools/client/netmonitor/actions/index");
   let { RequestsMenu } = NetMonitorView;
 
   RequestsMenu.lazyUpdate = false;
 
   let wait = waitForNetworkEvents(monitor, 0, 2);
   yield ContentTask.spawn(tab.linkedBrowser, {}, function* () {
     content.wrappedJSObject.performRequests();
   });
   yield wait;
 
   let origItem = RequestsMenu.getItemAtIndex(0);
 
   RequestsMenu.selectedItem = origItem;
 
   // add a new custom request cloned from selected request
-  let onPopulated = panelWin.once(EVENTS.CUSTOMREQUESTVIEW_POPULATED);
-  RequestsMenu.cloneSelectedRequest();
-  yield onPopulated;
+  gStore.dispatch(Actions.cloneSelectedRequest());
 
   testCustomForm(origItem);
 
   let customItem = RequestsMenu.selectedItem;
   testCustomItem(customItem, origItem);
 
   // edit the custom request
   yield editCustomForm();
   // FIXME: reread the customItem, it's been replaced by a new object (immutable!)
   customItem = RequestsMenu.selectedItem;
   testCustomItemChanged(customItem, origItem);
 
   // send the new request
   wait = waitForNetworkEvents(monitor, 0, 1);
-  RequestsMenu.sendCustomRequest();
+  gStore.dispatch(Actions.sendCustomRequest());
   yield wait;
 
   let sentItem = RequestsMenu.selectedItem;
   testSentRequest(sentItem, origItem);
 
   return teardown(monitor);
 
   function testCustomItem(item, orig) {
--- a/devtools/client/netmonitor/test/browser_net_resend_cors.js
+++ b/devtools/client/netmonitor/test/browser_net_resend_cors.js
@@ -7,17 +7,18 @@
  * Tests if resending a CORS request avoids the security checks and doesn't send
  * a preflight OPTIONS request (bug 1270096 and friends)
  */
 
 add_task(function* () {
   let { tab, monitor } = yield initNetMonitor(CORS_URL);
   info("Starting test... ");
 
-  let { EVENTS, NetMonitorView } = monitor.panelWin;
+  let { EVENTS, gStore, NetMonitorView, windowRequire } = monitor.panelWin;
+  let Actions = windowRequire("devtools/client/netmonitor/actions/index");
   let { RequestsMenu } = NetMonitorView;
 
   RequestsMenu.lazyUpdate = false;
 
   let requestUrl = "http://test1.example.com" + CORS_SJS_PATH;
 
   info("Waiting for OPTIONS, then POST");
   let wait = waitForNetworkEvents(monitor, 1, 1);
@@ -40,22 +41,20 @@ add_task(function* () {
   let onRequests = waitForNetworkEvents(monitor, 1, 1);
   for (let [i, method] of METHODS.entries()) {
     let item = RequestsMenu.getItemAtIndex(i);
 
     info(`Selecting the ${method} request (at index ${i})`);
     RequestsMenu.selectedItem = item;
 
     info("Cloning the selected request into a custom clone");
-    let onPopulate = monitor.panelWin.once(EVENTS.CUSTOMREQUESTVIEW_POPULATED);
-    RequestsMenu.cloneSelectedRequest();
-    yield onPopulate;
+    gStore.dispatch(Actions.cloneSelectedRequest());
 
     info("Sending the cloned request (without change)");
-    RequestsMenu.sendCustomRequest();
+    gStore.dispatch(Actions.sendCustomRequest());
   }
 
   info("Waiting for both resent requests");
   yield onRequests;
 
   // Check the resent requests
   for (let [i, method] of METHODS.entries()) {
     let index = i + 2;
--- a/devtools/client/themes/netmonitor.css
+++ b/devtools/client/themes/netmonitor.css
@@ -40,28 +40,24 @@
 #details-pane.pane-collapsed {
   visibility: hidden;
 }
 
 #details-pane-toggle[disabled] {
   display: none;
 }
 
-#custom-pane {
+#react-custom-request-panel-hook {
   overflow: auto;
 }
 
 #response-content-image-box {
   overflow: auto;
 }
 
-#network-statistics-charts {
-  overflow: auto;
-}
-
 .cropped-textbox .textbox-input {
   /* workaround for textbox not supporting the @crop attribute */
   text-overflow: ellipsis;
 }
 
 :root.theme-dark {
   --table-splitter-color: rgba(255,255,255,0.15);
   --table-zebra-background: rgba(255,255,255,0.05);
@@ -787,34 +783,53 @@
 }
 
 @media (min-resolution: 1.1dppx) {
   .security-warning-icon {
     background-image: url(images/alerticon-warning@2x.png);
   }
 }
 
-/* Custom request form */
+/* Custom request view */
+
+#react-custom-request-panel-hook {
+  padding: 0.6em 1em;
+}
 
-#custom-pane {
-  padding: 0.6em 0.5em;
+.custom-request-panel {
+  height: 100vh;
+}
+
+.custom-header,
+.custom-method-and-url,
+.custom-request,
+.custom-section {
+  display: flex;
 }
 
 .custom-header {
+  flex-grow: 1;
   font-size: 1.1em;
 }
 
 .custom-section {
+  flex-direction: column;
   margin-top: 0.5em;
 }
 
-#custom-method-value {
+
+.custom-method-value {
   width: 4.5em;
 }
 
+.custom-url-value {
+  flex-grow: 1;
+  margin-inline-start: 6px;
+}
+
 /* Performance analysis buttons */
 
 #requests-menu-network-summary-button {
   display: flex;
   flex-wrap: nowrap;
   align-items: center;
   background: none;
   box-shadow: none;
@@ -841,17 +856,17 @@
 #requests-menu-network-summary-button:hover > .summary-info-icon,
 #requests-menu-network-summary-button:hover > .summary-info-text {
   opacity: 1;
 }
 
 /* Performance analysis view */
 
 #network-statistics-view {
-  display: -moz-box;
+  display: flex;
 }
 
 #network-statistics-toolbar {
   border: none;
   margin: 0;
   padding: 0;
 }
 
@@ -869,20 +884,22 @@
 #network-statistics-view-splitter {
   border-color: rgba(0,0,0,0.2);
   cursor: default;
   pointer-events: none;
 }
 
 #network-statistics-charts {
   min-height: 1px;
-}
-
-#network-statistics-charts {
   background-color: var(--theme-sidebar-background);
+  overflow: auto;
+  display: flex;
+  flex-grow: 1;
+  align-items: center;
+  justify-content: center;
 }
 
 #network-statistics-charts .pie-chart-container {
   margin-inline-start: 3vw;
   margin-inline-end: 1vw;
 }
 
 #network-statistics-charts .table-chart-container {
@@ -1206,45 +1223,45 @@
 }
 
 .headers-summary,
 .response-summary {
   display: flex;
   align-items: center;
 }
 
-.headers-summary .tool-button {
+.tool-button {
   border: 1px solid transparent;
   color: var(--theme-body-color);
   transition: background 0.05s ease-in-out;
   margin-inline-end: 6px;
   padding: 0 5px;
 }
 
-.theme-light .headers-summary .tool-button {
+.theme-light .tool-button {
   background-color: var(--toolbar-tab-hover);
 }
 
-.theme-light .headers-summary .tool-button:hover {
+.theme-light .tool-button:hover {
   background-color: rgba(170, 170, 170, 0.3);
 }
 
-.theme-light .headers-summary .tool-button:hover:active {
+.theme-light .tool-button:hover:active {
   background-color: var(--toolbar-tab-hover-active);
 }
 
-.theme-dark .headers-summary .tool-button {
+.theme-dark .tool-button {
   background-color: rgba(0, 0, 0, 0.2);
 }
 
-.theme-dark .headers-summary .tool-button:hover {
+.theme-dark .tool-button:hover {
   background-color: rgba(0, 0, 0, 0.3);
 }
 
-.theme-dark .headers-summary .tool-button:hover:active {
+.theme-dark .tool-button:hover:active {
   background-color: rgba(0, 0, 0, 0.4);
 }
 
 .headers-summary .requests-menu-status-icon {
   min-width: 10px;
 }
 
 .headers-summary .raw-headers-container {
@@ -1285,20 +1302,13 @@
   width: 100%;
   height: 100%;
 }
 
 /*
  * FIXME: normal html block element cannot fill outer XUL element
  * This workaround should be removed after netmonitor is migrated to react
  */
-
+#react-custom-request-panel-hook,
 #react-details-panel-hook {
   display: flex;
   flex-direction: column;
 }
-
-#network-statistics-charts,
-#primed-cache-chart,
-#empty-cache-chart {
-  display: -moz-box;
-  -moz-box-flex: 1;
-}