Bug 1308449 - Implement custom request view;r=honza,rickychien draft
authorFred Lin <gasolin@mozilla.com>
Tue, 27 Dec 2016 10:17:27 +0800
changeset 481110 f4f18a3993457b62171bf289b9d76113f9043990
parent 481078 d286cc530247452509a181f48c9030ef197984b5
child 545117 f135fd53a0bebdfb79ab42e8e07923db1277afa3
push id44725
push userbmo:gasolin@mozilla.com
push dateThu, 09 Feb 2017 09:18:37 +0000
reviewershonza, rickychien
bugs1308449
milestone54.0a1
Bug 1308449 - Implement custom request view;r=honza,rickychien move send/cancel func to component use thunk to handle sendHTTPRequest use single updateRequest method to update all form values MozReview-Commit-ID: Ed2hhSdCFpW
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/selectors/requests.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_raw_headers.js
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,21 +1,25 @@
 /* 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 {
   ADD_REQUEST,
-  UPDATE_REQUEST,
+  CLEAR_REQUESTS,
   CLONE_SELECTED_REQUEST,
   REMOVE_SELECTED_CUSTOM_REQUEST,
-  CLEAR_REQUESTS,
+  SEND_CUSTOM_REQUEST,
+  UPDATE_REQUEST,
 } = require("../constants");
+const { getSelectedRequest } = require("../selectors/index");
 
 function addRequest(id, data, batch) {
   return {
     type: ADD_REQUEST,
     id,
     data,
     meta: { batch },
   };
@@ -36,16 +40,53 @@ function updateRequest(id, data, batch) 
  */
 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 selected = getSelectedRequest(getState());
+
+    if (!selected) {
+      return;
+    }
+
+    // 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) => {
+      return dispatch({
+        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 +94,14 @@ function removeSelectedCustomRequest() {
 function clearRequests() {
   return {
     type: CLEAR_REQUESTS
   };
 }
 
 module.exports = {
   addRequest,
-  updateRequest,
+  clearRequests,
   cloneSelectedRequest,
   removeSelectedCustomRequest,
-  clearRequests,
+  sendCustomRequest,
+  updateRequest,
 };
--- 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,20 +16,20 @@ 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",
   WATERFALL_RESIZE: "WATERFALL_RESIZE",
 };
 
 // Descriptions for what this frontend is currently doing.
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
@@ -4,40 +4,47 @@
 
 /* globals $, gStore, NetMonitorController, dumpn */
 
 "use strict";
 
 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 { SidebarView } = require("./sidebar-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"));
 const StatisticsPanel = createFactory(require("./components/statistics-panel"));
 const Toolbar = createFactory(require("./components/toolbar"));
 
 /**
  * Object defining the network monitor view components.
  */
 var NetMonitorView = {
   /**
    * Initializes the network monitor view.
    */
   initialize: function () {
     this._initializePanes();
 
+    this.customRequestPanel = $("#react-custom-request-panel-hook");
+
+    ReactDOM.render(Provider(
+      { store: gStore },
+      CustomRequestPanel(),
+    ), this.customRequestPanel);
+
     this.detailsPanel = $("#react-details-panel-hook");
 
     ReactDOM.render(Provider(
       { store: gStore },
       DetailsPanel({ toolbox: NetMonitorController._toolbox }),
     ), this.detailsPanel);
 
     this.statisticsPanel = $("#statistics-panel");
@@ -50,34 +57,33 @@ var NetMonitorView = {
     this.toolbar = $("#react-toolbar-hook");
 
     ReactDOM.render(Provider(
       { store: gStore },
       Toolbar(),
     ), this.toolbar);
 
     this.RequestsMenu.initialize(gStore);
-    this.CustomRequest.initialize();
 
     // 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,
       this.toggleFrontendMode.bind(this)
     ));
   },
 
   /**
    * Destroys the network monitor view.
    */
   destroy: function () {
     this._isDestroyed = true;
     this.RequestsMenu.destroy();
-    this.CustomRequest.destroy();
+    ReactDOM.unmountComponentAtNode(this.customRequestPanel);
     ReactDOM.unmountComponentAtNode(this.detailsPanel);
     ReactDOM.unmountComponentAtNode(this.statisticsPanel);
     ReactDOM.unmountComponentAtNode(this.toolbar);
     this.unsubscribeStore();
 
     this._destroyPanes();
   },
 
@@ -190,11 +196,10 @@ function storeWatcher(initialValue, redu
   };
 }
 
 /**
  * Preliminary setup for the NetMonitorView object.
  */
 NetMonitorView.Sidebar = new SidebarView();
 NetMonitorView.RequestsMenu = new RequestsMenuView();
