Bug 1403530 - HAR export toolbar button; r=davidwalsh
authorJan Odvarko <odvarko@gmail.com>
Thu, 26 Apr 2018 19:23:56 +0200
changeset 460881 d8bb9fa651c6484a07d441109553d80403493aef
parent 460880 356d8af4a2b9248e445d68019ef91316f3363a3d
child 460882 1936e3c9b600639a9e5ce6e5df93f64607d2ddaf
push id165
push userfmarier@mozilla.com
push dateMon, 30 Apr 2018 23:50:51 +0000
reviewersdavidwalsh
bugs1403530
milestone61.0a1
Bug 1403530 - HAR export toolbar button; r=davidwalsh MozReview-Commit-ID: 6rZlTAUSXnL
devtools/client/jar.mn
devtools/client/locales/en-US/netmonitor.properties
devtools/client/netmonitor/src/assets/icons/drop-down.svg
devtools/client/netmonitor/src/assets/styles/Toolbar.css
devtools/client/netmonitor/src/assets/styles/variables.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/har/test/browser_net_har_copy_all_as_har.js
devtools/client/netmonitor/src/har/test/browser_net_har_import.js
devtools/client/netmonitor/src/har/test/browser_net_har_post_data.js
devtools/client/netmonitor/src/har/test/browser_net_har_post_data_on_get.js
devtools/client/netmonitor/src/har/test/browser_net_har_throttle_upload.js
devtools/client/netmonitor/src/utils/menu.js
devtools/client/netmonitor/src/widgets/RequestListContextMenu.js
devtools/client/netmonitor/src/widgets/RequestListHeaderContextMenu.js
--- a/devtools/client/jar.mn
+++ b/devtools/client/jar.mn
@@ -295,16 +295,17 @@ devtools.jar:
     content/netmonitor/src/assets/styles/netmonitor.css (netmonitor/src/assets/styles/netmonitor.css)
     content/netmonitor/src/assets/styles/NetworkDetailsPanel.css (netmonitor/src/assets/styles/NetworkDetailsPanel.css)
     content/netmonitor/src/assets/styles/RequestList.css (netmonitor/src/assets/styles/RequestList.css)
     content/netmonitor/src/assets/styles/StatisticsPanel.css (netmonitor/src/assets/styles/StatisticsPanel.css)
     content/netmonitor/src/assets/styles/StatusBar.css (netmonitor/src/assets/styles/StatusBar.css)
     content/netmonitor/src/assets/styles/Toolbar.css (netmonitor/src/assets/styles/Toolbar.css)
     content/netmonitor/src/assets/styles/variables.css (netmonitor/src/assets/styles/variables.css)
     content/netmonitor/src/assets/icons/play.svg (netmonitor/src/assets/icons/play.svg)
+    content/netmonitor/src/assets/icons/drop-down.svg (netmonitor/src/assets/icons/drop-down.svg)
     content/netmonitor/index.html (netmonitor/index.html)
     content/netmonitor/initializer.js (netmonitor/initializer.js)
 
     # Application panel
     content/application/index.html (application/index.html)
     content/application/initializer.js (application/initializer.js)
 
     # Devtools-components
--- 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
+# as a tooltip for toolbar drop-down button with HAR actions
+netmonitor.label.har=HAR Export/Import
new file mode 100644
--- /dev/null
+++ b/devtools/client/netmonitor/src/assets/icons/drop-down.svg
@@ -0,0 +1,8 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+     width="8" height="8" viewBox="0 0 16 16">
+  <path fill="context-fill" d="M7.9 16.3c-.3 0-.6-.1-.8-.4l-4-4.8c-.2-.3-.3-.5-.1-.8.1-.3.5-.3.9-.3h8c.4 0 .7 0 .9.3.2.4.1.6-.1.9l-4 4.8c-.2.3-.5.3-.8.3zM7.8 0c.3 0 .6.1.7.4L12.4 5c.2.3.3.4.1.7-.1.4-.5.3-.8.3H3.9c-.4 0-.8.1-.9-.2-.2-.4-.1-.6.1-.9L7 .3c.2-.3.5-.3.8-.3z"/>
+</svg>
+
--- a/devtools/client/netmonitor/src/assets/styles/Toolbar.css
+++ b/devtools/client/netmonitor/src/assets/styles/Toolbar.css
@@ -56,16 +56,36 @@
 .devtools-button.devtools-pause-icon::before {
   background-image: var(--pause-icon-url);
 }
 
 .devtools-button.devtools-play-icon::before {
   background-image: var(--play-icon-url);
 }
 
