Bug 1308449 - Implement custom request view draft
authorFred Lin <gasolin@mozilla.com>
Tue, 27 Dec 2016 10:17:27 +0800
changeset 464884 6bdfcbb67c0c0c0c8fdda4ecd695c79b4b1cdb66
parent 464883 5267191b229f425a34f26f0d7a96fd5391f3105d
child 543030 c65b741106f4be9d1d02a21494b5bbd6c7a536df
push id42471
push userbmo:gasolin@mozilla.com
push dateMon, 23 Jan 2017 07:57:07 +0000
bugs1308449
milestone53.0a1
Bug 1308449 - Implement custom request view show postdata unchange data instead of data-key implement selected_update in reducer instead of use mergeProps move updateQuery dispatch to UPDATE_SELECTED_REQUEST_QUERY move send/cancel func to component use thunk to handle sendHTTPRequest MozReview-Commit-ID: Bmtk3ZxGNhc
devtools/client/netmonitor/actions/requests.js
devtools/client/netmonitor/constants.js
devtools/client/netmonitor/custom-request-view.js
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/themes/netmonitor.css
--- a/devtools/client/netmonitor/actions/requests.js
+++ b/devtools/client/netmonitor/actions/requests.js
@@ -1,20 +1,24 @@
 /* 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_QUERY,
 } = require("../constants");
 
 function addRequest(id, data, batch) {
   return {
     type: ADD_REQUEST,
     id,
     data,
     meta: { batch },
@@ -25,27 +29,84 @@ 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 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();
+  }
+
+  /* eslint-disable consistent-return */
+  return (dispatch, getState) => {
+    const state = getState();
+    const selected = getSelectedRequest(state);
+
+    if (!selected) {
+      return 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 => {
+      let id = response.eventActor.actor;
+      return {
+        type: SEND_CUSTOM_REQUEST,
+        id: id,
+      };
+    });
+  };
+  /* eslint-enable consistent-return */
+}
+
+/**
  * 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 +114,16 @@ function removeSelectedCustomRequest() {
 function clearRequests() {
   return {
     type: CLEAR_REQUESTS
   };
 }
 
 module.exports = {
   addRequest,
-  updateRequest,
+  clearRequests,
   cloneSelectedRequest,
   removeSelectedCustomRequest,
-  clearRequests,
+  sendCustomRequest,
+  updateRequest,
+  updateSelectedRequest,
+  updateSelectedRequestQuery,
 };
--- a/devtools/client/netmonitor/constants.js
+++ b/devtools/client/netmonitor/constants.js
@@ -19,20 +19,23 @@ const actionTypes = {
   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",
+  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_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,
 
--- a/devtools/client/netmonitor/custom-request-view.js
+++ b/devtools/client/netmonitor/custom-request-view.js
@@ -1,222 +1,59 @@
 /* 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 */
+/* globals window, dumpn, $, EVENTS */
 
 "use strict";
 
+const { createFactory } = require("devtools/client/shared/vendor/react");
 const { Task } = require("devtools/shared/task");
