Skip to content
This repository

Selection append support for naked nodes and functions #732

Closed
wants to merge 10 commits into from

4 participants

gabriel montagné láscaris-comneno Mike Bostock Ian MacLeod Raymond Hill
gabriel montagné láscaris-comneno

Hello Mike,

I'd like to collaborate selection-append support for naked dom nodes and for functions which, according to normal d3 style, will take the (d, i) data and index arguments and can return either a node or a qualified or unqualified name for a node.

Mike Bostock
Owner

Related #4 #311 #724

Mike Bostock
Owner

I'd like to see more discussion about how this would be used.

gabriel montagné láscaris-comneno

I've implemented this because it was needed at work: We're building a generic component that takes an unknown container (can be a list, or a div, etc) for which we define a "prototype" renderer in markup. This renderer can be an arbitrary chunk of html that has been styled, etc.. Our component pulls that node from the component and caches it. When we join the data we append a new clone of the for each of the data items and we then update each with the datum.

A simplified example would go along these lines. Given the markup,

  <html>
  ...
  <li class="prototype"> <!-- or ".template", etc. -->
    <span class="price">Price</span>
    ::
    <span class="index">Index</span>
    <p>... other stuff</p>
  </li>

We do something along the lines of

;requirejs.config({ shim: { 'd3': {exports: 'd3'}} })
require(["d3", "jquery"], function(d3, $) {

  var $prototype = $(".prototype").remove(), format = d3.format("+3.3f")

  ul = d3.select("body").append("ul")
  li = ul.selectAll("*").data(d3.range(10).map(format))
  li.enter().append(function(d, i) { return $prototype.clone().get(0)  })
  li.select(".price").text(function(d, i) { return d; })
  li.select(".index").text(function(d, i) { return i; })

});

We "managed" to implement this without appending the raw node by appending stub "span" nodes, then on an each we actually replace them using $.replace()... but we have to set the datum again by hand and re-calculate the data join. Not nice at all.

gabriel montagné láscaris-comneno

... We're also expecting to be able to select among various data renderers based on the data type... We wish to have certain markup structures that can render certain kinds of articles, for example, and some other configuration for other kinds of research, etc. and be able to decide at the moment of appending the nodes. Hopefully from some markup chunk written (and styled) as html and not have to reconstruct them using javscript as they can get pretty complex.

We're using d3 to build all our data-driven components. Not only charts but mixed activity feeds for publications, selectors, typeaheads which mix text and images, etc.

gabriel montagné láscaris-comneno

... and even if just to be able to do things like,

div.enter().append(function(d, i) { return Math.random() > 0.5 ? $("<p>something" + d + "</p>").get(0) : $("<h2>other" + i + "</h2>").get(0) })

and get

<div><p>something+0.000</p><p>something+1.000</p><p>something+2.000</p><h2>other3</h2><h2>other4</h2><p>something+5.000</p><p>something+6.000</p><p>something+7.000</p><h2>other8</h2><p>something+9.000</p><p>something+10.000</p><h2>other11</h2><p>something+12.000</p><h2>other13</h2><p>something+14.000</p><p>something+15.000</p><p>something+16.000</p><p>something+17.000</p><p>something+18.000</p><p>something+19.000</p><h2>other20</h2></div>

:^)

Mike Bostock
Owner

Seems like selection.clone(node) would be a closer fit to what you want, so as to support templating. But even then, you might want selection.appendClone and selection.insertClone, or at least allow an optional argument to selection.clone(node, before) (essentially making clone equivalent to insertClone).

I'm hesitant to allow selection.append(function), since there's no convenient way to use it—your function gets called multiple times, so you need to carefully return the correct node each time the function is called. A selection.clone(node) method is safer since it can create new nodes for you automatically. Though there's still the awkwardness that you need to specify a reference to the node to clone (perhaps var node = d3.select(selector).remove().node()).

Another possibility might be selection.clone(selector). For example, say you had some HTML:

<ul>
  <li class="template">
    <span class="price"></span>
    <span class="index"></span>
  </li>
</ul>

You might say:

var item = d3.select("ul").selectAll(".item").data(items).enter().clone(".template");
item.select(".price").text();
item.select(".index").text();

So, the node matching the selector ".template" is first removed from the DOM, and then clones are appended for each element in the enter selection. Here's how you could extend the enter prototype to do that:

d3.selection.enter.prototype.clone = function(selector) {
  var template, parent;
  return this.select(function() {
    if (parent !== this) template = d3.select(parent = this).select(selector).remove().node();
    return this.appendChild(template.cloneNode(true));
  });
};

This isn't perfect because the original selector ".item" doesn't match the template selector ".template"; you'd want to remove the "template" class and add the "item" class. It'd be better to use ".item" as the selector for both, but then the first datum will match the template node and end up in the update selection rather than the enter selection, throwing the whole thing off. I suppose selectAll(".item:not(.template)") would work, but that's fairly awkward (and all the cloned nodes will still have the class "template").

Note that you can pretty much do this already using enter.select:

var item = d3.select("ul").selectAll(".item").data(items).enter().select(clone(".template"));
item.select(".price").text();
item.select(".index").text();

function clone(template) {
  template = d3.select(template).remove().node();
  return function() {
    return this.appendChild(template.cloneNode(true));
  };
}

Here ".template" is found globally, though the better solution (as in selection.clone) would only select within the each group's parentNode, i.e., within the previously-selected UL element.

gabriel montagné láscaris-comneno

Thanks for the reply. And I agree, clone (and insertClone) do sound like a good idea. Although in my mind, if you read it too quickly, it might read like you could be cloning the selection.

I understand that in order to use selection.append(function) one needs to understand that the function will be called several times and that one must return a fresh object every time... but I don't think that that is significally harder to grasp than the other functions one can provide for the different methods like attr(), style(), etc. for which one also needs to be aware that the method is called time and time again for each of the elements.
The arguments of the append function would be the same so I think it can even feel intuitive for the users.

With the patch the current functionality of selection.append() wouldn't be affected. One can use the name string just as before; the use of a function can be thought of as a slightly more advance use. ---The function can even return the element name as a String, "p", for example, and it will still work---

We do have one (for us very important) use case which couldn't be fulfilled by the clone method. This is being able to decide which node to return based on the datum item. It is in this context that I find the function argument quite convenient:

enter.append(function(d, i) {
  return  d.type == "publication" ? publicationRenderer.cloneNode(true)
        : d.type == "research"    ? researchRenderer.cloneNode(true)
        : "p"  // default?
});

... as illustrated on this very toyish example (which also shows how the nodes can be easily plucked and potentially prepared for rendering):

https://gist.github.com/3215903#file_index.html
http://bl.ocks.org/3215903

I like the manual selection / attachment you suggest on your last example and we can use this approach on our project. But I don't see how that's more intuitive than just allowing the function as argument for append / insert.

Ian MacLeod

To me, there's several issues being surfaced by this:

Consistency

Practically everything else supports functions, I'm guessing that most people would be genuinely surprised that selection.append() & selection.insert() do not.

Similarly, while select() can handle all of the desired behaviors, you certainly won't think to use it for a mutation. Tweaking the docs might mitigate that, but I suspect that you'll continue to see pull requests like this.

Node Confusion

@mbostock It seems like your primary concern here is around inserting raw nodes (and screwups from re-inserting existing nodes). What about:

  • Only allowing string values (or function calls that return strings)