+/* HAR button in the toolbar has a background only when hovered. */
+.devtools-button.devtools-har-button:not(:hover) {
+  background: transparent;
+}
+
+/* HAR button has label and icon, so make sure they don't overlap */
+.devtools-button.devtools-har-button::before {
+  content: "HAR";
+  width: 21px;
+  padding-right: 12px;
+  background-image: var(--drop-down-icon-url);
+  background-position: right center;
+  fill: var(--theme-toolbar-photon-icon-color);
+}
+
+/* Make sure the HAR button label is vertically centered on Mac */
+:root[platform="mac"] .devtools-button.devtools-har-button::before {
+  height: 14px;
+}
+
 .devtools-checkbox {
   position: relative;
   vertical-align: middle;
   bottom: 1px;
 }
 
 .devtools-checkbox-label {
   margin-inline-start: 10px;
--- a/devtools/client/netmonitor/src/assets/styles/variables.css
+++ b/devtools/client/netmonitor/src/assets/styles/variables.css
@@ -35,16 +35,17 @@
 }
 
 :root {
   --primary-toolbar-height: 29px;
 
   /* Icons */
   --play-icon-url: url("chrome://devtools/content/netmonitor/src/assets/icons/play.svg");
   --pause-icon-url: url("chrome://devtools/skin/images/pause.svg");
+  --drop-down-icon-url: url("chrome://devtools/content/netmonitor/src/assets/icons/drop-down.svg");
 
   /* HTTP status codes */
   --status-code-color-1xx: var(--theme-highlight-blue);
   --status-code-color-2xx: var(--theme-highlight-green);
   --status-code-color-3xx: transparent;
   --status-code-color-4xx: var(--theme-highlight-pink);
   --status-code-color-5xx: var(--theme-highlight-red);
 }