-const { writeHeaderText,
-        getKeyWithEvent,
-        getUrlQuery,
-        parseQueryString } = require("./request-utils");
-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);
+const CustomRequestPanel = createFactory(require("./shared/components/custom-request-panel"));
 
 /**
  * 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 () {
+  initialize: function (store) {
     dumpn("Initializing the CustomRequestView");
 
-    this.updateCustomRequestEvent = getKeyWithEvent(this.onUpdate.bind(this));
-    $("#custom-pane").addEventListener("input",
-      this.updateCustomRequestEvent);
+    this.customRequestPanel = $("#react-custom-request-panel-hook");
+
+    ReactDOM.render(Provider(
+      { store },
+      CustomRequestPanel()
+    ), this.customRequestPanel);
   },
 
   /**
    * Destruction function, called when the network monitor is closed.
    */
   destroy: function () {
     dumpn("Destroying the CustomRequestView");
 
-    $("#custom-pane").removeEventListener("input",
-      this.updateCustomRequestEvent);
+    ReactDOM.unmountComponentAtNode(this.customRequestPanel);
   },
 
   /**
    * 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/netmonitor-view.js
+++ b/devtools/client/netmonitor/netmonitor-view.js
@@ -38,17 +38,17 @@ var NetMonitorView = {
    * Initializes the network monitor view.
    */
   initialize: function () {
     this._initializePanes();
 
     this.Toolbar.initialize(gStore);
     this.RequestsMenu.initialize(gStore);
     this.NetworkDetails.initialize(gStore);
-    this.CustomRequest.initialize();
+    this.CustomRequest.initialize(gStore);
     this.Statistics.initialize(gStore);
 
     // 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)
--- 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"/>
           <tabbox id="event-details-pane"
                   class="devtools-sidebar-tabs"
                   handleCtrlTab="false">
             <tabs>
               <tab id="headers-tab"
                    crop="end"
                    data-localization="label=netmonitor.tab.headers"/>
               <tab id="cookies-tab"
--- 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,
-  SELECT_REQUEST,
+  CLONE_SELECTED_REQUEST,
+  OPEN_SIDEBAR,
   PRESELECT_REQUEST,
-  CLONE_SELECTED_REQUEST,
   REMOVE_SELECTED_CUSTOM_REQUEST,
-  OPEN_SIDEBAR,
+  SELECT_REQUEST,
+  SEND_CUSTOM_REQUEST,
+  UPDATE_REQUEST,
+  UPDATE_SELECTED_REQUEST,
+  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,
@@ -87,16 +93,92 @@ const UPDATE_PROPS = [
   "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;
+  });
+}
+
+/**
+ * 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("&");
+}
+
 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,56 +196,47 @@ 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;
-
-      let updatedRequest = requests.get(action.id);
-      if (!updatedRequest) {
+      if (!selectedId) {
         return state;
       }
 
-      updatedRequest = updatedRequest.withMutations(request => {
-        for (let [key, value] of Object.entries(action.data)) {
-          if (!UPDATE_PROPS.includes(key)) {
-            continue;
-          }
-
-          request[key] = value;
+      action.id = selectedId;
+      return updateRequest(state, action);
+    }
+    case UPDATE_SELECTED_REQUEST_QUERY: {
+      let { requests, selectedId } = state;
 
-          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;
-          }
+      if (!selectedId) {
+        return state;
+      }
+
+      let request = requests.get(selectedId);
+      let queryString = writeQueryString(action.data.params);
+      let url = request.url.replace(getUrlQuery(request.url), queryString);
+      let newAction = Object.assign({}, action, {
+        id: selectedId,
+        data: {
+          url,
         }
       });
-
-      return state.withMutations(st => {
-        st.requests = requests.set(updatedRequest.id, updatedRequest);
-        st.lastEndedMillis = lastEndedMillis;
-      });
+      delete newAction.data.params;
+      return updateRequest(state, newAction);
     }
     case CLEAR_REQUESTS: {
       return new Requests();
     }
     case SELECT_REQUEST: {
       return state.set("selectedId", action.id);
     }
     case PRESELECT_REQUEST: {
@@ -192,32 +265,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);
   },
 
@@ -437,40 +416,14 @@ RequestsMenuView.prototype = {
    */
   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());
