Bug 1442249 - Add Copy context menu to PropertiesView. r=Honza
authorLaphets <wenqing4@illinois.edu>
Thu, 04 Apr 2019 16:57:22 +0000
changeset 468038 4b68c7d0429f3c206d27e639d291ec9b46ed8792
parent 468037 0cf838d61cdd13c9d2b6eefa0e75ebb2202a0934
child 468039 2585178aea393edb993b251d23ee3eb48350c43f
push id112677
push userccoroiu@mozilla.com
push dateFri, 05 Apr 2019 03:24:00 +0000
treeherdermozilla-inbound@83c38c0e430b [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersHonza
bugs1442249
milestone68.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1442249 - Add Copy context menu to PropertiesView. r=Honza Differential Revision: https://phabricator.services.mozilla.com/D23770
devtools/client/locales/en-US/netmonitor.properties
devtools/client/netmonitor/src/components/PropertiesView.js
devtools/client/netmonitor/src/widgets/PropertiesViewContextMenu.js
devtools/client/netmonitor/src/widgets/moz.build
devtools/client/netmonitor/test/browser.ini
devtools/client/netmonitor/test/browser_net_propertiesview-copy.js
devtools/client/shared/components/tree/TreeView.js
--- a/devtools/client/locales/en-US/netmonitor.properties
+++ b/devtools/client/locales/en-US/netmonitor.properties
@@ -912,17 +912,17 @@ netmonitor.security.certificate=Certific
 # in the Network monitor panel as a tooltip for tracking resource icon.
 netmonitor.trackingResource.tooltip=This URL matches a known tracker and it would be blocked with Content Blocking enabled.
 
 # LOCALIZATION NOTE (netmonitor.context.copy): This is the label displayed
 # for the copy sub-menu in the context menu for a request
 netmonitor.context.copy=Copy
 
 # LOCALIZATION NOTE (netmonitor.context.copy.accesskey): This is the access key
-# for the copy sub-menu displayed in the context menu for a request
+# for the copy menu/sub-menu displayed in the context menu for a request
 netmonitor.context.copy.accesskey=C
 
 # LOCALIZATION NOTE (netmonitor.context.copyUrl): This is the label displayed
 # on the context menu that copies the selected request's url
 netmonitor.context.copyUrl=Copy URL
 
 # LOCALIZATION NOTE (netmonitor.context.copyUrl.accesskey): This is the access key
 # for the Copy URL menu item displayed in the context menu for a request
@@ -989,16 +989,24 @@ netmonitor.context.copyImageAsDataUri.ac
 # LOCALIZATION NOTE (netmonitor.context.saveImageAs): This is the label displayed
 # on the context menu that save the Image
 netmonitor.context.saveImageAs=Save Image As
 
 # LOCALIZATION NOTE (netmonitor.context.saveImageAs.accesskey): This is the access key
 # for the Copy Image As Data URI menu item displayed in the context menu for a request
 netmonitor.context.saveImageAs.accesskey=v
 
+# LOCALIZATION NOTE (netmonitor.context.copyAll): This is the label displayed
+# on the context menu that copies all data
+netmonitor.context.copyAll=Copy All
+
+# LOCALIZATION NOTE (netmonitor.context.copyAll.accesskey): This is the access key
+# for the Copy All menu item displayed in the context menu for a properties view panel
+netmonitor.context.copyAll.accesskey=A
+
 # LOCALIZATION NOTE (netmonitor.context.copyAllAsHar): This is the label displayed
 # on the context menu that copies all as HAR format
 netmonitor.context.copyAllAsHar=Copy All As HAR
 
 # LOCALIZATION NOTE (netmonitor.context.copyAllAsHar.accesskey): This is the access key
 # for the Copy All As HAR menu item displayed in the context menu for a network panel
 netmonitor.context.copyAllAsHar.accesskey=o
 
--- a/devtools/client/netmonitor/src/components/PropertiesView.js
+++ b/devtools/client/netmonitor/src/components/PropertiesView.js
@@ -10,16 +10,17 @@ const Services = require("Services");
 const { Component, createFactory } = require("devtools/client/shared/vendor/react");
 const dom = require("devtools/client/shared/vendor/react-dom-factories");
 const PropTypes = require("devtools/client/shared/vendor/react-prop-types");
 
 const { FILTER_SEARCH_DELAY } = require("../constants");
 
 // Components
 const TreeViewClass = require("devtools/client/shared/components/tree/TreeView");
+const PropertiesViewContextMenu = require("../widgets/PropertiesViewContextMenu");
 const TreeView = createFactory(TreeViewClass);
 
 loader.lazyGetter(this, "SearchBox", function() {
   return createFactory(require("devtools/client/shared/components/SearchBox"));
 });
 loader.lazyGetter(this, "TreeRow", function() {
   return createFactory(require("devtools/client/shared/components/tree/TreeRow"));
 });
@@ -150,16 +151,33 @@ class PropertiesView extends Component {
     if ((path.includes(EDITOR_CONFIG_ID) || path.includes(HTML_PREVIEW_ID))
       && level >= 1) {
       return null;
     }
 
     return TreeRow(props);
   }
 
+  onContextMenuRow(member, evt) {
+    evt.preventDefault();
+
+    const { object } = member;
+
+    // Select the right clicked row
+    this.selectRow(evt.currentTarget);
+
+    // if data exists and can be copied, then show the contextmenu
+    if (typeof (object) === "object") {
+      if (!this.contextMenu) {
+        this.contextMenu = new PropertiesViewContextMenu({});
+      }
+      this.contextMenu.open(evt, { member, object: this.props.object });
+    }
+  }
+
   renderValueWithRep(props) {
     const { member } = props;
 
     // Hide strings with following conditions
     // 1. this row is a togglable section and content is object ('cause it shouldn't hide
     //    when string or number)
     // 2. the `value` object has a `value` property, only happened in Cookies panel
     // Put 2 here to not dup this method
@@ -235,16 +253,17 @@ class PropertiesView extends Component {
             expandedNodes: TreeViewClass.getExpandedNodes(
               object,
               {maxLevel: AUTO_EXPAND_MAX_LEVEL, maxNodes: AUTO_EXPAND_MAX_NODES}
             ),
             onFilter: (props) => this.onFilter(props, sectionNames),
             renderRow: renderRow || this.renderRowWithExtras,
             renderValue: renderValue || this.renderValueWithRep,
             openLink,
+            onContextMenuRow: this.onContextMenuRow,
           }),
         ),
       )
     );
   }
 }
 
 module.exports = PropertiesView;
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/src/widgets/PropertiesViewContextMenu.js
@@ -0,0 +1,65 @@
+/* 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 { L10N } = require("../utils/l10n");
+
+loader.lazyRequireGetter(this, "copyString", "devtools/shared/platform/clipboard", true);
+loader.lazyRequireGetter(this, "showMenu", "devtools/client/shared/components/menu/utils", true);
+
+class PropertiesViewContextMenu {
+  constructor(props) {
+    this.props = props;
+  }
+
+  /**
+   * Handle the context menu opening.
+   * @param {*} event open event
+   * @param {*} member member of the right-clicked row
+   * @param {*} object the whole data object
+   */
+  open(event = {}, { member, object }) {
+    const menu = [];
+
+    menu.push({
+      id: "properties-view-context-menu-copy",
+      label: L10N.getStr("netmonitor.context.copy"),
+      accesskey: L10N.getStr("netmonitor.context.copy.accesskey"),
+      click: () => this.copySelected(member),
+    });
+
+    menu.push({
+      id: "properties-view-context-menu-copyall",
+      label: L10N.getStr("netmonitor.context.copyAll"),
+      accesskey: L10N.getStr("netmonitor.context.copyAll.accesskey"),
+      click: () => this.copyAll(object),
+    });
+
+    showMenu(menu, {
+      screenX: event.screenX,
+      screenY: event.screenY,
+    });
+  }
+
+  copyAll(data) {
+    try {
+      copyString(JSON.stringify(data));
+    } catch (error) {}
+  }
+
+  copySelected({ object, hasChildren }) {
+    if (hasChildren) {
+      // If has children, copy the data as JSON
+      try {
+        copyString(JSON.stringify({ [object.name]: object.value }));
+      } catch (error) {}
+    } else {
+      // Copy the data as key-value format
+      copyString(`${object.name}: ${object.value}`);
+    }
+  }
+}
+
+module.exports = PropertiesViewContextMenu;
--- a/devtools/client/netmonitor/src/widgets/moz.build
+++ b/devtools/client/netmonitor/src/widgets/moz.build
@@ -1,9 +1,10 @@
 # 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(
