nested_array

πŸŽ‰ Congratulations! Version 3.0 has been released.

Gem nested_array allows you to convert a flat data array with a tree structure into a nested array. It also helps to display trees by forming HTML layout or pseudo-graphics.

The tree structure must be described using the Adjacency List pattern, that is, each node has an ancestor.

Select language README.md

Table of contents

Installation ↑

  1. Add a line to your application's Gemfile:
# Working with tree arrays
gem "nested_array", "~> 3.0"

And do bundle install.

  1. If you plan to use modest CSS gem styles, add to the app/assets/stylesheets/application.scss file:
/* Displaying Tree Arrays */
@import "nested_array";

Usage ↑

Converting data using the .to_nested method ↑

Source data – hash array ↑

Let's say we have a hash array:

flat = [
  {'id' => 3, 'parent_id' => nil},
  {'id' => 2, 'parent_id' => 1},
  {'id' => 1, 'parent_id' => nil}
]

Where each hash is a tree node, id is the node identifier, parent_id is a pointer to the parent node.

It is necessary to convert it into an array in which there will be root nodes ('parent_id' => nil), and child nodes placed in the children field.

nested = flat.to_nested
puts nested.pretty_inspect

This will output:

[#<OpenStruct id=3, parent_id=nil, level=0, origin={"id"=>3, "parent_id"=>nil}>,
 #<OpenStruct id=1, parent_id=nil, level=0, children=[#<OpenStruct id=2, parent_id=1, level=1, origin={"id"=>2, "parent_id"=>1}>], origin={"id"=>1, "parent_id"=>nil}>]

As a result, nodes are OpenStruct objects with initial fields id, parent_id and additional fields level, origin and children.

ActiveRecord objects can also serve as source nodes.

Source data – ActiveRecord array ↑

catalogs = Catalog.all.to_a
nested = catalogs.to_nested
puts nested.pretty_inspect

This will output:

[
  #<OpenStruct id=1, parent_id=nil, level=0, origin=#<Catalog id: 1, name: "Computer Components", parent_id: nil>, children=[
    #<OpenStruct id=11, parent_id=1, level=1, origin=#<Catalog id: 11, name: "External Components", parent_id: 1>, children=[
      #<OpenStruct id=111, parent_id=11, level=2, origin=#<Catalog id: 111, name: "Hard Drives", parent_id: 11>>,
      #<OpenStruct id=112, parent_id=11, level=2, origin=#<Catalog id: 112, name: "Sound Cards", parent_id: 11>>,
      #<OpenStruct id=113, parent_id=11, level=2, origin=#<Catalog id: 113, name: "KVM Switches", parent_id: 11>>,
      #<OpenStruct id=114, parent_id=11, level=2, origin=#<Catalog id: 114, name: "Optical Drives", parent_id: 11>>
    ]>,
    #<OpenStruct id=12, parent_id=1, level=1, origin=#<Catalog id: 12, name: "Internal Components", parent_id: 1>>
  ]>,
  #<OpenStruct id=2, parent_id=nil, level=0, origin=#<Catalog id: 2, name: "Monitors", parent_id: nil>>,
  #<OpenStruct id=3, parent_id=nil, level=0, origin=#<Catalog id: 3, name: "Servers", parent_id: nil>>,
  #<OpenStruct id=4, parent_id=nil, level=0, origin=#<Catalog id: 4, name: "Networking Products", parent_id: nil>>
]

The .to_nested method uses the object.serializable_hash method to get a list of the object's fields.

.to_nested method options ↑

root_id: id ↑

root_id: 1 β€” take children of node with id equal to 1.

<% catalogs_of_1 = Catalog.all.to_a.to_nested(root_id: 1) %>
<ul>
  <% catalogs_of_1.each_nested do |node, origin| %>
    <%= node.before -%>
    <%= origin.name -%> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.after -%>
  <% end %>
</ul>