or:

  • Throwing (or silent failing?) if you attempt to insert a node that already is in the DOM, or has __data__. (I'm less of a fan of this)

It is, however, tremendously useful to be able to synthesize nodes via whatever framework you're comfortable using (say, a Backbone view) - you also avoid having to create wrapper elements or modify the DOM multiple times...

DOM Mutations

DOM insertions are pretty heavy (and you especially feel the pain on a mobile device), cutting them to a minimum has been a great performance boon for my projects so far. I think we can get to a pretty comfortable world where those are cut down to a reasonable level while also producing clear & concise D3 code.

For example, the main thing I'm using (via #734) is to pass a function for the before argument of selection.insert - so that I can easily insert elements in the order that they are specified by data, regardless of what's already in the DOM. This avoids shuffling things around after the fact via order() - Is there a better, already-existing, approach that I'm missing?

Mike Bostock
Owner

You’re correct that many methods in D3 accept both constants and functions are arguments. When a function is accepted, its return value is typically the same type as the accepted constant. For example, with selection.attr(name, value), the value can be specified as a string or a function that returns a string (computing the value from data).

Note, however, that the attribute name cannot be specified as a function. This would be possible to implement, but I think there is a practical limit to what should be defined as a function. While restrictive, it is rare that you would need to compute the attribute name dynamically, as commonly only attribute values need to be computed from data. It is for the same reason that selection.append and selection.insert accept only strings rather than functions that return strings; it is rare that you need to compute the element name dynamically.

That selection.select and selection.selectAll also accept a function is somewhat of a special-case to allow extensibility. I would guess that most people don’t know that these methods accept functions, since they are almost always used with selector strings; so, I disagree that most people are genuinely surprised that selection.append and selection.insert do not accept functions.

Strictly speaking, if you wanted selection.select and selection.selectAll to be consistent with other function usage in D3, the input function should return a string (the selector string computed dynamically from data) rather than a node or an array of nodes. But this would make them significantly less powerful for extension; for example, you could no longer select via XPath functions. You could allow both types of return values and use type inference to determine whether the function returned a string or a node, but that introduces complexity and a performance cost; furthermore, I don't see computing a selector dynamically especially useful. I definitely want to preserve selection.select and selection.selectAll accepting functions that return nodes because this provides a fantastic building block (or escape-hatch, if you will) for extensibility.

So, should selection.append and selection.insert accept a function that returns a string? I would say no, for the same reason that the name passed to selection.attr need not be a function that returns a string. But should selection.append and selection.insert accept a function that returns a raw node? That seems more plausible to me—which is why there has been a long-standing TODO comment to this effect. Though I’m not totally convinced that such functionality is significantly more useful than passing a function to selection.select. And while you are correct that most people wouldn’t think of using selection.select to select elements that are created (or appended) dynamically, this seems like a power feature anyway, so I’m less worried about minimizing surprise. (Plus, it might be empowering to teach people that selection.append and selection.insert are thin wrappers on top of selection.select.)

It seems moderately useful and consistent to allow the before argument for selection.insert to be a function which returns a node (probably using d3_selection_selector). I expect this has a performance cost for the common case where the before argument is a selector string, but it’s probably negligible.

Ian MacLeod

Awesome, thanks for the thorough response and rationale! I think I'm pretty well in line with your thinking now.

For my specific problem, would a selection.insertInOrder(name) be even more appropriate here?

gabriel montagné láscaris-comneno

I also now agree... but only after having first done my own implementation of append for function that returns a raw node and after trying out the "select" direct way of creating and appending the node by hand.

Trying both, one realizes that the functions you pass to either are very very similar, the only difference is the actual attaching the node you create before returning it on the select route.

So now I know how little value the append(function--for raw node) provides.

But before, just by going through the API and the documentation, that was not visible.

If only for this, I wish there had been either a note on the selection.select documentation about this technique (which is not intuitive, I have to say) or the friendlier, thin, but welcomed sugar nicety of allowing the function that returns the node.

Raymond Hill

If selection.append() supported functions, this sure would simplify code.

Take this very simple case: a bar chart, where each bar is a filled "rect" and a "text" used as a tip for the datum, both sitting within a common parent "g".

I want to benefit from the power of CSS, so I can create a CSS rule for when the user hover over the parent "g", the child "text" become visible (i.e.: "svg.barchart g.bar text.tip { display: none }" then "svg.barchart g.bar:hover text.tip { display: block }"). No javascript required.

I can do that right now, but I need three consecutive select.append(), while in my opinion it would makes more sense to call select.append() once and let the user create whatever composite (or not) node he wants. Whenever the composite node grows in complexity, more append() calls are required.

But then, I admit I am new to d3, so I might be overlooking the optimal way to do this.

Mike Bostock
Owner

You can append bare nodes, but you have to pass a function to selection.select or selection.each, basically dropping down to the DOM API. For example:

var node = document.createElement("span");

d3.select("body").select(function() { return this.appendChild(node); });

You could also use selection.each, of course:

d3.select("body")
    .each(function() { this.appendChild(document.createElement("h1")); })
    .each(function() { this.appendChild(document.createElement("span")); });

One issue with append taking a function is whether this function should return a string (representing the name of the node to create, such as "span" or "g") or a node, or both. For selection.select(function), the function must return a node (not a selector string; that could be supported, but I think it would be rarely used).

Also, selection.select(node) or selection.append(node) only works when the selection has a single element, so I don’t think this is appropriate to add to the API.

Raymond Hill

The each() you propose is no different than what I see as a workaround now: multiple iterations are required in order to join data and composite DOM elements.

Whether this function should return a string is really a non issue in my view, as you said somewhere above, that make no sense, this I agree.

I do not understand your last sentence. I define a composite element as a single element, but with one or more children. So append() would be returned the single top element of the hierarchical ensemble. In the example I was giving, the function given to append() as an argument would instantiate and assemble <g><rect /><text>...</text></g> in memory, and return a reference to the "g" element. This would allow for a single iteration, and for whatever needs to be computed, to be computed once per datum.

The issue to me is that the current API to join data to DOM element is less friendly toward elements which are composite, which I believe is not an uncommon occurrence, as we are dealing here with objects which are inherently hierarchical, "g" or "div" elements are rather useless on their own.

Lets have this CSS:

.d3BarChart .plotArea .bar text {
    display: none;
    }

.d3BarChart .plotArea .bar:hover text {
    display: block;
    }

With current API:

    var bars = plotArea.selectAll(".bar")
        .data(d3FriendlyData)
        .enter()
        .append("g")
            .attr("class", "bar")
            [other attributes stuff]
        ;
    bars
        .append("rect")
            [attributes stuff]
        ;
    bars
        .append("text")
            [attributes stuff]
        ;

While if append() accepted a function as an argument:

    [...]

    var bars = plotArea.selectAll(".bar")
        .data(d3FriendlyData)
        .enter()
        .append(function(d, i) {
            var g = [create g (with convenient d3 API to create SVG elements?)]
            var rect = [create rect]
            var text = [create text]
            [append rect to g]
            [append text to g]
            [attributes stuff for all]
            return g;
            })

Obviously, this is a simple example, and the former form doesn't appear too much of a burden with this simple example. But I do believe supporting a function as an argument would remove a constraint from the current API which assume that whatever needs to represent a datum is a leaf DOM element.

This new form of append() has also nice side effects

  • Ability to reuse values which are computed on the fly from the datum for all elements in the composite element, thus compute once, use more than once.

  • Ability to reuse elements which are already instantiated but now marked as unused component. Say some elements are removed because no longer used, I could place them in a recycling bin for later reuse, thus skipping the (costly from what I understand) instantiation from scratch, all with the attribute settings stuff which does not depend on the datum. Example:

    var bars = plotArea.selectAll(".bar")
        .data(d3FriendlyData)
        .enter()
        .append(function(d, i) {
            var g = RecyclingBin.pop();
            if (!g) {
                g = [create g]
                var rect = [create rect]
                var text = [create text]
                [append rect to g]
                [append text to g]
                [attributes stuff which does not depend on datum]
                }
            [attributes stuff which depends on datum]
            return g;
            })

Mike Bostock
Owner

The append behavior you describe is nearly identical to selection.select(function). The only difference is that with selection.select, you also have to append the node yourself (not just create it).

Raymond Hill

Ah I see. I tried it and I can indeed do the above using plotArea.selectAll(".bar").data(d3FriendlyData).enter().select(). Never mind then, I am new to D3, I still need to wrap my head around it. Sorry for the noise.

gabriel montagné láscaris-comneno

Yes, once one realizes that if you pass a function to select or selectAll one can return whatever elements
one wants, then it's easy to appreciate how flexible but terse the API is.

One can then build all sorts of components to plug in there. At work we've built components that clone from other nodes or that build nodes from html snippets.

But one can do many different things as well: selection functions that return objects from a pool, components that do subselections based on characteristics of the data or the nodes, components that return nodes that already live somewhere else on the DOM or even creating nodes that are not yet attached because we want to asynchronously build them before showing them... etc.

For example, this great new library, https://github.com/sammyt/see , https://groups.google.com/forum/?fromgroups=#!topic/d3-js/dNemrm3UF1M which can make sure a certain DOM structure---described in a simple expression---is in place, building whatever elements are missing, and returning a new
selection with whatever nodes one has marked as targets.

I think Mike is right in trying to keep the API small; it'd be hard to cater for all these different uses without overly complicating the calls. And it the most straightforward things one would like built are easy to express.

Mike Bostock mbostock referenced this pull request from a commit June 30, 2013
Mike Bostock Accept function for selection append and insert.
Like selection.select, selection.append and selection.insert can now accept a
function which returns a node. This makes it slightly easier to append or insert
elements whose name is computed from data, or to append elements that already
exist (say from an element pool).

There has been much discussion regarding whether the function should return the
name of the element or the element itself. Returning a name is less work for the
caller, but only supports creating new elements; returning a name is also more
consistent with how D3 defines attribute values, but D3 does not allow attribute
names to be specified as functions. So, it seemed better to opt for consistency
with selection.select and selection.selectAll, which accept functions that
return elements, since this is more expressive. Of course, you can still use
select and selectAll to append elements, but using append to do that directly is
more intuitive.

Related #4 #311 #724 #732 #734 #961 #1031 #1271.
bef1ccb
Mike Bostock
Owner

Superseded by #1354.

Mike Bostock mbostock closed this June 30, 2013
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
82  d3.v2.js
@@ -1749,10 +1749,22 @@ d3_selectionPrototype.html = function(value) {
1749 1749
       ? function() { this.innerHTML = ""; }
1750 1750
       : function() { this.innerHTML = value; });
1751 1751
 };