+  sendCustomRequest() {
+    this.store.dispatch(Actions.sendCustomRequest());
   },
 };
 
 exports.RequestsMenuView = RequestsMenuView;
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/shared/components/custom-request-panel.js
@@ -0,0 +1,267 @@
+/* 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_NEW_REQUEST = L10N.getStr("netmonitor.custom.newRequest");
+const CUSTOM_SEND = L10N.getStr("netmonitor.custom.send");
+const CUSTOM_CANCEL = L10N.getStr("netmonitor.custom.cancel");
+const CUSTOM_QUERY = L10N.getStr("netmonitor.custom.query");
+const CUSTOM_HEADERS = L10N.getStr("netmonitor.custom.headers");
+const CUSTOM_POSTDATA = L10N.getStr("netmonitor.custom.postData");
+
+function CustomRequestPanel({
+  headers,
+  method,
+  paramsArray,
+  postData,
+  removeSelectedCustomRequest,
+  sendCustomRequest,
+  updateBody,
+  updateHeaders,
+  updateMethod,
+  updateQuery,
+  updateUrl,
+  url,
+}) {
+  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,
+      }),
+      input({
+        className: "custom-url-value",
+        id: "custom-url-value",
+        onChange: updateUrl,
+        value: url,
+      }),
+    ),
+    // hide query field when there is no params
+    paramsArray ? 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: writeQueryText(paramsArray),
+        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 = {
+  headers: PropTypes.string,
+  method: PropTypes.string.isRequired,
+  paramsArray: PropTypes.array,
+  postData: PropTypes.string,
+  removeSelectedCustomRequest: PropTypes.func,
+  sendCustomRequest: PropTypes.func,
+  updateBody: PropTypes.func,
+  updateHeaders: PropTypes.func,
+  updateMethod: PropTypes.func,
+  updateQuery: PropTypes.func,
+  updateUrl: PropTypes.func,
+  url: PropTypes.string.isRequired,
+};
+
+/**
+ * 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") : "";
+}
+
+/**
+ * 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 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 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;
+}
+
+module.exports = connect(
+  state => {
+    const selectedRequest = getSelectedRequest(state);
+    if (!selectedRequest) {
+      return {
+        method: "",
+        url: "",
+      };
+    }
+
+    const {
+      method,
+      url,
+      requestHeaders,
+      requestPostData,
+    } = selectedRequest;
+    let headers = requestHeaders ? writeHeaderText(requestHeaders.headers) : "";
+    let paramsArray = parseQueryString(getUrlQuery(url));
+    let postData = requestPostData && requestPostData.postData.text ?
+      requestPostData.postData.text : "";
+    return {
+      headers,
+      method,
+      paramsArray,
+      postData,
+      url,
+    };
+  },
+  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 headers = parseHeadersText(evt.target.value);
+      dispatch(Actions.updateSelectedRequest({
+        requestHeaders: { headers }
+      }));
+    },
+    updateMethod: (evt) => {
+      let method = evt.target.value.trim();
+      dispatch(Actions.updateSelectedRequest({ method }));
+    },
+    updateQuery: (evt) => {
+      // Update the url based on the query string field
+      let params = parseQueryText(evt.target.value);
+      dispatch(Actions.updateSelectedRequestQuery({ params }));
+    },
+    updateUrl: (evt) => {
+      let link = evt.target.value;
+      dispatch(Actions.updateSelectedRequest({ url: link }));
+    },
+  })
+)(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',
     'editor.js',
     'headers-panel.js',
     'params-panel.js',
     'preview-panel.js',
     'properties-view.js',
     'response-panel.js',
     'security-panel.js',
     'timings-panel.js',
--- a/devtools/client/themes/netmonitor.css
+++ b/devtools/client/themes/netmonitor.css
@@ -29,28 +29,35 @@
 #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;
 }
 
+#network-statistics-charts {
+  display: flex;
+  flex-grow: 1;
+  align-items: center;
+  justify-content: center;
+}
+
 .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);
@@ -776,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;
@@ -830,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;
 }
 
@@ -1194,45 +1220,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 {
@@ -1274,30 +1300,30 @@
   height: 100%;
 }
 
 /*
  * FIXME: normal html block element cannot fill outer XUL element
  * This workaround should be removed after netmonitor is migrated to react
  */
 #react-cookies-tabpanel-hook,
+#react-custom-request-panel-hook,
 #react-headers-tabpanel-hook,
 #react-params-tabpanel-hook,
 #react-preview-tabpanel-hook,
 #react-response-tabpanel-hook,
 #react-security-tabpanel-hook,
 #react-timings-tabpanel-hook {
   display: flex;
   -moz-box-flex: 1;
-  -moz-box-orient: vertical;
+  flex-direction: column;
 }
 
-#network-statistics-charts,
-#primed-cache-chart,
-#empty-cache-chart {
-  display: -moz-box;
-  -moz-box-flex: 1;
+/* For vbox */
+#react-cookies-tabpanel-hook,
+#react-headers-tabpanel-hook,
+#react-params-tabpanel-hook,
+#react-preview-tabpanel-hook,
+#react-response-tabpanel-hook,
+#react-security-tabpanel-hook,
+#react-timings-tabpanel-hook {
+  -moz-box-orient: vertical;
 }
-
-#primed-cache-chart,
-#empty-cache-chart {
-  -moz-box-pack: center;
-}