Bug 1403530 - Export as HAR toolbar button; r=? draft
authorJan Odvarko <odvarko@gmail.com>
Fri, 20 Apr 2018 15:27:33 +0200
changeset 785623 db6b1c1493a638451705cea5d9689f1fc730c404
parent 785622 7b2223f73696d7ab90e66a52424af794001c16e8
push id107278
push userjodvarko@mozilla.com
push dateFri, 20 Apr 2018 13:30:13 +0000
bugs1403530
milestone61.0a1
Bug 1403530 - Export as HAR toolbar button; r=? MozReview-Commit-ID: LdfGtmXL7V7
devtools/client/locales/en-US/netmonitor.properties
devtools/client/netmonitor/src/assets/styles/Toolbar.css
devtools/client/netmonitor/src/components/Toolbar.js
devtools/client/netmonitor/src/har/har-menu-utils.js
devtools/client/netmonitor/src/har/moz.build
devtools/client/netmonitor/src/utils/menu.js
devtools/client/netmonitor/src/widgets/RequestListContextMenu.js
devtools/client/netmonitor/src/widgets/RequestListHeaderContextMenu.js
--- a/devtools/client/locales/en-US/netmonitor.properties
+++ b/devtools/client/locales/en-US/netmonitor.properties
@@ -938,17 +938,16 @@ 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.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
 
@@ -1058,8 +1057,12 @@ netmonitor.status.tooltip.worker = %1$S 
 # of the column status code, when the request is cached and is from a service worker
 # %1$S is the status code, %2$S is the status text.
 netmonitor.status.tooltip.cachedworker = %1$S %2$S (cached, service worker)
 
 # LOCALIZATION NOTE (netmonitor.label.dropHarFiles): This is a label
 # rendered within the Network panel when *.har file(s) are dragged
 # over the content.
 netmonitor.label.dropHarFiles = Drop HAR files here
+
+# LOCALIZATION NOTE (netmonitor.label.har): This is a label used
+# for toolbar drop-down button with HAR actions
+netmonitor.label.har=HAR
--- a/devtools/client/netmonitor/src/assets/styles/Toolbar.css
+++ b/devtools/client/netmonitor/src/assets/styles/Toolbar.css
@@ -63,8 +63,13 @@
 }
 
 .devtools-checkbox-label {
   margin-inline-start: 10px;
   margin-inline-end: 3px;
   white-space: nowrap;
   margin-top: 1px;
 }
+
+/* HAR button in the toolbar has a background only when hovered. */
+#devtools-har-button:not(:hover) {
+  background: none;
+}
--- a/devtools/client/netmonitor/src/components/Toolbar.js
+++ b/devtools/client/netmonitor/src/components/Toolbar.js
@@ -8,16 +8,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 { connect } = require("devtools/client/shared/redux/visibility-handler-connect");
 
 const Actions = require("../actions/index");
 const { FILTER_SEARCH_DELAY, FILTER_TAGS } = require("../constants");
 const {
+  getDisplayedRequests,
   getRecordingState,
   getTypeFilteredRequests,
 } = require("../selectors/index");
 const { autocompleteProvider } = require("../utils/filter-autocomplete-provider");
 const { L10N } = require("../utils/l10n");
 const { fetchNetworkUpdatePacket } = require("../utils/request-utils");
 
 // Components
