nested_array
🎉 Мои поздравления! Вышла версия 3.0.
Гем nested_array позволяет преобразовать плоский массив данных древовидной
структуры во вложенный массив, а так же помогает отобразить деревья формируя
HTML вёрстку или псевдографику.
Древовидная структура должна быть описана по шаблону Списка смежности (Adjacency List), то есть в каждом узле указан предок.
Выбрать язык README.md
Оглавление
- Установка
- Использование
Установка ↑
- Добавте строку в файл Gemfile вашего приложения:
# Работа с древовидными массивами
gem "nested_array", "~> 3.0"
И выполните bundle install.
- Если вы планируете использовать скромные 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:

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 и его потомков:

Отображение древовидных структур ↑
В виде многоуровневых списков ↑
Маркированный и нумерованный списки <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>

Использование собственных шаблонов для отображения списка ↑
Вместо <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>

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

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

Стоит отметить, что поле 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>

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

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

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

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

Собственная певдографика ↑
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id, pseudographics: %w(┬ ─ ❇ ├ └ │)) %>
<pre><code><%= options.pluck(0).join($/).html_safe %>
</code></pre>

Увеличение отступа в собственной псевдографике ↑
<% options = @catalogs.to_a.to_nested.nested_to_options(:name, :id, pseudographics: ['─┬', '──', '─ ', ' ├', ' └', ' ', ' │']) %>
<pre><code><%= options.pluck(0).join($/).html_safe %>
</code></pre>

В формах ↑
С хелпером 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 %>

С хелперами 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 %>

Раскрывающийся список с переключателями 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 %>

Разработка
Часто используемые команды
# rspec управляет загружаемыми гемами, поэтому сам rspec запускается НЕ `bundle
# exec rspec`, а просто `rspec` или `./bin/rspec`.
rspec ./spec/lib/nested_array_spec.rb
rspec ./spec/lib/nested_array/nested_spec.rb
rspec # Прогон тестов
subl lib/nested_array/version.rb # Обновление версии
bundle exec yard doc # Обновление документации в doc/_index.html
git … # Git-фиксация в origin/master и тег
gem build # Сборка гема
gem push ./nested_array-… # Публикация гема
Для подключения локальной версии гема в rails замените в строке подключения (файл Gemfile) второй аргумент (версию) на опцию path:
# Gemfile
# Работа с древовидными массивами
gem "nested_array", path: "../nested_array"