-NetMonitorView.CustomRequest = new CustomRequestView();
 
 exports.NetMonitorView = NetMonitorView;
--- a/devtools/client/netmonitor/netmonitor.xul
+++ b/devtools/client/netmonitor/netmonitor.xul
@@ -30,75 +30,18 @@
                   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"/>
           <html:div xmlns="http://www.w3.org/1999/xhtml"
                     id="react-details-panel-hook"/>
         </deck>
       </hbox>
 
     </vbox>
 
     <html:div xmlns="http://www.w3.org/1999/xhtml"
--- a/devtools/client/netmonitor/reducers/requests.js
+++ b/devtools/client/netmonitor/reducers/requests.js
@@ -3,23 +3,23 @@
  * 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 {
   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,
 } = 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 +38,19 @@ const Request = I.Record({
   securityState: undefined,
   securityInfo: undefined,
   mimeType: "text/plain",
   contentSize: undefined,
   transferredSize: undefined,
   totalTime: undefined,
   eventTimings: undefined,
   headersSize: undefined,
+  // Text value is used for storing custom request query
+  // which 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 +79,51 @@ const UPDATE_PROPS = [
   "securityState",
   "securityInfo",
   "mimeType",
   "contentSize",
   "transferredSize",
   "totalTime",
   "eventTimings",
   "headersSize",
+  "customQueryValue",
   "requestHeaders",
   "requestHeadersFromUploadStream",
   "requestCookies",
   "requestPostData",
   "responseHeaders",
   "responseCookies",
   "responseContent",
   "responseContentDataUri",
   "formDataSections",
 ];
 
+/**
+ * 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 = st.requests.delete(selectedId);
+    st.selectedId = null;
+  });
+}
+
 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,17 +141,16 @@ 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: {
       let { requests, lastEndedMillis } = state;
 
       let updatedRequest = requests.get(action.id);
       if (!updatedRequest) {
         return state;
       }
 
@@ -161,19 +187,16 @@ function requestsReducer(state = new Req
       });
     }
     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 +215,23 @@ 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.
+      return closeCustomRequest(state.set("preselectedId", action.id));
     }
     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;
--- a/devtools/client/netmonitor/selectors/requests.js
+++ b/devtools/client/netmonitor/selectors/requests.js
@@ -89,17 +89,17 @@ const getDisplayedRequestsSummary = crea
       bytes: totalBytes,
       millis: totalMillis,
     };
   }
 );
 
 const getSelectedRequest = createSelector(
   state => state.requests,
-  ({ selectedId, requests }) => selectedId ? requests.get(selectedId) : null
+  ({ selectedId, requests }) => selectedId ? requests.get(selectedId) : undefined
 );
 
 function getRequestById(state, id) {
   return state.requests.requests.get(id);
 }
 
 function getDisplayedRequestById(state, id) {
   return getDisplayedRequests(state).find(r => r.id === id);
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/shared/components/custom-request-panel.js
@@ -0,0 +1,257 @@
+/* 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,
+  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,
+  updateRequest,
+}) {
+  let {
+    method,
+    customQueryValue,
+    requestHeaders,
+    requestPostData,
+    url,
+  } = request;
+
+  let headers = "";
+  if (requestHeaders) {
+    headers = requestHeaders.customHeadersValue ?
+      requestHeaders.customHeadersValue : writeHeaderText(requestHeaders.headers);
+  }
+  let queryArray = url ? parseQueryString(getUrlQuery(url)) : [];
+  let params = customQueryValue;
+  if (!params) {
+    params = queryArray ?
+      queryArray.map(({ name, value }) => name + "=" + value).join("\n") : "";
+  }
+  let postData = requestPostData && requestPostData.postData.text ?
+    requestPostData.postData.text : "";
+
+  return (
+    div({ className: "custom-request-panel" },
+      div({ className: "tabpanel-summary-container custom-request" },
+        div({ className: "custom-request-label custom-header" },
+          CUSTOM_NEW_REQUEST
+        ),
+        button({
+          className: "devtools-button",
+          id: "custom-request-send-button",
+          onClick: sendCustomRequest,
+        },
+          CUSTOM_SEND
+        ),
+        button({
+          className: "devtools-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: (evt) => updateCustomRequestFields(evt, request, updateRequest),
+          value: method || "GET",
+        }),
+        input({
+          className: "custom-url-value",
+          id: "custom-url-value",
+          onChange: (evt) => updateCustomRequestFields(evt, request, updateRequest),
+          value: url || "http://",
+        }),
+      ),
+      // Hide query field when there is no params
+      params ? div({
+        className: "tabpanel-summary-container custom-section",
+        id: "custom-query",
+      },
+        div({ className: "custom-request-label" }, CUSTOM_QUERY),
+        textarea({
+          className: "tabpanel-summary-input",
+          id: "custom-query-value",
+          onChange: (evt) => updateCustomRequestFields(evt, request, updateRequest),
+          rows: 4,
+          value: params,
+          wrap: "off",
+        })
+      ) : null,
+      div({
+        id: "custom-headers",
+        className: "tabpanel-summary-container custom-section",
+      },
+        div({ className: "custom-request-label" }, CUSTOM_HEADERS),
+        textarea({
+          className: "tabpanel-summary-input",
+          id: "custom-headers-value",
+          onChange: (evt) => updateCustomRequestFields(evt, request, updateRequest),
+          rows: 8,
+          value: headers,
+          wrap: "off",
+        })
+      ),
+      div({
+        id: "custom-postdata",
+        className: "tabpanel-summary-container custom-section",
+      },
+        div({ className: "custom-request-label" }, CUSTOM_POSTDATA),
+        textarea({
+          className: "tabpanel-summary-input",
+          id: "custom-postdata-value",
+          onChange: (evt) => updateCustomRequestFields(evt, request, updateRequest),
+          rows: 6,
+          value: postData,
+          wrap: "off",
+        })
+      ),
+    )
+  );
+}
+
+CustomRequestPanel.displayName = "CustomRequestPanel";
+
+CustomRequestPanel.propTypes = {
+  removeSelectedCustomRequest: PropTypes.func.isRequired,
+  request: PropTypes.object,
+  sendCustomRequest: PropTypes.func.isRequired,
+  updateRequest: PropTypes.func.isRequired,
+};
+
+/**
+ * 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;
+}
+
+/**
+ * Update Custom Request Fields
+ *
+ * @param {Object} evt click event
+ * @param {Object} request current request
+ * @param {updateRequest} updateRequest action
+ */
+function updateCustomRequestFields(evt, request, updateRequest) {
+  const val = evt.target.value;
+  let data;
+  switch (evt.target.id) {
+    case "custom-headers-value":
+      let customHeadersValue = val || "";
+      // Parse text representation of multiple HTTP headers
+      let headersArray = parseRequestText(customHeadersValue, "\\S+?", ":");
+      // Remove temp customHeadersValue while query string is parsable
+      if (customHeadersValue === "" ||
+          headersArray.length === customHeadersValue.split("\n").length) {
+        customHeadersValue = null;
+      }
+      data = {
+        requestHeaders: {
+          customHeadersValue,
+          headers: headersArray,
+        },
+      };
+      break;
+    case "custom-method-value":
+      data = { method: val.trim() };
+      break;
+    case "custom-postdata-value":
+      data = {
+        requestPostData: {
+          postData: { text: val },
+        }
+      };
+      break;
+    case "custom-query-value":
+      let customQueryValue = val || "";
+      // Parse readable text list of a query string
+      let queryArray = customQueryValue ?
+        parseRequestText(customQueryValue, ".+?", "=") : [];
+      // Write out a list of query params into a query string
+      let queryString = queryArray.map(
+        ({ name, value }) => name + "=" + value).join("&");
+      let url = queryString ? [request.url.split("?")[0], queryString].join("?") :
+        request.url.split("?")[0];
+      // Remove temp customQueryValue while query string is parsable
+      if (customQueryValue === "" ||
+          queryArray.length === customQueryValue.split("\n").length) {
+        customQueryValue = null;
+      }
+      data = {
+        customQueryValue,
+        url,
+      };
+      break;
+    case "custom-url-value":
+      data = {
+        customQueryValue: null,
+        url: val
+      };
+      break;
+    default:
+      break;
+  }
+  if (data) {
+    // All updateRequest batch mode should be disabled to make UI editing in sync
+    updateRequest(request.id, data, false);
+  }
+}
+
+module.exports = connect(
+  (state) => ({ request: getSelectedRequest(state) }),
+  (dispatch) => ({
+    removeSelectedCustomRequest: () => dispatch(Actions.removeSelectedCustomRequest()),
+    sendCustomRequest: () => dispatch(Actions.sendCustomRequest()),
+    updateRequest: (id, data, batch) => dispatch(Actions.updateRequest(id, data, batch)),
+  })
+)(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-mdn.js',
     'headers-panel.js',
     'params-panel.js',
     'preview-panel.js',
     'properties-view.js',
     'response-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
@@ -129,16 +129,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_raw_headers.js
+++ b/devtools/client/netmonitor/test/browser_net_raw_headers.js
@@ -25,23 +25,23 @@ add_task(function* () {
   let origItem = RequestsMenu.getItemAtIndex(0);
 
   wait = waitForDOM(document, ".headers-overview");
   RequestsMenu.selectedItem = origItem;
   yield wait;
 
   wait = waitForDOM(document, ".raw-headers-container textarea", 2);
   EventUtils.sendMouseEvent({ type: "click" },
-    document.querySelectorAll(".tool-button")[1]);
+    document.querySelectorAll(".headers-summary .tool-button")[1]);
   yield wait;
 
   testShowRawHeaders(origItem);
 
   EventUtils.sendMouseEvent({ type: "click" },
-    document.querySelectorAll(".tool-button")[1]);
+    document.querySelectorAll(".headers-summary .tool-button")[1]);
 
   testHideRawHeaders(document);
 
   return teardown(monitor);
 
   /*
    * Tests that raw headers were displayed correctly
    */
--- 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, 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,72 +7,67 @@
  * 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 { 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);
   yield ContentTask.spawn(tab.linkedBrowser, requestUrl, function* (url) {
     content.wrappedJSObject.performRequests(url, "triggering/preflight", "post-data");
   });
   yield wait;
 
   const METHODS = ["OPTIONS", "POST"];
+  const ITEMS = METHODS.map((val, i) => RequestsMenu.getItemAtIndex(i));
 
   // Check the requests that were sent
-  for (let [i, method] of METHODS.entries()) {
-    let item = RequestsMenu.getItemAtIndex(i);
-    is(item.method, method, `The ${method} request has the right method`);
-    is(item.url, requestUrl, `The ${method} request has the right URL`);
-  }
+  ITEMS.forEach((item, i) => {
+    is(item.method, METHODS[i], `The ${item.method} request has the right method`);
+    is(item.url, requestUrl, `The ${item.method} request has the right URL`);
+  });
 
   // Resend both requests without modification. Wait for resent OPTIONS, then POST.
   // POST is supposed to have no preflight OPTIONS request this time (CORS is disabled)
-  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})`);
+  let onRequests = waitForNetworkEvents(monitor, 1, 0);
+  ITEMS.forEach((item) => {
+    info(`Selecting the ${item.method} request`);
     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;
-    let item = RequestsMenu.getItemAtIndex(index);
-    is(item.method, method, `The ${method} request has the right method`);
-    is(item.url, requestUrl, `The ${method} request has the right URL`);
-    is(item.status, 200, `The ${method} response has the right status`);
+  ITEMS.forEach((item, i) => {
+    is(item.method, METHODS[i], `The ${item.method} request has the right method`);
+    is(item.url, requestUrl, `The ${item.method} request has the right URL`);
+    is(item.status, 200, `The ${item.method} response has the right status`);
 
-    if (method === "POST") {
+    if (item.method === "POST") {
       is(item.requestPostData.postData.text, "post-data",
         "The POST request has the right POST data");
       // eslint-disable-next-line mozilla/no-cpows-in-tests
       is(item.responseContent.content.text, "Access-Control-Allow-Origin: *",
         "The POST response has the right content");
     }
-  }
+  });
 
   info("Finishing the test");
   return teardown(monitor);
 });
--- a/devtools/client/themes/netmonitor.css
+++ b/devtools/client/themes/netmonitor.css
@@ -23,16 +23,17 @@
 
 .devtools-toolbar-group {
   display: flex;
   flex: 0 0 auto;
   flex-wrap: nowrap;
   align-items: center;
 }
 
+.custom-request-panel,
 #details-pane {
   /* Make details-pane's width adjustable by splitter */
   min-width: 50px;
 }
 
 /**
  * Collapsed details pane needs to be truly hidden to prevent both accessibility
  * tools and keyboard from accessing its contents.
@@ -40,28 +41,20 @@
 #details-pane.pane-collapsed {
   visibility: hidden;
 }
 
 #details-pane-toggle[disabled] {
   display: none;
 }
 
-#custom-pane {
-  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);
@@ -91,17 +84,17 @@
   --sort-ascending-image: url(chrome://devtools/skin/images/sort-arrows.svg#ascending);
   --sort-descending-image: url(chrome://devtools/skin/images/sort-arrows.svg#descending);
 }
 
 :root.theme-firebug {
   --table-splitter-color: rgba(0,0,0,0.15);
   --table-zebra-background: rgba(0,0,0,0.05);
 
-  --timing-blocked-color:  rgba(235, 83, 104, 0.8); /* red */
+  --timing-blocked-color: rgba(235, 83, 104, 0.8); /* red */
   --timing-dns-color: rgba(223, 128, 255, 0.8); /* pink */
   --timing-connect-color: rgba(217, 102, 41, 0.8); /* orange */
   --timing-send-color: rgba(70, 175, 227, 0.8); /* light blue */
   --timing-wait-color: rgba(94, 136, 176, 0.8); /* blue grey */
   --timing-receive-color: rgba(112, 191, 83, 0.8); /* green */
 
   --sort-ascending-image: url(chrome://devtools/skin/images/firebug/arrow-up.svg);
   --sort-descending-image: url(chrome://devtools/skin/images/firebug/arrow-down.svg);
@@ -656,24 +649,16 @@
 
 #details-pane-toggle.pane-collapsed:-moz-locale-dir(ltr)::before,
 #details-pane-toggle:-moz-locale-dir(rtl)::before {
   background-image: var(--theme-pane-expand-image);
 }
 
 /* Network request details tabpanels */
 
-.tabpanel-content {
-  background-color: var(--theme-sidebar-background);
-}
-
-.theme-dark .tabpanel-content {
-  color: var(--theme-selection-color);
-}
-
 .theme-firebug .variables-view-scope:focus > .title {
   color: var(--theme-body-color);
 }
 
 /* Summary tabpanel */
 
 .tabpanel-summary-container {
   padding: 1px;
@@ -780,34 +765,71 @@
 }
 
 @media (min-resolution: 1.1dppx) {
   .security-warning-icon {
     background-image: url(images/alerticon-warning@2x.png);
   }
 }
 
-/* Custom request form */
+/* Custom request view */
+
+.custom-request-panel {
+  overflow: auto;
+  /* Full view hight - toolbar height */
+  height: calc(100vh - 24px);
+  padding: 6px 4px;
+  background-color: var(--theme-sidebar-background);
+}
+
+.theme-dark .custom-request-panel {
+  color: var(--theme-selection-color);
+}
 
-#custom-pane {
-  padding: 0.6em 0.5em;
+.custom-request-label {
+  font-weight: 600;
+}
+
+.custom-request-panel textarea {
+  resize: none;
+  font: message-box;
+}
+
+.custom-request-panel .devtools-button {
+  margin: 3px 1px;
+  min-width: 78px;
+}
+
+.custom-header,
+.custom-method-and-url,
+.custom-request,
+.custom-section {
+  display: flex;
 }
 
 .custom-header {
+  flex-grow: 1;
   font-size: 1.1em;
+  padding-top: 4px;
 }
 
 .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;
@@ -997,16 +1019,21 @@
     height: 22px;
   }
 
   .requests-menu-header-button {
     min-height: 22px;
     padding-left: 8px;
   }
 
+  .custom-request-panel {
+    height: 100%;
+  }
+
+  .custom-request-panel,
   #details-pane {
     margin: 0 !important;
     /* To prevent all the margin hacks to hide the sidebar. */
   }
 
   .requests-menu-status {
     max-width: none;
     width: 10vw;
@@ -1207,59 +1234,64 @@
 
 .headers-summary,
 .response-summary {
   display: flex;
   align-items: center;
 }
 
 .headers-summary .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 {
+.tool-button {
+  background: transparent;
+  border: none;
+  border-color: var(--toolbar-button-border-color);
+  color: var(--theme-body-color);
+  min-height: 18px;
+  transition: background 0.05s ease-in-out;
+}
+
+.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 {
   display: flex;
   width: 100%;
 }
 
 .headers-summary .raw-headers {
   width: 50%;
-  padding: 0px 4px;
+  padding: 0 4px;
 }
 
 .headers-summary .raw-headers textarea {
   width: 100%;
   height: 50vh;
   font: message-box;
   resize: none;
   box-sizing: border-box;
@@ -1292,16 +1324,17 @@
  */
 #network-table {
   display: -moz-box;
   -moz-box-orient: vertical;
   -moz-box-flex: 1;
   overflow: hidden;
 }
 
+#react-custom-request-panel-hook,
 #statistics-panel,
 #react-details-panel-hook {
   display: flex;
   flex-direction: column;
 }
 
 #primed-cache-chart,
 #empty-cache-chart {