Will output a multi-level bulleted list of descendants of node #1:

Screenshot

branch_id: id ↑

branch_id: 1 β€” take the node with id equal to 1 and all its descendants.

<% catalogs_from_1 = Catalog.all.to_a.to_nested(branch_id: 1) %>
<ul>
  <% catalogs_from_1.each_nested do |node, origin| %>
    <%= node.before -%>
    <%= origin.name -%> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.after -%>
  <% end %>
</ul>

Will output node #1 and its descendants:

Screenshot

Displaying tree structures ↑

As multi-level lists ↑

Bulleted and numbered lists <ul>, <ol> ↑
<ul>
  <% @catalogs.to_a.to_nested.each_nested do |node, origin| %>
    <%= node.before %>
    <%= link_to origin.name, origin %> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.after %>
  <% end %>
</ul>

<ol>
  <% @catalogs.to_a.to_nested.each_nested ul: '<ol>', _ul: '</ol>' do |node, origin| %>
    <%= node.before %>
    <%= link_to origin.name, origin %> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.after %>
  <% end %>
</ol>

Screenshot

Using your own templates to display a list ↑

Instead of <ul><li>/<ol><li>

<% content_for :head do %>
  <style>
    /* Vertical node padding */
    div.li { margin: .5em 0; }
    /* Level indentation (children) */
    div.ul { margin-left: 2em; }
  </style>
<% end %>

<div class="ul">
  <%# Overriding opening and closing template tags. %>
  <% @catalogs.to_a.to_nested.each_nested(
    ul: '<div class="ul">',
    _ul: '</div>',
    li: '<div class="li">',
    _li: '</div>'
  ) do |node, origin| %>
    <%= node.before -%>
    <%= origin.name -%> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.after -%>
  <% end %>
</div>

Screenshot

Changing the template depending on node data ↑

To change the output patterns depending on the node data, we can check the node fields node.li and node.ul. If the fields are not empty, then instead of displaying their contents, substitute your own dynamic html.

Output of available node templates (node.li, node.ul and node._):

<ul>
  <% @catalogs.to_a.to_nested.each_nested do |node, origin| %>
    <%= node.li -%>
    <%= origin.name -%> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.ul -%>
    <%= node._ -%>
  <% end %>
</ul>

Screenshot

Replacing templates with dynamic html:

<% content_for :head do %>
  <style>
    li.level-0 {color: red;}
    li.level-1 {color: green;}
    li.level-2 {color: blue;}
    li.has_children {font-weight: bold;}
    ul.big {border: solid 1px gray;}
  </style>
<% end %>

<ul>
  <% @catalogs.to_a.to_nested.each_nested do |node, origin| %>
    <li class="level-<%= node.level %> <%= 'has_children' if node.is_has_children %>">
    <%= origin.name %> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <% if node.ul.present? %>
      <ul class="<%= 'big' if node.children.length > 2 %>">
    <% end %>
    <%= node._ -%>
  <% end %>
</ul>

Screenshot

It's worth noting that the node.li field is always present in a node, unlike node.ul.

<ul class="nested_array-details">
  <% @catalogs.to_a.to_nested.each_nested details: true do |node, origin| %>
    <%= node.before %>
    <%= origin.name %> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.after %>
  <% end %>
</ul>

Screenshot

By default, sublevels are hidden; you can control the display of sublevels by passing an option to the node method: node.after(open: ...):

<ul class="nested_array-details">
  <% @catalogs.to_a.to_nested.each_nested details: true do |node, origin| %>
    <%= node.before %>
    <%= origin.name %> <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
    <%= node.after(open: node.is_has_children) %>
  <% end %>
</ul>

Screenshot

Formation and output of your own templates based on changing the node level node.level ↑
<% content_for :head do %>
  <style>
    div.children {margin-left: 1em;}
    div.node {position: relative;}
    div.node::before {
      position: absolute;
      content: "";
      width: 0px;
      height: 0px;
      border-top: 5px solid transparent;
      border-bottom: 5px solid transparent;
      border-left: 8.66px solid red;
      left: -9px;
      top: 3px;
    }
  </style>
