nested_array

🎉 Мои поздравления! Вышла версия 3.0.

Гем nested_array позволяет преобразовать плоский массив данных древовидной структуры во вложенный массив, а так же помогает отобразить деревья формируя HTML вёрстку или псевдографику.

Древовидная структура должна быть описана по шаблону Списка смежности (Adjacency List), то есть в каждом узле указан предок.

Выбрать язык README.md

Оглавление

Установка

  1. Добавте строку в файл Gemfile вашего приложения:
# Работа с древовидными массивами
gem "nested_array", "~> 3.0"

И выполните bundle install.

  1. Если вы планируете использовать скромные CSS стили гема, добавте в файл app/assets/stylesheets/application.scss:
/* Отображение древовидных массивов */
@import "nested_array";

Использование

Преобразование данных методом .to_nested

Исходные данные – массив хэш

Допустим, есть массив хэш:

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

Где каждый хэш это узел дерева, id — идентификатор узла, parent_id — указатель на родительский узел.

Необходимо преобразовать в массив в котором будут только корневые узлы ('parent_id' => nil), а дочерние узлы помещены в поле children.

nested = flat.to_nested
puts nested.pretty_inspect

Выведет:

[#<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}>]

В результате узлы представляют собой объекты OpenStruct у которых исходные поля id, parent_id и дополнительные поля level, origin и children.

В качестве исходных узлов могут быть и объекты ActiveRecord.

Исходные данные – массив ActiveRecord

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

Выведет:

[
  #<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>>
]

Метод .to_nested использует метод object.serializable_hash, чтобы получить список полей объекта.

Опции метода .to_nested

root_id: id

root_id: 1 — взять потомков узла с id равным 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>

Выведет многоуровневый маркированный список потомков узла №1:

Screenshot

branch_id: id

branch_id: 1 — взять узел с id равным 1 и всех его потомков.

<% 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>

Выведет узел №1 и его потомков:

Screenshot

Отображение древовидных структур

В виде многоуровневых списков

Маркированный и нумерованный списки <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

Использование собственных шаблонов для отображения списка

Вместо <ul><li>/<ol><li>

<% content_for :head do %>
  <style>
    /* Вертикальные отступы узла */
    div.li { margin: .5em 0; }
    /* Отступ уровней (children) */
    div.ul { margin-left: 2em; }
  </style>
<% end %>

<div class="ul">
  <%# Переопределение открывающих и закрывающих тегов шаблонов. %>
  <% @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

Изменение шаблона в зависимости от данных узла

Для изменения шаблонов вывода в зависимости от данных узла мы можем проверять поля узла node.li и node.ul. Если поля не пустые, то вместо вывода их содержимого подставлять собственный динамичный html.

Вывод имеющихся шаблонов узла (node.li, node.ul и 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

Замена шаблонов на динамический 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

Стоит отметить, что поле node.li всегда присутствует в узле, в отличие от node.ul.

Расскрывающийся список на основе тега <details></details>
<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

По умолчанию подуровни скрыты, можно управлять отображением подуровней передавая опцию в метод узла: 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

Формирование и вывод собственных шаблонов опираясь на изменение уровня узла 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| %>

    <%# Уровень повысился? — открываем подуровень. %>
    <% if prev_level.present? && prev_level < node.level %>
      <div class="children">
    <% end %>

    <%# Уровень тот же? — предыдущий закрываем просто. %>
    <% if prev_level.present? && prev_level == node.level %>
      </div>
    <% end %>

    <%# Уровень понизился? - предыдущий закрываем сложно. %>
    <% if prev_level.present? && prev_level > node.level %>
      <% (prev_level - node.level).times do |t| %>
        </div>
        </div>
      <% end %>
      </div>
    <% end %>

    <%# Наш узел. %>
    <div class="node">
    <%= origin.name %>

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

  <%# Учёт предыдущего уровня при выходе из цикла (Уровень понизился). %>
  <% if !prev_level.nil? %>
    <% prev_level.times do |t| %>
      </div>
      </div>
    <% end %>
    </div>
  <% end %>
</div>

Screenshot

В виде псевдографики

Добавление псевдографики перед именем модели методом nested_to_options
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id) %>
<pre><code><%= options.pluck(0).join($/) %>
</code></pre>

Screenshot

Тонкая псевдографика
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id, thin_pseudographic: true) %>
<pre><code><%= options.pluck(0).join($/) %>
</code></pre>

Screenshot

Собственная певдографика
<% 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

Увеличение отступа в собственной псевдографике
<% 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

В формах

С хелпером form.select

<%= 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

С хелперами form.select и options_for_select

<%= 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.radio_button

<%= 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

Разработка

Для подключения локальной версии гема замените в строке подключения (файл Gemfile) второй аргумент (версию) на опцию path:

# Gemfile
# Работа с древовидными массивами
gem "nested_array", path: "../nested_array"

Часто используемые команды