@@ -25,29 +26,34 @@ const SearchBox = createFactory(require(
 
 const { button, div, input, label, span } = dom;
 
 // Localization
 const SEARCH_KEY_SHORTCUT = L10N.getStr("netmonitor.toolbar.filterFreetext.key");
 const SEARCH_PLACE_HOLDER = L10N.getStr("netmonitor.toolbar.filterFreetext.label");
 const TOOLBAR_CLEAR = L10N.getStr("netmonitor.toolbar.clear");
 const TOOLBAR_TOGGLE_RECORDING = L10N.getStr("netmonitor.toolbar.toggleRecording");
+const TOOLBAR_HAR_BUTTON = L10N.getStr("netmonitor.label.har");
 
 // Preferences
 const DEVTOOLS_DISABLE_CACHE_PREF = "devtools.cache.disabled";
 const DEVTOOLS_ENABLE_PERSISTENT_LOG_PREF = "devtools.netmonitor.persistlog";
 const TOOLBAR_FILTER_LABELS = FILTER_TAGS.concat("all").reduce((o, tag) =>
   Object.assign(o, { [tag]: L10N.getStr(`netmonitor.toolbar.filter.${tag}`) }), {});
 const ENABLE_PERSISTENT_LOGS_TOOLTIP =
   L10N.getStr("netmonitor.toolbar.enablePersistentLogs.tooltip");
 const ENABLE_PERSISTENT_LOGS_LABEL =
   L10N.getStr("netmonitor.toolbar.enablePersistentLogs.label");
 const DISABLE_CACHE_TOOLTIP = L10N.getStr("netmonitor.toolbar.disableCache.tooltip");
 const DISABLE_CACHE_LABEL = L10N.getStr("netmonitor.toolbar.disableCache.label");
 
+// Menu
+loader.lazyRequireGetter(this, "showMenu", "devtools/client/netmonitor/src/utils/menu", true);
+loader.lazyRequireGetter(this, "HarMenuUtils", "devtools/client/netmonitor/src/har/har-menu-utils", true);
+
 /**
  * Network monitor toolbar component.
  *
  * Toolbar contains a set of useful tools to control network requests
  * as well as set of filters for filtering the content.
  */
 class Toolbar extends Component {
   static get propTypes() {
@@ -246,16 +252,46 @@ class Toolbar extends Component {
           onChange: toggleBrowserCache,
         }),
         DISABLE_CACHE_LABEL,
       )
     );
   }
 
   /**
+   * Render drop down button with HAR related actions.
+   */
+  renderHarButton() {
+    return button({
+      id: "devtools-har-button",
+      className: "devtools-button",
+      onClick: evt => {
+        this.showHarMenu(evt.target);
+      },
+    }, TOOLBAR_HAR_BUTTON);
+  }
+
+  showHarMenu(menuButton) {
+    const { displayedRequests } = this.props;
+    let saveAll = HarMenuUtils.getSaveAllAsHarMenuItem(displayedRequests);
+    let copyAll = HarMenuUtils.getCopyAllAsHarMenuItem(displayedRequests);
+
+    // Make the items disabled (not hidden) if there are no requests.
+    saveAll.visible = copyAll.visible = true;
+    saveAll.disabled = copyAll.disabled = !displayedRequests.length;
+
+    const items = [
+      saveAll,
+      copyAll,
+    ];
+
+    showMenu(items, { button: menuButton });
+  }
+
+  /**
    * Render filter Searchbox.
    */
   renderFilterBox(setRequestFilterText) {
     return (
       SearchBox({
         delay: FILTER_SEARCH_DELAY,
         keyShortcut: SEARCH_KEY_SHORTCUT,
         placeholder: SEARCH_PLACE_HOLDER,
@@ -293,41 +329,46 @@ class Toolbar extends Component {
           this.renderFilterBox(setRequestFilterText),
           this.renderSeparator(),
           this.renderToggleRecordingButton(recording, toggleRecording),
           this.renderSeparator(),
           this.renderFilterButtons(requestFilterTypes),
           this.renderSeparator(),
           this.renderPersistlogCheckbox(persistentLogsEnabled, togglePersistentLogs),
           this.renderCacheCheckbox(browserCacheDisabled, toggleBrowserCache),
+          this.renderSeparator(),
+          this.renderHarButton(),
         )
       )
     ) : (
       span({ className: "devtools-toolbar devtools-toolbar-container" },
         span({ className: "devtools-toolbar-group devtools-toolbar-two-rows-1" },
           this.renderClearButton(clearRequests),
           this.renderSeparator(),
           this.renderFilterBox(setRequestFilterText),
           this.renderSeparator(),
           this.renderToggleRecordingButton(recording, toggleRecording),
           this.renderSeparator(),
           this.renderPersistlogCheckbox(persistentLogsEnabled, togglePersistentLogs),
           this.renderCacheCheckbox(browserCacheDisabled, toggleBrowserCache),
+          this.renderSeparator(),
+          this.renderHarButton(),
         ),
         span({ className: "devtools-toolbar-group devtools-toolbar-two-rows-2" },
           this.renderFilterButtons(requestFilterTypes)
         )
       )
     );
   }
 }
 
 module.exports = connect(
   (state) => ({
     browserCacheDisabled: state.ui.browserCacheDisabled,
+    displayedRequests: getDisplayedRequests(state),
     filteredRequests: getTypeFilteredRequests(state),
     persistentLogsEnabled: state.ui.persistentLogsEnabled,
     recording: getRecordingState(state),
     requestFilterTypes: state.filters.requestFilterTypes,
   }),
   (dispatch) => ({
     clearRequests: () => dispatch(Actions.clearRequests()),
     disableBrowserCache: (disabled) => dispatch(Actions.disableBrowserCache(disabled)),
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/src/har/har-menu-utils.js
@@ -0,0 +1,64 @@
+/* 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 { L10N } = require("../utils/l10n");
+
+loader.lazyRequireGetter(this, "HarExporter", "devtools/client/netmonitor/src/har/har-exporter", true);
+
+/**
+ * Helper object for building HAR related context menu items.
+ */
+var HarMenuUtils = {
+  getSaveAllAsHarMenuItem(requests, connector) {
+    return {
+      id: "request-list-context-save-all-as-har",
+      label: L10N.getStr("netmonitor.context.saveAllAsHar"),
+      accesskey: L10N.getStr("netmonitor.context.saveAllAsHar.accesskey"),
+      visible: requests.length > 0,
+      click: () => this.saveAllAsHar(requests, connector),
+    };
+  },
+
+  getCopyAllAsHarMenuItem(requests, connector) {
+    return {
+      id: "request-list-context-copy-all-as-har",
+      label: L10N.getStr("netmonitor.context.copyAllAsHar"),
+      accesskey: L10N.getStr("netmonitor.context.copyAllAsHar.accesskey"),
+      visible: requests.length > 0,
+      click: () => this.copyAllAsHar(requests, connector),
+    };
+  },
+
+  /**
+   * Copy HAR from the network panel content to the clipboard.
+   */
+  copyAllAsHar(requests, connector) {
+    return HarExporter.copy(this.getDefaultHarOptions(requests, connector));
+  },
+
+  /**
+   * Save HAR from the network panel content to a file.
+   */
+  saveAllAsHar(requests, connector) {
+    // This will not work in launchpad
+    // document.execCommand(‘cut’/‘copy’) was denied because it was not called from
+    // inside a short running user-generated event handler.
+    // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Interact_with_the_clipboard
+    return HarExporter.save(this.getDefaultHarOptions(requests, connector));
+  },
+
+  getDefaultHarOptions(requests, connector) {
+    return {
+      connector: connector,
+      items: requests,
+    };
+  },
+};
+
+// Exports from this module
+exports.HarMenuUtils = HarMenuUtils;
--- a/devtools/client/netmonitor/src/har/moz.build
+++ b/devtools/client/netmonitor/src/har/moz.build
@@ -4,13 +4,14 @@
 
 DevToolsModules(
     'har-automation.js',
     'har-builder-utils.js',
     'har-builder.js',
     'har-collector.js',
     'har-exporter.js',
     'har-importer.js',
+    'har-menu-utils.js',
     'har-utils.js',
     'toolbox-overlay.js',
 )
 
 BROWSER_CHROME_MANIFESTS += ['test/browser.ini']
--- a/devtools/client/netmonitor/src/utils/menu.js
+++ b/devtools/client/netmonitor/src/utils/menu.js
@@ -2,35 +2,58 @@
  * 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 Menu = require("devtools/client/framework/menu");
 const MenuItem = require("devtools/client/framework/menu-item");
 
-function showMenu(evt, items) {
+/**
+ * Helper function for opening context menu.
+ *
+ * @param {Array} items List of menu items.
+ * @param {Object} options:
+ * @property {Number} screenX coordinate of the menu on the screen
+ * @property {Number} screenY coordinate of the menu on the screen
+ * @property {Object} button parent used to open the menu
+ */
+function showMenu(items, options) {
   if (items.length === 0) {
     return;
   }
 
+  // Build the menu object from provided menu items.
   let menu = new Menu();
   items.forEach((item) => {
     let menuItem = new MenuItem(item);
     let subItems = item.submenu;
 
     if (subItems) {
       let subMenu = new Menu();
       subItems.forEach((subItem) => {
         subMenu.append(new MenuItem(subItem));
       });
       menuItem.submenu = subMenu;
     }
 
     menu.append(menuItem);
   });
 
-  menu.popup(evt.screenX, evt.screenY, { doc: window.parent.document });
+  let screenX = options.screenX;
+  let screenY = options.screenY
+
+  // Calculate position on the screen according to
+  // the parent button if available.
+  if (options.button) {
+    const button = options.button;
+    const rect = button.getBoundingClientRect();
+    const defaultView = button.ownerDocument.defaultView;
+    screenX = rect.left + defaultView.mozInnerScreenX;
+    screenY = rect.bottom + defaultView.mozInnerScreenY;
+  }
+
+  menu.popup(screenX, screenY, { doc: window.parent.document });
 }
 
 module.exports = {
   showMenu,
 };
--- a/devtools/client/netmonitor/src/widgets/RequestListContextMenu.js
+++ b/devtools/client/netmonitor/src/widgets/RequestListContextMenu.js
@@ -14,17 +14,17 @@ const {
   parseQueryString,
 } = require("../utils/request-utils");
 
 loader.lazyRequireGetter(this, "Curl", "devtools/client/shared/curl", true);
 loader.lazyRequireGetter(this, "saveAs", "devtools/client/shared/file-saver", true);
 loader.lazyRequireGetter(this, "copyString", "devtools/shared/platform/clipboard", true);
 loader.lazyRequireGetter(this, "showMenu", "devtools/client/netmonitor/src/utils/menu", true);
 loader.lazyRequireGetter(this, "openRequestInTab", "devtools/client/netmonitor/src/utils/firefox/open-request-in-tab", true);
-loader.lazyRequireGetter(this, "HarExporter", "devtools/client/netmonitor/src/har/har-exporter", true);
+loader.lazyRequireGetter(this, "HarMenuUtils", "devtools/client/netmonitor/src/har/har-menu-utils", true);
 
 class RequestListContextMenu {
   constructor(props) {
     this.props = props;
   }
 
   open(event, selectedRequest, requests) {
     let {
@@ -40,16 +40,17 @@ class RequestListContextMenu {
       requestPostDataAvailable,
       responseHeaders,
       responseHeadersAvailable,
       responseContent,
       responseContentAvailable,
       url,
     } = selectedRequest;
     let {
+      connector,
       cloneSelectedRequest,
       openStatistics,
     } = this.props;
     let menu = [];
     let copySubmenu = [];
 
     copySubmenu.push({
       id: "request-list-context-copy-url",
@@ -134,38 +135,26 @@ class RequestListContextMenu {
       click: () => this.copyImageAsDataUri(id, mimeType, responseContent),
     });
 
     copySubmenu.push({
       type: "separator",
       visible: copySubmenu.slice(5, 9).some((subMenu) => subMenu.visible),
     });
 
-    copySubmenu.push({
-      id: "request-list-context-copy-all-as-har",
-      label: L10N.getStr("netmonitor.context.copyAllAsHar"),
-      accesskey: L10N.getStr("netmonitor.context.copyAllAsHar.accesskey"),
-      visible: requests.size > 0,
-      click: () => this.copyAllAsHar(requests),
-    });
+    copySubmenu.push(HarMenuUtils.getCopyAllAsHarMenuItem(requests, connector));
 
     menu.push({
       label: L10N.getStr("netmonitor.context.copy"),
       accesskey: L10N.getStr("netmonitor.context.copy.accesskey"),
       visible: !!selectedRequest,
       submenu: copySubmenu,
     });
 
-    menu.push({
-      id: "request-list-context-save-all-as-har",
-      label: L10N.getStr("netmonitor.context.saveAllAsHar"),
-      accesskey: L10N.getStr("netmonitor.context.saveAllAsHar.accesskey"),
-      visible: requests.size > 0,
-      click: () => this.saveAllAsHar(requests),
-    });
+    menu.push(HarMenuUtils.getSaveAllAsHarMenuItem(requests, connector));
 
     menu.push({
       id: "request-list-context-save-image-as",
       label: L10N.getStr("netmonitor.context.saveImageAs"),
       accesskey: L10N.getStr("netmonitor.context.saveImageAs.accesskey"),
       visible: !!(selectedRequest && (responseContentAvailable || responseContent) &&
         mimeType && mimeType.includes("image/")),
       click: () => this.saveImageAs(id, url, responseContent),
@@ -218,17 +207,20 @@ class RequestListContextMenu {
     menu.push({
       id: "request-list-context-perf",
       label: L10N.getStr("netmonitor.context.perfTools"),
       accesskey: L10N.getStr("netmonitor.context.perfTools.accesskey"),
       visible: requests.size > 0,
       click: () => openStatistics(true),
     });
 
-    showMenu(event, menu);
+    showMenu(menu, {
+      screenX: event.screenX,
+      screenY: event.screenY,
+    });
   }
 
   /**
    * Opens selected item in a new tab.
    */
   async openRequestInTab(id, url, requestPostData) {
     requestPostData = requestPostData ||
       await this.props.connector.requestData(id, "requestPostData");
@@ -389,36 +381,11 @@ class RequestListContextMenu {
    * Copy response data as a string.
    */
   async copyResponse(id, responseContent) {
     responseContent = responseContent ||
       await this.props.connector.requestData(id, "responseContent");
 
     copyString(responseContent.content.text);
   }
-
-  /**
-   * Copy HAR from the network panel content to the clipboard.
-   */
-  copyAllAsHar(requests) {
-    return HarExporter.copy(this.getDefaultHarOptions(requests));
-  }
-
-  /**
-   * Save HAR from the network panel content to a file.
-   */
-  saveAllAsHar(requests) {
-    // This will not work in launchpad
-    // document.execCommand(‘cut’/‘copy’) was denied because it was not called from
-    // inside a short running user-generated event handler.
-    // https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Interact_with_the_clipboard
-    return HarExporter.save(this.getDefaultHarOptions(requests));
-  }
-
-  getDefaultHarOptions(requests) {
-    return {
-      connector: this.props.connector,
-      items: requests,
-    };
-  }
 }
 
 module.exports = RequestListContextMenu;
--- a/devtools/client/netmonitor/src/widgets/RequestListHeaderContextMenu.js
+++ b/devtools/client/netmonitor/src/widgets/RequestListHeaderContextMenu.js
@@ -65,13 +65,16 @@ class RequestListHeaderContextMenu {
 
     menu.push({ type: "separator" });
     menu.push({
       id: "request-list-header-reset-columns",
       label: L10N.getStr("netmonitor.toolbar.resetColumns"),
       click: () => this.props.resetColumns(),
     });
 
-    return showMenu(event, menu);
+    showMenu(menu, {
+      screenX: event.screenX,
+      screenY: event.screenY,
+    });
   }
 }
 
 module.exports = RequestListHeaderContextMenu;