1752  
-// TODO append(node)?
1753  
-// TODO append(function)?
1754  
-d3_selectionPrototype.append = function(name) {
1755  
-  name = d3.ns.qualify(name);
  1752
+d3_selectionPrototype.append = function(element) {
  1753
+  var node, func, name
  1754
+
  1755
+  switch (true) {
  1756
+    case element instanceof Function:
  1757
+      func = element;
  1758
+      break;
  1759
+
  1760
+    case element instanceof Node:
  1761
+      node = element;
  1762
+      break;
  1763
+
  1764
+    default:
  1765
+      name = d3.ns.qualify(element);
  1766
+      break;
  1767
+  }
1756 1768
 
1757 1769
   function append() {
1758 1770
     return this.appendChild(document.createElementNS(this.namespaceURI, name));
@@ -1762,13 +1774,43 @@ d3_selectionPrototype.append = function(name) {
1762 1774
     return this.appendChild(document.createElementNS(name.space, name.local));
1763 1775
   }
1764 1776
 
1765  
-  return this.select(name.local ? appendNS : append);
  1777
+  function appendNode() {
  1778
+    return this.appendChild(node);
  1779
+  }
  1780
+
  1781
+  function appendFunc() {
  1782
+    var result = func.apply(this, arguments);
  1783
+    if (result instanceof Node) return this.appendChild(result)
  1784
+    name = d3.ns.qualify(result);
  1785
+    if (name.local) return appendNS.apply(this);
  1786
+    return append.apply(this);
  1787
+  }
  1788
+
  1789
+  return this.select(
  1790
+      node        ? appendNode
  1791
+    : func        ? appendFunc
  1792
+    : name.local  ? appendNS
  1793
+    : append
  1794
+  );
1766 1795
 };
1767 1796
 // TODO insert(node, function)?
1768  
-// TODO insert(function, string)?
1769 1797
 // TODO insert(function, function)?
1770  
-d3_selectionPrototype.insert = function(name, before) {
1771  
-  name = d3.ns.qualify(name);
  1798
+d3_selectionPrototype.insert = function(element, before) {
  1799
+  var node, func, name
  1800
+
  1801
+  switch (true) {
  1802
+    case element instanceof Function:
  1803
+      func = element;
  1804
+      break;
  1805
+
  1806
+    case element instanceof Node:
  1807
+      node = element;
  1808
+      break;
  1809
+
  1810
+    default:
  1811
+      name = d3.ns.qualify(element);
  1812
+      break;
  1813
+  }
1772 1814
 
1773 1815
   function insert() {
1774 1816
     return this.insertBefore(
@@ -1782,7 +1824,29 @@ d3_selectionPrototype.insert = function(name, before) {
1782 1824
         d3_select(before, this));
1783 1825
   }
1784 1826
 
1785  
-  return this.select(name.local ? insertNS : insert);
  1827
+  function insertNode() {
  1828
+    return this.insertBefore(
  1829
+        node,
  1830
+        d3_select(before, this));
  1831
+  }
  1832
+
  1833
+  function insertFunc() {
  1834
+    var result = func.apply(this, arguments);
  1835
+    if (result instanceof Node) {
  1836
+      return this.insertBefore(result,
  1837
+          d3_select(before, this));
  1838
+    }
  1839
+    name = d3.ns.qualify(result);
  1840
+    if (name.local) return insertNS.apply(this);
  1841
+    return insert.apply(this);
  1842
+  }
  1843
+
  1844
+  return this.select(
  1845
+      node        ? insertNode
  1846
+    : func        ? insertFunc
  1847
+    : name.local  ? insertNS
  1848
+    : append
  1849
+  );
1786 1850
 };
1787 1851
 // TODO remove(selector)?
1788 1852
 // TODO remove(node)?
8  d3.v2.min.js
4 additions, 4 deletions not shown
39  src/core/selection-append.js
... ...
@@ -1,7 +1,19 @@
1  
-// TODO append(node)?
2  
-// TODO append(function)?
3  
-d3_selectionPrototype.append = function(name) {
4  
-  name = d3.ns.qualify(name);
  1
+d3_selectionPrototype.append = function(element) {
  2
+  var node, func, name
  3
+
  4
+  switch (true) {
  5
+    case element instanceof Function:
  6
+      func = element;
  7
+      break;
  8
+
  9
+    case element instanceof Node:
  10
+      node = element;
  11
+      break;
  12
+
  13
+    default:
  14
+      name = d3.ns.qualify(element);
  15
+      break;
  16
+  }
5 17
 
6 18
   function append() {
7 19
     return this.appendChild(document.createElementNS(this.namespaceURI, name));
@@ -11,5 +23,22 @@ d3_selectionPrototype.append = function(name) {
11 23
     return this.appendChild(document.createElementNS(name.space, name.local));
12 24
   }
13 25
 
14  
-  return this.select(name.local ? appendNS : append);
  26
+  function appendNode() {
  27
+    return this.appendChild(node);
  28
+  }
  29
+
  30
+  function appendFunc() {
  31
+    var result = func.apply(this, arguments);
  32
+    if (result instanceof Node) return this.appendChild(result)
  33
+    name = d3.ns.qualify(result);
  34
+    if (name.local) return appendNS.apply(this);
  35
+    return append.apply(this);
  36
+  }
  37
+
  38
+  return this.select(
  39
+      node        ? appendNode
  40
+    : func        ? appendFunc
  41
+    : name.local  ? appendNS
  42
+    : append
  43
+  );
15 44
 };
43  src/core/selection-insert.js
... ...
@@ -1,8 +1,21 @@
1 1
 // TODO insert(node, function)?
2  
-// TODO insert(function, string)?
3 2
 // TODO insert(function, function)?
4  
-d3_selectionPrototype.insert = function(name, before) {
5  
-  name = d3.ns.qualify(name);
  3
+d3_selectionPrototype.insert = function(element, before) {
  4
+  var node, func, name
  5
+
  6
+  switch (true) {
  7
+    case element instanceof Function:
  8
+      func = element;
  9
+      break;
  10
+
  11
+    case element instanceof Node:
  12
+      node = element;
  13
+      break;
  14
+
  15
+    default:
  16
+      name = d3.ns.qualify(element);
  17
+      break;
  18
+  }
6 19
 
7 20
   function insert() {
8 21
     return this.insertBefore(
@@ -16,5 +29,27 @@ d3_selectionPrototype.insert = function(name, before) {
16 29
         d3_select(before, this));
17 30
   }
18 31
 
19  
-  return this.select(name.local ? insertNS : insert);
  32
+  function insertNode() {
  33
+    return this.insertBefore(
  34
+        node,
  35
+        d3_select(before, this));
  36
+  }
  37
+
  38
+  function insertFunc() {
  39
+    var result = func.apply(this, arguments);
  40
+    if (result instanceof Node) {
  41
+      return this.insertBefore(result,
  42
+          d3_select(before, this));
  43
+    }
  44
+    name = d3.ns.qualify(result);
  45
+    if (name.local) return insertNS.apply(this);
  46
+    return insert.apply(this);
  47
+  }
  48
+
  49
+  return this.select(
  50
+      node        ? insertNode
  51
+    : func        ? insertFunc
  52
+    : name.local  ? insertNS
  53
+    : append
  54
+  );
20 55
 };
Commit_comment_tip

Tip: You can add notes to lines in a file. Hover to the left of a line to make a note

Something went wrong with that request. Please try again.