Bug 1304352 - Add a Rep for Map and WeakMap. r=Honza; draft
authorNicolas Chevobbe <chevobbe.nicolas@gmail.com>
Tue, 27 Sep 2016 19:41:03 +0200
changeset 419384 3868293a10beb9b732d08441b5cfd226d9df929e
parent 419035 acce6fd30080ee91b26a24ef45f6c55d29d81167
child 532559 0a7c2a4ebf098b482f786d3c1b75ffa234615ce3
push id30914
push userchevobbe.nicolas@gmail.com
push dateFri, 30 Sep 2016 05:41:54 +0000
reviewersHonza
bugs1304352
milestone52.0a1
Bug 1304352 - Add a Rep for Map and WeakMap. r=Honza; Add a GripMap rep which handles both map and weakmap objects. Make it possible to pass an object as the `name` property to PropRep, in order to handle WeakMap keys. Add a mochitest to make sure everything is displayed correctly. MozReview-Commit-ID: ABBy2FT4rmE
devtools/client/shared/components/reps/grip-map.js
devtools/client/shared/components/reps/moz.build
devtools/client/shared/components/reps/prop-rep.js
devtools/client/shared/components/reps/rep.js
devtools/client/shared/components/test/mochitest/chrome.ini
devtools/client/shared/components/test/mochitest/test_reps_grip-map.html
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/reps/grip-map.js
@@ -0,0 +1,193 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* 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";
+// Make this available to both AMD and CJS environments
+define(function (require, exports, module) {
+  // Dependencies
+  const React = require("devtools/client/shared/vendor/react");
+  const { createFactories, isGrip } = require("./rep-utils");
+  const { Caption } = createFactories(require("./caption"));
+  const { PropRep } = createFactories(require("./prop-rep"));
+
+  // Shortcuts
+  const { span } = React.DOM;
+  /**
+   * Renders an map. A map is represented by a list of its
+   * entries enclosed in curly brackets.
+   */
+  const GripMap = React.createClass({
+    displayName: "GripMap",
+
+    propTypes: {
+      object: React.PropTypes.object,
+      mode: React.PropTypes.string,
+    },
+
+    getTitle: function (object) {
+      let title = object && object.class ? object.class : "Map";
+      if (this.props.objectLink) {
+        return this.props.objectLink({
+          object: object
+        }, title);
+      }
+      return title;
+    },
+
+    safeEntriesIterator: function (object, max) {
+      max = (typeof max === "undefined") ? 3 : max;
+      try {
+        return this.entriesIterator(object, max);
+      } catch (err) {
+        console.error(err);
+      }
+      return [];
+    },
+
+    entriesIterator: function (object, max) {
+      // Entry filter. Show only interesting entries to the user.
+      let isInterestingEntry = this.props.isInterestingEntry || ((type, value) => {
+        return (
+          type == "boolean" ||
+          type == "number" ||
+          (type == "string" && value.length != 0)
+        );
+      });
+
+      let mapEntries = object.preview && object.preview.entries
+        ? object.preview.entries : [];
+
+      let indexes = this.getEntriesIndexes(mapEntries, max, isInterestingEntry);
+      if (indexes.length < max && indexes.length < mapEntries.length) {
+        // There are not enough entries yet, so we add uninteresting entries.
+        indexes = indexes.concat(
+          this.getEntriesIndexes(mapEntries, max - indexes.length, (t, value, name) => {
+            return !isInterestingEntry(t, value, name);
+          })
+        );
+      }
+
+      let entries = this.getEntries(mapEntries, indexes);
+      if (entries.length < mapEntries.length) {
+        // There are some undisplayed entries. Then display "more…".
+        let objectLink = this.props.objectLink || span;
+
+        entries.push(Caption({
+          key: "more",
+          object: objectLink({
+            object: object
+          }, `${mapEntries.length - max} more…`)
+        }));
+      }
+
+      return entries;
+    },
+
+    /**
+     * Get entries ordered by index.
+     *
+     * @param {Array} entries Entries array.
+     * @param {Array} indexes Indexes of entries.
+     * @return {Array} Array of PropRep.
+     */
+    getEntries: function (entries, indexes) {
+      // Make indexes ordered by ascending.
+      indexes.sort(function (a, b) {
+        return a - b;
+      });
+
+      return indexes.map((index, i) => {
+        let [key, entryValue] = entries[index];
+        let value = entryValue.value !== undefined ? entryValue.value : entryValue;
+
+        return PropRep({
+          // key,
+          name: key,
+          equal: ": ",
+          object: value,
+          // Do not add a trailing comma on the last entry
+          // if there won't be a "more..." item.
+          delim: (i < indexes.length - 1 || indexes.length < entries.length) ? ", " : "",
+          mode: "tiny",
+          objectLink: this.props.objectLink,
+        });
+      });
+    },
+
+    /**
+     * Get the indexes of entries in the map.
+     *
+     * @param {Array} entries Entries array.
+     * @param {Number} max The maximum length of indexes array.
+     * @param {Function} filter Filter the entry you want.
+     * @return {Array} Indexes of filtered entries in the map.
+     */
+    getEntriesIndexes: function (entries, max, filter) {
+      return entries
+        .reduce((indexes, [key, entry], i) => {
+          if (indexes.length < max) {
+            let value = (entry && entry.value !== undefined) ? entry.value : entry;
+            // Type is specified in grip's "class" field and for primitive
+            // values use typeof.
+            let type = (value && value.class ? value.class : typeof value).toLowerCase();
+
+            if (filter(type, value, key)) {
+              indexes.push(i);
+            }
+          }
+
+          return indexes;
+        }, []);
+    },
+
+    render: function () {
+      let object = this.props.object;
+      let props = this.safeEntriesIterator(object,
+        (this.props.mode == "long") ? 100 : 3);
+
+      let objectLink = this.props.objectLink || span;
+      if (this.props.mode == "tiny") {
+        return (
+          span({className: "objectBox objectBox-object"},
+            this.getTitle(object),
+            objectLink({
+              className: "objectLeftBrace",
+              object: object
+            }, "")
+          )
+        );
+      }
+
+      return (
+        span({className: "objectBox objectBox-object"},
+          this.getTitle(object),
+          objectLink({
+            className: "objectLeftBrace",
+            object: object
+          }, " { "),
+          props,
+          objectLink({
+            className: "objectRightBrace",
+            object: object
+          }, " }")
+        )
+      );
+    },
+  });
+
+  function supportsObject(grip, type) {
+    if (!isGrip(grip)) {
+      return false;
+    }
+    return (grip.preview && grip.preview.kind == "MapLike");
+  }
+
+  // Exports from this module
+  exports.GripMap = {
+    rep: GripMap,
+    supportsObject: supportsObject
+  };
+});
--- a/devtools/client/shared/components/reps/moz.build
+++ b/devtools/client/shared/components/reps/moz.build
@@ -8,16 +8,17 @@ DevToolsModules(
     'array.js',
     'attribute.js',
     'caption.js',
     'date-time.js',
     'document.js',
     'event.js',
     'function.js',
     'grip-array.js',
+    'grip-map.js',
     'grip.js',
     'null.js',
     'number.js',
     'object-with-text.js',
     'object-with-url.js',
     'object.js',
     'prop-rep.js',
     'regexp.js',
--- a/devtools/client/shared/components/reps/prop-rep.js
+++ b/devtools/client/shared/components/reps/prop-rep.js
@@ -4,43 +4,60 @@
  * 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";
 
 // Make this available to both AMD and CJS environments
 define(function (require, exports, module) {
   const React = require("devtools/client/shared/vendor/react");
   const { createFactories } = require("./rep-utils");
-
   const { span } = React.DOM;
 
   /**
-   * Property for Obj (local JS objects) and Grip (remote JS objects)
-   * reps. It's used to render object properties.
+   * Property for Obj (local JS objects), Grip (remote JS objects)
+   * and GripMap (remote JS maps and weakmaps) reps.
+   * It's used to render object properties.
    */
   let PropRep = React.createFactory(React.createClass({
     displayName: "PropRep",
 
     propTypes: {
       // Property name.
-      name: React.PropTypes.string,
+      name: React.PropTypes.oneOfType([
+        React.PropTypes.string,
+        React.PropTypes.object,
+      ]).isRequired,
       // Equal character rendered between property name and value.
       equal: React.PropTypes.string,
       // Delimiter character used to separate individual properties.
       delim: React.PropTypes.string,
+      mode: React.PropTypes.string,
     },
 
     render: function () {
+      const { Grip } = require("./grip");
       let { Rep } = createFactories(require("./rep"));
 
+      let key;
+      // The key can be a simple string, for plain objects,
+      // or another object for maps and weakmaps.
+      if (typeof this.props.name === "string") {
+        key = span({"className": "nodeName"}, this.props.name);
+      } else {
+        key = Rep({
+          object: this.props.name,
+          mode: this.props.mode || "tiny",
+          defaultRep: Grip,
+          objectLink: this.props.objectLink,
+        });
+      }
+
       return (
         span({},
-          span({
-            "className": "nodeName"},
-            this.props.name),
+          key,
           span({
             "className": "objectEqual"
           }, this.props.equal),
           Rep(this.props),
           span({
             "className": "objectComma"
           }, this.props.delim)
         )
--- a/devtools/client/shared/components/reps/rep.js
+++ b/devtools/client/shared/components/reps/rep.js
@@ -30,16 +30,17 @@ define(function (require, exports, modul
   const { Func } = require("./function");
   const { RegExp } = require("./regexp");
   const { StyleSheet } = require("./stylesheet");
   const { TextNode } = require("./text-node");
   const { Window } = require("./window");
   const { ObjectWithText } = require("./object-with-text");
   const { ObjectWithURL } = require("./object-with-url");
   const { GripArray } = require("./grip-array");
+  const { GripMap } = require("./grip-map");
   const { Grip } = require("./grip");
 
   // List of all registered template.
   // XXX there should be a way for extensions to register a new
   // or modify an existing rep.
   let reps = [
     RegExp,
     StyleSheet,
@@ -49,16 +50,17 @@ define(function (require, exports, modul
     Attribute,
     Func,
     ArrayRep,
     Document,
     Window,
     ObjectWithText,
     ObjectWithURL,
     GripArray,
+    GripMap,
     Grip,
     Undefined,
     Null,
     StringRep,
     Number,
     SymbolRep,
   ];
 
--- a/devtools/client/shared/components/test/mochitest/chrome.ini
+++ b/devtools/client/shared/components/test/mochitest/chrome.ini
@@ -10,16 +10,17 @@ support-files =
 [test_reps_array.html]
 [test_reps_attribute.html]
 [test_reps_date-time.html]
 [test_reps_document.html]
 [test_reps_event.html]
 [test_reps_function.html]
 [test_reps_grip.html]
 [test_reps_grip-array.html]
+[test_reps_grip-map.html]
 [test_reps_null.html]
 [test_reps_number.html]
 [test_reps_object.html]
 [test_reps_object-with-text.html]
 [test_reps_object-with-url.html]
 [test_reps_regexp.html]
 [test_reps_string.html]
 [test_reps_stylesheet.html]
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/test/mochitest/test_reps_grip-map.html
@@ -0,0 +1,403 @@
+
+<!DOCTYPE HTML>
+<html>
+<!--
+Test GripMap rep
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Rep test - GripMap</title>
+  <script type="application/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css">
+</head>
+<body>
+<pre id="test">
+<script src="head.js" type="application/javascript;version=1.8"></script>
+<script type="application/javascript;version=1.8">
+"use strict";
+
+window.onload = Task.async(function* () {
+  let { Rep } = browserRequire("devtools/client/shared/components/reps/rep");
+  let { GripMap } = browserRequire("devtools/client/shared/components/reps/grip-map");
+
+  const componentUnderTest = GripMap;
+
+  try {
+    yield testEmptyMap();
+    yield testSymbolKeyedMap();
+    yield testWeakMap();
+
+    // // Test entries iterator
+    yield testMaxEntries();
+    yield testMoreThanMaxEntries();
+    yield testUninterestingEntries();
+  } catch (e) {
+    ok(false, "Got an error: " + DevToolsUtils.safeErrorString(e));
+  } finally {
+    SimpleTest.finish();
+  }
+
+  function testEmptyMap() {
+    // Test object: `new Map()`
+    const testName = "testEmptyMap";
+
+    // Test that correct rep is chosen
+    const gripStub = getGripStub("testEmptyMap");
+    const renderedRep = shallowRenderComponent(Rep, { object: gripStub });
+    is(renderedRep.type, GripMap.rep, `Rep correctly selects ${GripMap.rep.displayName}`);
+
+    // Test rendering
+    const defaultOutput = `Map {  }`;
+
+    const modeTests = [
+      {
+        mode: undefined,
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "tiny",
+        expectedOutput: "Map",
+      },
+      {
+        mode: "short",
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "long",
+        expectedOutput: defaultOutput,
+      }
+    ];
+
+    testRepRenderModes(modeTests, testName, componentUnderTest, getGripStub(testName));
+  }
+
+  function testSymbolKeyedMap() {
+    // Test object:
+    // `new Map([[Symbol("a"), "value-a"], [Symbol("b"), "value-b"]])`
+    const testName = "testSymbolKeyedMap";
+
+    const defaultOutput = `Map { Symbol(a): "value-a", Symbol(b): "value-b" }`;
+
+    const modeTests = [
+      {
+        mode: undefined,
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "tiny",
+        expectedOutput: "Map",
+      },
+      {
+        mode: "short",
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "long",
+        expectedOutput: defaultOutput,
+      }
+    ];
+
+    testRepRenderModes(modeTests, testName, componentUnderTest, getGripStub(testName));
+  }
+
+  function testWeakMap() {
+    // Test object: `new WeakMap([[{a: "key-a"}, "value-a"]])`
+    const testName = "testWeakMap";
+
+    // Test that correct rep is chosen
+    const gripStub = getGripStub("testWeakMap");
+    const renderedRep = shallowRenderComponent(Rep, { object: gripStub });
+    is(renderedRep.type, GripMap.rep, `Rep correctly selects ${GripMap.rep.displayName}`);
+
+    // Test rendering
+    const defaultOutput = `WeakMap { Object: "value-a" }`;
+
+    const modeTests = [
+      {
+        mode: undefined,
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "tiny",
+        expectedOutput: "WeakMap",
+      },
+      {
+        mode: "short",
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "long",
+        expectedOutput: defaultOutput,
+      }
+    ];
+
+    testRepRenderModes(modeTests, testName, componentUnderTest, getGripStub(testName));
+  }
+
+  function testMaxEntries() {
+    // Test object:
+    // `new Map([["key-a","value-a"], ["key-b","value-b"], ["key-c","value-c"]])`
+    const testName = "testMaxEntries";
+
+    const defaultOutput = `Map { key-a: "value-a", key-b: "value-b", key-c: "value-c" }`;
+
+    const modeTests = [
+      {
+        mode: undefined,
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "tiny",
+        expectedOutput: "Map",
+      },
+      {
+        mode: "short",
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "long",
+        expectedOutput: defaultOutput,
+      }
+    ];
+
+    testRepRenderModes(modeTests, testName, componentUnderTest, getGripStub(testName));
+  }
+
+  function testMoreThanMaxEntries() {
+    // Test object = `new Map(
+    //   [["key-0", "value-0"], ["key-1", "value-1"]], …, ["key-100", "value-100"]]}`
+    const testName = "testMoreThanMaxEntries";
+
+    const defaultOutput =
+      `Map { key-0: "value-0", key-1: "value-1", key-2: "value-2", 98 more… }`;
+
+    // Generate string with 101 entries, which is the max limit for 'long' mode.
+    let longString = Array.from({length: 100}).map((_, i) => `key-${i}: "value-${i}"`);
+    const longOutput = `Map { ${longString.join(", ")}, 1 more… }`;
+
+    const modeTests = [
+      {
+        mode: undefined,
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "tiny",
+        expectedOutput: `Map`,
+      },
+      {
+        mode: "short",
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "long",
+        expectedOutput: longOutput,
+      }
+    ];
+
+    testRepRenderModes(modeTests, testName, componentUnderTest, getGripStub(testName));
+  }
+
+  function testUninterestingEntries() {
+    // Test object:
+    // `new Map([["key-a",null], ["key-b",undefined], ["key-c","value-c"], ["key-d",4]])`
+    const testName = "testUninterestingEntries";
+
+    const defaultOutput =
+      `Map { key-a: null, key-c: "value-c", key-d: 4, 1 more… }`;
+    const longOutput =
+      `Map { key-a: null, key-b: undefined, key-c: "value-c", key-d: 4 }`;
+
+    const modeTests = [
+      {
+        mode: undefined,
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "tiny",
+        expectedOutput: `Map`,
+      },
+      {
+        mode: "short",
+        expectedOutput: defaultOutput,
+      },
+      {
+        mode: "long",
+        expectedOutput: longOutput,
+      }
+    ];
+
+    testRepRenderModes(modeTests, testName, componentUnderTest, getGripStub(testName));
+  }
+
+  function getGripStub(functionName) {
+    switch (functionName) {
+      case "testEmptyMap":
+        return {
+          "type": "object",
+          "actor": "server1.conn1.child1/obj97",
+          "class": "Map",
+          "extensible": true,
+          "frozen": false,
+          "sealed": false,
+          "ownPropertyLength": 0,
+          "preview": {
+            "kind": "MapLike",
+            "size": 0,
+            "entries": []
+          }
+        };
+
+      case "testSymbolKeyedMap":
+        return {
+          "type": "object",
+          "actor": "server1.conn1.child1/obj118",
+          "class": "Map",
+          "extensible": true,
+          "frozen": false,
+          "sealed": false,
+          "ownPropertyLength": 0,
+          "preview": {
+            "kind": "MapLike",
+            "size": 2,
+            "entries": [
+              [
+                {
+                  "type": "symbol",
+                  "name": "a"
+                },
+                "value-a"
+              ],
+              [
+                {
+                  "type": "symbol",
+                  "name": "b"
+                },
+                "value-b"
+              ]
+            ]
+          }
+        };
+
+      case "testWeakMap":
+        return {
+          "type": "object",
+          "actor": "server1.conn1.child1/obj115",
+          "class": "WeakMap",
+          "extensible": true,
+          "frozen": false,
+          "sealed": false,
+          "ownPropertyLength": 0,
+          "preview": {
+            "kind": "MapLike",
+            "size": 1,
+            "entries": [
+              [
+                {
+                  "type": "object",
+                  "actor": "server1.conn1.child1/obj116",
+                  "class": "Object",
+                  "extensible": true,
+                  "frozen": false,
+                  "sealed": false,
+                  "ownPropertyLength": 1
+                },
+                "value-a"
+              ]
+            ]
+          }
+        };
+
+      case "testMaxEntries":
+        return {
+          "type": "object",
+          "actor": "server1.conn1.child1/obj109",
+          "class": "Map",
+          "extensible": true,
+          "frozen": false,
+          "sealed": false,
+          "ownPropertyLength": 0,
+          "preview": {
+            "kind": "MapLike",
+            "size": 3,
+            "entries": [
+              [
+                "key-a",
+                "value-a"
+              ],
+              [
+                "key-b",
+                "value-b"
+              ],
+              [
+                "key-c",
+                "value-c"
+              ]
+            ]
+          }
+        };
+
+      case "testMoreThanMaxEntries": {
+        let entryNb = 101;
+        return {
+          "type": "object",
+          "class": "Map",
+          "actor": "server1.conn0.obj332",
+          "extensible": true,
+          "frozen": false,
+          "sealed": false,
+          "ownPropertyLength": 0,
+          "preview": {
+            "kind": "MapLike",
+            "size": entryNb,
+            // Generate 101 entries, which is more that the maximum
+            // limit in case of the 'long' mode.
+            "entries": Array.from({length: entryNb}).map((_, i) => {
+              return [`key-${i}`, `value-${i}`];
+            })
+          }
+        };
+      }
+
+      case "testUninterestingEntries":
+        return {
+          "type": "object",
+          "actor": "server1.conn1.child1/obj111",
+          "class": "Map",
+          "extensible": true,
+          "frozen": false,
+          "sealed": false,
+          "ownPropertyLength": 0,
+          "preview": {
+            "kind": "MapLike",
+            "size": 4,
+            "entries": [
+              [
+                "key-a",
+                {
+                  "type": "null"
+                }
+              ],
+              [
+                "key-b",
+                {
+                  "type": "undefined"
+                }
+              ],
+              [
+                "key-c",
+                "value-c"
+              ],
+              [
+                "key-d",
+                4
+              ]
+            ]
+          }
+        };
+    }
+  }
+});
+</script>
+</pre>
+</body>
+</html>