--- 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,42 +26,49 @@ 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() {
     return {
       connector: PropTypes.object.isRequired,
       toggleRecording: PropTypes.func.isRequired,
       recording: PropTypes.bool.isRequired,
       clearRequests: PropTypes.func.isRequired,
+      // List of currently displayed requests (i.e. filtered & sorted).
+      displayedRequests: PropTypes.array.isRequired,
       requestFilterTypes: PropTypes.object.isRequired,
       setRequestFilterText: PropTypes.func.isRequired,
       enablePersistentLogs: PropTypes.func.isRequired,
       togglePersistentLogs: PropTypes.func.isRequired,
       persistentLogsEnabled: PropTypes.bool.isRequired,
       disableBrowserCache: PropTypes.func.isRequired,
       toggleBrowserCache: PropTypes.func.isRequired,
       browserCacheDisabled: PropTypes.bool.isRequired,
@@ -246,16 +254,57 @@ class Toolbar extends Component {
           onChange: toggleBrowserCache,
         }),
         DISABLE_CACHE_LABEL,
       )
     );
   }
 
   /**
+   * Render drop down button with HAR related actions.
+   */
+  renderHarButton() {
+    return button({
+      id: "devtools-har-button",
+      title: TOOLBAR_HAR_BUTTON,
+      className: "devtools-button devtools-har-button",
+      onClick: evt => {
+        this.showHarMenu(evt.target);
+      },
+    });
+  }
+
+  showHarMenu(menuButton) {
+    const {
+      connector,
+      displayedRequests
+    } = this.props;
+
+    let menuItems = [];
+
+    menuItems.push({
+      id: "request-list-context-save-all-as-har",
+      label: L10N.getStr("netmonitor.context.saveAllAsHar"),
+      accesskey: L10N.getStr("netmonitor.context.saveAllAsHar.accesskey"),
+      disabled: !displayedRequests.length,
+      click: () => HarMenuUtils.saveAllAsHar(displayedRequests, connector),
+    });
+
+    menuItems.push({
+      id: "request-list-context-copy-all-as-har",
+      label: L10N.getStr("netmonitor.context.copyAllAsHar"),
+      accesskey: L10N.getStr("netmonitor.context.copyAllAsHar.accesskey"),
+      disabled: !displayedRequests.length,
+      click: () => HarMenuUtils.copyAllAsHar(displayedRequests, connector),
+    });
+
+    showMenu(menuItems, { button: menuButton });
+  }
+
+  /**
    * Render filter Searchbox.
    */
   renderFilterBox(setRequestFilterText) {
     return (
       SearchBox({
         delay: FILTER_SEARCH_DELAY,
         keyShortcut: SEARCH_KEY_SHORTCUT,
         placeholder: SEARCH_PLACE_HOLDER,
@@ -293,41 +342,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,42 @@
+/* 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";
+
+loader.lazyRequireGetter(this, "HarExporter", "devtools/client/netmonitor/src/har/har-exporter", true);
+
+/**
+ * Helper object with HAR related context menu actions.
+ */
+var HarMenuUtils = {
+  /**
+   * 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/har/test/browser_net_har_copy_all_as_har.js
+++ b/devtools/client/netmonitor/src/har/test/browser_net_har_copy_all_as_har.js
@@ -60,26 +60,24 @@ add_task(async function() {
 });
 
 /**
  * Reload the page and copy all as HAR.
  */
 async function reloadAndCopyAllAsHar(tab, monitor) {
   let { connector, store, windowRequire } = monitor.panelWin;
   let Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
-  let RequestListContextMenu = windowRequire(
-    "devtools/client/netmonitor/src/widgets/RequestListContextMenu");
+  let { HarMenuUtils } = windowRequire(
+    "devtools/client/netmonitor/src/har/har-menu-utils");
   let { getSortedRequests } = windowRequire(
     "devtools/client/netmonitor/src/selectors/index");
 
   store.dispatch(Actions.batchEnable(false));
 
   let wait = waitForNetworkEvents(monitor, 1);
   tab.linkedBrowser.reload();
   await wait;
 
-  let contextMenu = new RequestListContextMenu({ connector });
-
-  await contextMenu.copyAllAsHar(getSortedRequests(store.getState()));
+  await HarMenuUtils.copyAllAsHar(getSortedRequests(store.getState()), connector);
 
   let jsonString = SpecialPowers.getClipboardData("text/unicode");
   return JSON.parse(jsonString);
 }
--- a/devtools/client/netmonitor/src/har/test/browser_net_har_import.js
+++ b/devtools/client/netmonitor/src/har/test/browser_net_har_import.js
@@ -9,42 +9,43 @@
 add_task(async () => {
   let { tab, monitor } = await initNetMonitor(
     HAR_EXAMPLE_URL + "html_har_import-test-page.html");
 
   info("Starting test... ");
 
   let { actions, connector, store, windowRequire } = monitor.panelWin;
   let Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
-  let RequestListContextMenu = windowRequire(
-    "devtools/client/netmonitor/src/widgets/RequestListContextMenu");
+  let { HarMenuUtils } = windowRequire(
+    "devtools/client/netmonitor/src/har/har-menu-utils");
   let { getSortedRequests } = windowRequire(
     "devtools/client/netmonitor/src/selectors/index");
   let { HarImporter } = windowRequire(
     "devtools/client/netmonitor/src/har/har-importer");
 
   store.dispatch(Actions.batchEnable(false));
 
   // Execute one POST request on the page and wait till its done.
   let wait = waitForNetworkEvents(monitor, 3);
   await ContentTask.spawn(tab.linkedBrowser, {}, async () => {
     await content.wrappedJSObject.executeTest();
   });
   await wait;
 
   // Copy HAR into the clipboard
-  let contextMenu = new RequestListContextMenu({ connector });
-  let json1 = await contextMenu.copyAllAsHar(getSortedRequests(store.getState()));
+  let json1 = await HarMenuUtils.copyAllAsHar(
+    getSortedRequests(store.getState()), connector);
 
   // Import HAR string
   let importer = new HarImporter(actions);
   importer.import(json1);
 
   // Copy HAR into the clipboard again
-  let json2 = await contextMenu.copyAllAsHar(getSortedRequests(store.getState()));
+  let json2 = await HarMenuUtils.copyAllAsHar(
+    getSortedRequests(store.getState()), connector);
 
   // Compare exported HAR data
   let har1 = JSON.parse(json1);
   let har2 = JSON.parse(json2);
 
   dump("---------------\n");
   dump(json1 + "\n");
   dump("---------------\n");
--- a/devtools/client/netmonitor/src/har/test/browser_net_har_post_data.js
+++ b/devtools/client/netmonitor/src/har/test/browser_net_har_post_data.js
@@ -9,33 +9,33 @@
 add_task(async function() {
   let { tab, monitor } = await initNetMonitor(
     HAR_EXAMPLE_URL + "html_har_post-data-test-page.html");
 
   info("Starting test... ");
 
   let { connector, store, windowRequire } = monitor.panelWin;
   let Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
-  let RequestListContextMenu = windowRequire(
-    "devtools/client/netmonitor/src/widgets/RequestListContextMenu");
+  let { HarMenuUtils } = windowRequire(
+    "devtools/client/netmonitor/src/har/har-menu-utils");
   let { getSortedRequests } = windowRequire(
     "devtools/client/netmonitor/src/selectors/index");
 
   store.dispatch(Actions.batchEnable(false));
 
   // Execute one POST request on the page and wait till its done.
   let wait = waitForNetworkEvents(monitor, 1);
   await ContentTask.spawn(tab.linkedBrowser, {}, async function() {
     content.wrappedJSObject.executeTest();
   });
   await wait;
 
   // Copy HAR into the clipboard (asynchronous).
-  let contextMenu = new RequestListContextMenu({ connector });
-  let jsonString = await contextMenu.copyAllAsHar(getSortedRequests(store.getState()));
+  let jsonString = await HarMenuUtils.copyAllAsHar(
+    getSortedRequests(store.getState()), connector);
   let har = JSON.parse(jsonString);
 
   // Check out the HAR log.
   isnot(har.log, null, "The HAR log must exist");
   is(har.log.pages.length, 1, "There must be one page");
   is(har.log.entries.length, 1, "There must be one request");
 
   let entry = har.log.entries[0];
--- a/devtools/client/netmonitor/src/har/test/browser_net_har_post_data_on_get.js
+++ b/devtools/client/netmonitor/src/har/test/browser_net_har_post_data_on_get.js
@@ -9,33 +9,33 @@
 add_task(async function() {
   let { tab, monitor } = await initNetMonitor(
     HAR_EXAMPLE_URL + "html_har_post-data-test-page.html");
 
   info("Starting test... ");
 
   let { connector, store, windowRequire } = monitor.panelWin;
   let Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
-  let RequestListContextMenu = windowRequire(
-    "devtools/client/netmonitor/src/widgets/RequestListContextMenu");
+  let { HarMenuUtils } = windowRequire(
+    "devtools/client/netmonitor/src/har/har-menu-utils");
   let { getSortedRequests } = windowRequire(
     "devtools/client/netmonitor/src/selectors/index");
 
   store.dispatch(Actions.batchEnable(false));
 
   // Execute one GET request on the page and wait till its done.
   let wait = waitForNetworkEvents(monitor, 1);
   await ContentTask.spawn(tab.linkedBrowser, {}, async function() {
     content.wrappedJSObject.executeTest3();
   });
   await wait;
 
   // Copy HAR into the clipboard (asynchronous).
-  let contextMenu = new RequestListContextMenu({ connector });
-  let jsonString = await contextMenu.copyAllAsHar(getSortedRequests(store.getState()));
+  let jsonString = await HarMenuUtils.copyAllAsHar(
+    getSortedRequests(store.getState()), connector);
   let har = JSON.parse(jsonString);
 
   // Check out the HAR log.
   isnot(har.log, null, "The HAR log must exist");
   is(har.log.pages.length, 1, "There must be one page");
   is(har.log.entries.length, 1, "There must be one request");
 
   let entry = har.log.entries[0];
--- a/devtools/client/netmonitor/src/har/test/browser_net_har_throttle_upload.js
+++ b/devtools/client/netmonitor/src/har/test/browser_net_har_throttle_upload.js
@@ -13,18 +13,18 @@ add_task(async function() {
 async function throttleUploadTest(actuallyThrottle) {
   let { tab, monitor } = await initNetMonitor(
     HAR_EXAMPLE_URL + "html_har_post-data-test-page.html");
 
   info("Starting test... (actuallyThrottle = " + actuallyThrottle + ")");
 
   let { connector, store, windowRequire } = monitor.panelWin;
   let Actions = windowRequire("devtools/client/netmonitor/src/actions/index");
-  let RequestListContextMenu = windowRequire(
-    "devtools/client/netmonitor/src/widgets/RequestListContextMenu");
+  let { HarMenuUtils } = windowRequire(
+    "devtools/client/netmonitor/src/har/har-menu-utils");
   let { getSortedRequests } = windowRequire(
     "devtools/client/netmonitor/src/selectors/index");
 
   store.dispatch(Actions.batchEnable(false));
 
   const size = 4096;
   const uploadSize = actuallyThrottle ? size / 3 : 0;
 
@@ -51,18 +51,18 @@ async function throttleUploadTest(actual
   let wait = waitForNetworkEvents(monitor, 1);
   await ContentTask.spawn(tab.linkedBrowser, { size }, async function(args) {
     content.wrappedJSObject.executeTest2(args.size);
   });
   await wait;
   await onEventTimings;
 
   // Copy HAR into the clipboard (asynchronous).
-  let contextMenu = new RequestListContextMenu({ connector });
-  let jsonString = await contextMenu.copyAllAsHar(getSortedRequests(store.getState()));
+  let jsonString = await HarMenuUtils.copyAllAsHar(
+    getSortedRequests(store.getState()), connector);
   let har = JSON.parse(jsonString);
 
   // Check out the HAR log.
   isnot(har.log, null, "The HAR log must exist");
   is(har.log.pages.length, 1, "There must be one page");
   is(har.log.entries.length, 1, "There must be one request");
 
   let entry = har.log.entries[0];
--- 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",
@@ -138,33 +139,33 @@ class RequestListContextMenu {
       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),
+      visible: requests.length > 0,
+      click: () => HarMenuUtils.copyAllAsHar(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),
+      visible: requests.length > 0,
+      click: () => HarMenuUtils.saveAllAsHar(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/")),
@@ -218,17 +219,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 +393,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;