Ruby - Array of hashes - Nested HTML menu from has

2019-07-24 05:09发布

问题:

I have a relatively large hash where the values for all keys within are array of hashes (see below for sample layout). I have spent the last 4.5 hours attempting to write out HTML for my Rails application and I am seriously about to cry as I've gone round in circles with nothing really to show for it!

Any help is greatly appreciated and thank you in advance for your time!

Specific Problems Encountered

  1. Chapters/verses appear for books that they do not align to.
  2. I'm also not able to de-duplicate entries, so 'Chapter 4', for example, is appearing multiple times (instead of it appearing once, with mulitple chapter/verse references nested beneath)

Desired Solution Criteria

  1. The HTML needs to be written in 3 layers/nested (1st layer/div containing Book Name (e.g. Genesis, Exodus, Leviticus), 2nd layer/div containing chapter and 3rd layer/div containing verse)

  2. Chapters and verses must align (e.g. Exodus 2:7 should not be written to the Genesis chapter 2 menu).

  3. Book names should be written in the order of hsh's keys (e.g. Genesis followed by Exodus followed by Leviticus as opposed to alphabetical order)

Data Format:

hsh = { :genesis =>
             [
                 {:id => 1, :verse => 'Genesis 4:12'},
                 {:id => 1, :verse => 'Genesis 4:23-25'},
                 {:id => 2, :verse => 'Genesis 6:17'}
             ],
        :exodus =>
             [
                 {:id => 5, :verse => 'Exodus 2:7'},
                 {:id => 3, :verse => 'Exodus 2:14-15'},
                 {:id => 4, :verse => 'Exodus 12:16'}
             ],
        :leviticus =>
             [
                 {:id => 2, :verse => 'Leviticus 11:19-21'},
                 {:id => 7, :verse => 'Leviticus 15:14-31'},
                 {:id => 7, :verse => 'Leviticus 19:11-12'}
             ]
     }

Desired Output HTML [Shortened for Brevity]

<div class="submenu">
    <a href="#">Genesis</a>
    <div class="lvl-2">
        <div>
            <div class="submenu">
                <a>Chapter 4</a>
                <div class="lvl-3">
                    <div>
                        <a onclick="load('1')"><span>ID 1</span> Verse 12</a>
                        <a onclick="load('1')"><span>ID 1</span> Verse 23-25</a>
                    </div>
                </div>
            </div>
            <div class="submenu">
                <a>Chapter 6</a>
                <div class="lvl-3">
                    <div> <a onclick="load('2')"><span>ID 2</span> Verse 17</a> </div>
                </div>
            </div>
        </div>
    </div>
    <div class="submenu">
        <a href="#">Exodus</a>
        <div class="lvl-2">
            <div>
                <div class="submenu">
                    <a>Chapter 2</a>
                    <div class="lvl-3">
                        <div>
                            <a onclick="load('5')"><span>ID 5</span> Verse 7</a>
                            <a onclick="load('3')"><span>ID 3</span>Verse 14-15</a>
                        </div>
                    </div>
                </div>
                <div class="submenu">
                    <a>Chapter 12</a>
                    <div class="lvl-3">
                        <div>
                            <a onclick="load('4')"><span>ID 4</span> Verse 16</a>
                        </div>
                    </div>
                </div>
            </div>
        </div>
        ## Shortened for brevity (Leviticus references excluded)
    </div>
</div>

Code

final_html = String.new

hsh.each do |book, verse_array|

    verse_array.each do |reference|

      book = reference[:verse].split(' ').first           # => "Genesis"
      full_verse = reference[:verse].split(' ').last      # => "4:12"
      chapter = full_verse.split(':').first               # => "4"
      verse = full_verse.split(':').first                 # => "12"

      # Failing Miserably at appending the right HTML to the final_html string here...
      # final_html << "<div class=\"submenu\">"
      # final_html << ...
      # final_html << ...

    end
end

Finally, note that there are no duplicate chapter/verse combinations (e.g. Genesis 4:12 will never appear a second time). It's also worth noting that both chapters and verses need to be sorted numerically and ascending.

回答1:

As is often the case with such problems, it becomes much easier to solve once you've put the data into a "shape" that closely resembles your desired output. Your output is a nested tree structure, so your data should be, too. Let's do that first. Here's your data:

data = {
  genesis: [
    { id: 1, verse: "Genesis 4:12" },
    { id: 1, verse: "Genesis 4:23-25" },
    { id: 2, verse: "Genesis 6:17" }
  ],
  exodus: [
    { id: 5, verse: "Exodus 2:7" },
    # ...
  ],
  # ...
}

And, setting aside the HTML for now, here's the structure we want:

Genesis
  Chapter 4
    ID 1 - Verse 12
    ID 1 - Verse 23-25
  Chapter 6
    ID 2 - Verse 17
Exodus
  Chapter 2
    ID 5 - Verse 7
    ...
...

The first thing I notice about your data is that it has a level of nesting you don't need. Since the :verse values contain the book name, we don't the keys from the outer hash (:genesis et al) and we can just flatten all of the inner hashes into a single array:

data.values.flatten
# => [ { id: 1, verse: "Genesis 4:12" },
#      { id: 1, verse: "Genesis 4:23-25" },
#      { id: 2, verse: "Genesis 6:17" }
#      { id: 5, verse: "Exodus 2:7" },
#      # ... ]

Now we need a method to extract the book, chapter, and verse from the :verse strings. You can use String#split if you want, but a Regexp is a good choice too:

VERSE_EXPR = /(.+)\s+(\d+):(.+)$/

def parse_verse(str)
  m = str.match(VERSE_EXPR)
  raise "Invalid verse string!" if m.nil?
  book, chapter, verse = m.captures
  { book: book, chapter: chapter, verse: verse }
end

flat_data = data.values.flatten.map do |id:, verse:|
  { id: id }.merge(parse_verse(verse))
end
# => [ { id: 1, book: "Genesis", chapter: "4", verse: "12" },
#      { id: 1, book: "Genesis", chapter: "4", verse: "23-25" },
#      { id: 2, book: "Genesis", chapter: "6", verse: "17" },
#      { id: 5, book: "Exodus", chapter: "2", verse: "7" },
#      # ... ]

Now it's easy to group the data by book:

books = flat_data.group_by {|book:, **| book }
# => { "Genesis" => [
#        { id: 1, book: "Genesis", chapter: "4", verse: "12" },
#        { id: 1, book: "Genesis", chapter: "4", verse: "23-25" },
#        { id: 2, book: "Genesis", chapter: "6", verse: "17" }
#      ],
#      "Exodus" => [
#        { id: 5, book: "Exodus", chapter: "2", verse: "7" },
#        # ...
#      ],
#      # ...
#    }

...and within each book, by chapter:

books_chapters = books.map do |book, verses|
  [ book, verses.group_by {|chapter:, **| chapter } ]
end
# => [ [ "Genesis",
#        { "4" => [ { id: 1, book: "Genesis", chapter: "4", verse: "12" },
#                   { id: 1, book: "Genesis", chapter: "4", verse: "23-25" } ],
#          "6" => [ { id: 2, book: "Genesis", chapter: "6", verse: "17" } ]
#        }
#      ],
#      [ "Exodus",
#        { "2" => [ { id: 5, book: "Exodus", chapter: "2", verse: "7" },
#                   # ... ],
#          # ...
#        }
#      ],
#      # ...
#    ]

You'll notice that since we called map on books our final result is an Array, not a Hash. You could call to_h on it to make it a Hash again, but for our purposes it's not necessary (iterating over an Array of key-value pairs works the same as iterating over a Hash).

It looks a little messy, but you can see that the structure is there: Verses nested within chapters nested within books. Now we just need to turn it into HTML.

An aside, for the sake of our friends with disabilities: The correct HTML element to use for nested tree structures is <ul> or <ol>. If you have some requirement to use <div>s instead you can, but otherwise use the right element for the job—users who use accessibility devices will thank you. (Many articles have been written on styling such trees, so I won't go into it, but for a start you can hide the bullets with list-style-type: none;.)

I don't have a Rails app at my disposal, so to generate the HTML I'll just use ERB from the Ruby standard library. It will look more-or-less identical in Rails except for how you pass the variable to the view.

require "erb"

VIEW = <<END
<ul>
<% books.each do |book_name, chapters| %>
  <li>
    <a href="#"><%= book_name %></a>
    <ul>
    <% chapters.each do |chapter, verses| %>
      <li>
        <a href="#">Chapter <%= chapter %></a>
        <ul>
        <% verses.each do |id:, verse:, **| %>
          <li>
            <a onclick="load(<%= id %>)">ID <%= id %>: Verse <%= verse %></a>
          </li>
        <% end %>
        </ul>
      </li>
    <% end %>
    </ul>
  </li>
<% end %>
</ul>
END

def render(books)
   b = binding
   ERB.new(VIEW, nil, "<>-").result(b)
end

puts render(books_chapters)

And here's the result as an HTML snippet:

<ul>

  <li>
    <a href="#">Genesis</a>
    <ul>

      <li>
        <a href="#">Chapter 4</a>
        <ul>

          <li>
            <a onclick="load(1)">ID 1: Verse 12</a>
          </li>

          <li>
            <a onclick="load(1)">ID 1: Verse 23-25</a>
          </li>

        </ul>
      </li>

      <li>
        <a href="#">Chapter 6</a>
        <ul>

          <li>
            <a onclick="load(2)">ID 2: Verse 17</a>
          </li>

        </ul>
      </li>

    </ul>
  </li>

  <li>
    <a href="#">Exodus</a>
    <ul>

      <li>
        <a href="#">Chapter 2</a>
        <ul>

          <li>
            <a onclick="load(5)">ID 5: Verse 7</a>
          </li>

          <li>
            <a onclick="load(3)">ID 3: Verse 14-15</a>
          </li>

        </ul>
      </li>

      <li>
        <a href="#">Chapter 12</a>
        <ul>

          <li>
            <a onclick="load(4)">ID 4: Verse 16</a>
          </li>

        </ul>
      </li>

    </ul>
  </li>

  <li>
    <a href="#">Leviticus</a>
    <ul>

      <li>
        <a href="#">Chapter 11</a>
        <ul>

          <li>
            <a onclick="load(2)">ID 2: Verse 19-21</a>
          </li>

        </ul>
      </li>

      <li>
        <a href="#">Chapter 15</a>
        <ul>

          <li>
            <a onclick="load(7)">ID 7: Verse 14-31</a>
          </li>

        </ul>
      </li>

      <li>
        <a href="#">Chapter 19</a>
        <ul>

          <li>
            <a onclick="load(7)">ID 7: Verse 11-12</a>
          </li>

        </ul>
      </li>

    </ul>
  </li>

</ul>

Finally, here it is in action on repl.it: https://repl.it/Duhm