<% end %>

<div class="children">
  <% prev_level = nil %>
  <% @catalogs.to_a.to_nested.each_nested do |node, origin| %>

    <%# Has the level increased? - open the sublevel. %>
    <% if prev_level.present? && prev_level < node.level %>
      <div class="children">
    <% end %>

    <%# Same level? β€” we simply close the previous one. %>
    <% if prev_level.present? && prev_level == node.level %>
      </div>
    <% end %>

    <%# Has the level dropped? - closing the previous one is difficult. %>
    <% if prev_level.present? && prev_level > node.level %>
      <% (prev_level - node.level).times do |t| %>
        </div>
        </div>
      <% end %>
      </div>
    <% end %>

    <%# Our node. %>
    <div class="node">
    <%= origin.name %>

    <% prev_level = node.level %>
  <% end %>

  <%# Taking into account the previous level when exiting the cycle (Level has decreased). %>
  <% if !prev_level.nil? %>
    <% prev_level.times do |t| %>
      </div>
      </div>
    <% end %>
    </div>
  <% end %>
</div>

Screenshot

Pseudo-graphic output ↑

Adding pseudo-graphics before the model name using the nested_to_options method ↑
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id) %>
<pre><code><%= options.pluck(0).join($/) %>
</code></pre>

Screenshot

Thin pseudographics ↑
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id, thin_pseudographic: true) %>
<pre><code><%= options.pluck(0).join($/) %>
</code></pre>

Screenshot

Own pseudographics ↑
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id, pseudographics: %w(┬ ─ ❇ β”œ β”” &nbsp; β”‚)) %>
<pre><code><%= options.pluck(0).join($/).html_safe %>
</code></pre>

Screenshot

Increase indentation in own pseudographics ↑
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id, pseudographics: ['─┬', '──', '─&nbsp;', '&nbsp;β”œ', '&nbsp;β””', '&nbsp;&nbsp;', '&nbsp;β”‚']) %>
<pre><code><%= options.pluck(0).join($/).html_safe %>
</code></pre>

Screenshot

In html forms ↑

With the form.select helper ↑

<%= form_with(model: Catalog.find(11), url: root_path, method: :get) do |form| %>
  <%= form.select :parent_id,
    @catalogs.to_a.to_nested.nested_to_options(:name, :id),
    {
      include_blank: 'None'
    },
    {
      multiple: false,
      size: 11,
      class: 'form-select form-select-sm nested_array-select'
    }
  %>
<% end %>

Screenshot

With form.select and options_for_select helpers ↑

<%= form_with(model: Catalog.find(11), url: root_path, method: :get) do |form| %>
  <%= form.select :parent_id,
    options_for_select(
      @catalogs.to_a.to_nested.nested_to_options(:name, :id).unshift(['None', '']),
      selected: form.object.parent_id.to_s
    ),
    {
    },
    {
      multiple: false,
      size: 11,
      class: 'nested_array-select'
    }
  %>
<% end %>

Screenshot

<%= form_with(model: nil, url: root_path, method: :get) do |form| %>
  <ul class="nested_array-details">
    <% @catalogs.to_a.to_nested.each_nested details: true do |node, origin| %>
      <%= node.before %>
      <%= form.radio_button :parent_id, origin.id %>
      <%= form.label :parent_id, origin.name, value: origin.id %>
      <small>[<%= origin.id %>, <%= origin.parent_id || :nil %>, <%= node.level %>]</small>
      <%= node.after(open: node.is_has_children) %>
    <% end %>
  </ul>
<% end %>

Screenshot

Development

To connect the local version of the gem, replace the second argument(version) in the connection line (Gemfile file) with the path option:

# Working with tree arrays
gem "nested_array", path: "../nested_array"