+    'PropertiesViewContextMenu.js',
     'RequestListContextMenu.js',
     'RequestListHeaderContextMenu.js',
     'WaterfallBackground.js',
 )
--- a/devtools/client/netmonitor/test/browser.ini
+++ b/devtools/client/netmonitor/test/browser.ini
@@ -98,16 +98,18 @@ skip-if = (verify && !debug && (os == 'm
 [browser_net_brotli.js]
 [browser_net_curl-utils.js]
 [browser_net_copy_image_as_data_uri.js]
 subsuite = clipboard
 [browser_net_copy_svg_image_as_data_uri.js]
 subsuite = clipboard
 [browser_net_copy_url.js]
 subsuite = clipboard
+[browser_net_propertiesview-copy.js]
+subsuite = clipboard
 [browser_net_copy_params.js]
 subsuite = clipboard
 skip-if = (verify && !debug && (os == 'mac')) # bug 1328915, disable linux32 debug devtools for timeouts
 [browser_net_copy_response.js]
 subsuite = clipboard
 [browser_net_copy_headers.js]
 subsuite = clipboard
 [browser_net_cookies_sorted.js]
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/test/browser_net_propertiesview-copy.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test if response JSON in PropertiesView can be copied
+ */
+
+add_task(async function() {
+  const { tab, monitor } = await initNetMonitor(JSON_BASIC_URL + "?name=nogrip");
+  info("Starting test... ");
+
+  const { document, store, windowRequire } = monitor.panelWin;
+  const Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
+
+  store.dispatch(Actions.batchEnable(false));
+
+  await performRequests(monitor, tab, 1);
+
+  const onResponsePanelReady = waitForDOM(document, "#response-panel .treeTable");
+  store.dispatch(Actions.toggleNetworkDetails());
+  EventUtils.sendMouseEvent({ type: "click" },
+    document.querySelector("#response-tab"));
+  await onResponsePanelReady;
+
+  const responsePanel = document.querySelector("#response-panel");
+
+  const objectRow = responsePanel.querySelectorAll(".objectRow")[1];
+  const stringRow = responsePanel.querySelectorAll(".stringRow")[0];
+
+  /* Test for copy an object */
+  EventUtils.sendMouseEvent({ type: "contextmenu" },
+    objectRow);
+  await waitForClipboardPromise(function setup() {
+    monitor.panelWin.parent.document
+      .querySelector("#properties-view-context-menu-copy").click();
+  }, `{"obj":{"type":"string"}}`);
+
+  /* Test for copy all */
+  EventUtils.sendMouseEvent({ type: "contextmenu" },
+    objectRow);
+  await waitForClipboardPromise(function setup() {
+    monitor.panelWin.parent.document
+      .querySelector("#properties-view-context-menu-copyall").click();
+  }, `{"JSON":{"obj":{"type":"string"}},` +
+    `"Response payload":{"EDITOR_CONFIG":{"text":` +
+    `"{\\"obj\\": {\\"type\\": \\"string\\" }}","mode":"application/json"}}}`);
+
+  /* Test for copy a single row */
+  EventUtils.sendMouseEvent({ type: "contextmenu" },
+    stringRow);
+  await waitForClipboardPromise(function setup() {
+    monitor.panelWin.parent.document
+      .querySelector("#properties-view-context-menu-copy").click();
+  }, "type: string");
+
+  await teardown(monitor);
+});
+
+/**
+ * Test if response/request Cookies in PropertiesView can be copied
+ */
+
+add_task(async function() {
+  const { tab, monitor } = await initNetMonitor(SIMPLE_UNSORTED_COOKIES_SJS);
+  info("Starting test... ");
+
+  const { document, store, windowRequire } = monitor.panelWin;
+  const Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
+
+  store.dispatch(Actions.batchEnable(false));
+
+  tab.linkedBrowser.reload();
+
+  let wait = waitForNetworkEvents(monitor, 1);
+  await wait;
+
+  wait = waitForDOM(document, ".headers-overview");
+  EventUtils.sendMouseEvent({ type: "mousedown" },
+    document.querySelectorAll(".request-list-item")[0]);
+  await wait;
+
+  EventUtils.sendMouseEvent({ type: "mousedown" },
+    document.querySelectorAll(".request-list-item")[0]);
+  EventUtils.sendMouseEvent({ type: "click" },
+    document.querySelector("#cookies-tab"));
+
+  const cookiesPanel = document.querySelector("#cookies-panel");
+
+  const objectRows = cookiesPanel.querySelectorAll(".objectRow:not(.tree-section)");
+  const stringRows = cookiesPanel.querySelectorAll(".stringRow");
+
+  const expectedResponseCookies = [
+    { bob: { httpOnly: true, value: "true" } },
+    { foo: { httpOnly: true, value: "bar"  } },
+    { tom: { httpOnly: true, value: "cool" } }];
+  for (let i = 0; i < objectRows.length; i++) {
+    const cur = objectRows[i];
+    EventUtils.sendMouseEvent({ type: "contextmenu" },
+      cur);
+    await waitForClipboardPromise(function setup() {
+      monitor.panelWin.parent.document
+        .querySelector("#properties-view-context-menu-copy").click();
+    }, JSON.stringify(expectedResponseCookies[i]));
+  }
+
+  const expectedRequestCookies = ["bob: true", "foo: bar", "tom: cool"];
+  for (let i = 0; i < expectedRequestCookies.length; i++) {
+    const cur = stringRows[objectRows.length + i];
+    EventUtils.sendMouseEvent({ type: "contextmenu" },
+      cur);
+    await waitForClipboardPromise(function setup() {
+      monitor.panelWin.parent.document
+        .querySelector("#properties-view-context-menu-copy").click();
+    }, expectedRequestCookies[i]);
+  }
+
+  await teardown(monitor);
+});
--- a/devtools/client/shared/components/tree/TreeView.js
+++ b/devtools/client/shared/components/tree/TreeView.js
@@ -122,16 +122,18 @@ define(function(require, exports, module
         // The currently active (keyboard) item, if any such item exists.
         active: PropTypes.string,
         // Custom filtering callback
         onFilter: PropTypes.func,
         // Custom sorting callback
         onSort: PropTypes.func,
         // Custom row click callback
         onClickRow: PropTypes.func,
+        // Row context menu event handler
+        onContextMenuRow: PropTypes.func,
         // Tree context menu event handler
         onContextMenuTree: PropTypes.func,
         // A header is displayed if set to true
         header: PropTypes.bool,
         // Long string is expandable by a toggle button
         expandableStrings: PropTypes.bool,
         // Array of columns
         columns: PropTypes.arrayOf(PropTypes.shape({
@@ -363,16 +365,23 @@ define(function(require, exports, module
       event.stopPropagation();
       const cell = event.target.closest("td");
       if (cell && cell.classList.contains("treeLabelCell")) {
         this.toggle(nodePath);
       }
       this.selectRow(event.currentTarget);
     }
 
+    onContextMenu(member, event) {
+      const onContextMenuRow = this.props.onContextMenuRow;
+      if (onContextMenuRow) {
+        onContextMenuRow.call(this, member, event);
+      }
+    }
+
     getSelectedRow() {
       if (!this.state.selected || this.rows.length === 0) {
         return null;
       }
       return this.rows.find(row => this.isSelected(row.props.member.path));
     }
 
     getSelectedRowIndex() {
@@ -534,16 +543,17 @@ define(function(require, exports, module
 
         const props = Object.assign({}, this.props, {
           key: `${member.path}-${member.active ? "active" : "inactive"}`,
           member: member,
           columns: this.state.columns,
           id: member.path,
           ref: row => row && this.rows.push(row),
           onClick: this.onClickRow.bind(this, member.path),
+          onContextMenu: this.onContextMenu.bind(this, member),
         });
 
         // Render single row.
         rows.push(renderRow(props));
 
         // If a child node is expanded render its rows too.
         if (member.hasChildren && member.open) {
           const childRows = this.renderRows(member.object, level + 1,