Nested map to HTML table with rowspans

I just had the surprisingly interesting task to create a HTML table with rowspans from a nested map.
The challenge is, that the table rows can’t be nested, but have to be flat, and you have to know at the start of a span how large it is.

I came up with a solution, but find it a little odd and I’m wondering how you would tackle this.

foos = [...] # see below

for foo <- foos do
  [head | tail] =
    for bar <- foo.bars do
      [head | tail] =
        for foo_bar <- bar.foo_bars do
          %{
            foo: %{text: foo.text, span: nil},
            bar: %{text: bar.text, span: nil},
            foo_bar: %{text: foo_bar.text, span: 1}
          }
        end
        |> List.flatten()

      [put_in(head, [:bar, :span], length(tail) + 1)] ++ tail
    end
    |> List.flatten()

  [put_in(head, [:foo, :span], length(tail) + 1)] ++ tail
end
|> List.flatten()
|> Enum.map(&clean_spans/1)

defp clean_spans(enum) do
  Enum.filter(enum, fn {_, v} -> v.span end)
end

results in:

[
  [
    bar: %{span: 4, text: "Bar 1-1"},
    foo: %{span: 6, text: "Foo 1"},
    foo_bar: %{span: 1, text: "FooBar 1-1-1"}
  ],
  [foo_bar: %{span: 1, text: "FooBar 1-1-2"}],
  [foo_bar: %{span: 1, text: "FooBar 1-1-3"}],
  [foo_bar: %{span: 1, text: "FooBar 1-1-4"}],
  [
    bar: %{span: 2, text: "Bar 1-2"},
    foo_bar: %{span: 1, text: "FooBar 1-2-1"}
  ],
  [foo_bar: %{span: 1, text: "FooBar 1-2-2"}],
  [
    bar: %{span: 1, text: "Bar 2-1"},
    foo: %{span: 4, text: "Foo 2"},
    foo_bar: %{span: 1, text: "FooBar 2-1-1"}
  ],
  [
    bar: %{span: 3, text: "Bar 2-2"},
    foo_bar: %{span: 1, text: "FooBar 2-2-1"}
  ],
  [foo_bar: %{span: 1, text: "FooBar 2-2-2"}],
  [foo_bar: %{span: 1, text: "FooBar 2-2-3"}]
]

with this data:

%{
    text: "Foo 1",
    bars: [
      %{
        text: "Bar 1-1",
        foo_bars: [
          %{text: "FooBar 1-1-1"},
          %{text: "FooBar 1-1-2"},
          %{text: "FooBar 1-1-3"},
          %{text: "FooBar 1-1-4"}
        ]
      },
      %{
        text: "Bar 1-2",
        foo_bars: [
          %{text: "FooBar 1-2-1"},
          %{text: "FooBar 1-2-2"}
        ]
      }
    ]
  },
  %{
    text: "Foo 2",
    bars: [
      %{
        text: "Bar 2-1",
        foo_bars: [
          %{text: "FooBar 2-1-1"}
        ]
      },
      %{
        text: "Bar 2-2",
        foo_bars: [
          %{text: "FooBar 2-2-1"},
          %{text: "FooBar 2-2-2"},
          %{text: "FooBar 2-2-3"}
        ]
      }
    ]
  }
]

Since in your question you mention HTML table I’m giving a full example script using phoenix_html dependency to generate HTML code.

Mix.install([:phoenix_html])

defmodule Example do
  alias Phoenix.HTML
  alias Phoenix.HTML.Tag

  def sample(list), do: build_table(list)

  defp build_table(list) do
    :table
    |> Tag.content_tag do
      [build_thead(), build_tbody(list)]
    end
    |> HTML.safe_to_string()
  end

  defp build_thead do
    Tag.content_tag :thead do
      Tag.content_tag :tr do
        Enum.map(~w"Foos Bars FooBars", &Tag.content_tag(:th, &1))
      end
    end
  end

  defp build_tbody(list) do
    Tag.content_tag :tbody do
      Enum.map(list, &build_tbody_foo/1)
    end
  end

  defp build_tbody_foo(%{bars: bars, text: text}) do
    bars = update_in(bars, [Access.at(0), :foo_bars, Access.at(0)], &Map.put(&1, :add_foo, true))
    foo_span = count_foo_span(bars)
    Enum.map(bars, &build_tbody_bars(&1, text, foo_span))
  end

  defp build_tbody_bars(%{foo_bars: foo_bars, text: text}, foo_text, foo_span) do
    foo_bars = update_in(foo_bars, [Access.at(0)], &Map.put(&1, :add_bar, true))
    span = count_foo_bar_span(foo_bars)

    Enum.map(foo_bars, fn foo_bar ->
      contents = build_tbody_foo_bars(foo_bar, foo_text, foo_span, text, span)
      Tag.content_tag(:tr, contents)
    end)
  end

  defp build_tbody_foo_bars(%{add_foo: true} = foo_bar, foo_text, foo_span, bar_text, bar_span) do
    foo_bar = Map.delete(foo_bar, :add_foo)

    [
      Tag.content_tag(:td, foo_text, rowspan: foo_span),
      Tag.content_tag(:td, bar_text, rowspan: bar_span) | do_build_tbody_foo_bars(foo_bar)
    ]
  end

  defp build_tbody_foo_bars(%{add_bar: true} = foo_bar, _foo_text, _foo_span, bar_text, bar_span) do
    foo_bar = Map.delete(foo_bar, :add_bar)
    [Tag.content_tag(:td, bar_text, rowspan: bar_span) | do_build_tbody_foo_bars(foo_bar)]
  end

  defp build_tbody_foo_bars(foo_bar, _foo_text, _foo_span, _bar_text, _bar_span) do
    do_build_tbody_foo_bars(foo_bar)
  end

  defp do_build_tbody_foo_bars(%{text: text}) do
    Tag.content_tag(:td, text, rowspan: 1)
  end

  defp count_foo_span(bars) do
    count = bars |> Enum.map(&Enum.count(&1.foo_bars)) |> Enum.sum()
    (count == 0 && 1) || count
  end

  defp count_foo_bar_span(foo_bars) do
    count = Enum.count(foo_bars)
    (count == 0 && 1) || count
  end
end

input = [
  %{
    text: "Foo 1",
    bars: [
      %{
        text: "Bar 1-1",
        foo_bars: [
          %{text: "FooBar 1-1-1"},
          %{text: "FooBar 1-1-2"},
          %{text: "FooBar 1-1-3"},
          %{text: "FooBar 1-1-4"}
        ]
      },
      %{
        text: "Bar 1-2",
        foo_bars: [
          %{text: "FooBar 1-2-1"},
          %{text: "FooBar 1-2-2"}
        ]
      }
    ]
  },
  %{
    text: "Foo 2",
    bars: [
      %{
        text: "Bar 2-1",
        foo_bars: [
          %{text: "FooBar 2-1-1"}
        ]
      },
      %{
        text: "Bar 2-2",
        foo_bars: [
          %{text: "FooBar 2-2-1"},
          %{text: "FooBar 2-2-2"},
          %{text: "FooBar 2-2-3"}
        ]
      }
    ]
  }
]

Example.sample(input)
|> IO.puts()

Here is a result based on your input:

<table><thead><tr><th>Foos</th><th>Bars</th><th>FooBars</th></tr></thead><tbody><tr><td rowspan="6">Foo 1</td><td rowspan="4">Bar 1-1</td><td rowspan="1">FooBar 1-1-1</td></tr><tr><td rowspan="1">FooBar 1-1-2</td></tr><tr><td rowspan="1">FooBar 1-1-3</td></tr><tr><td rowspan="1">FooBar 1-1-4</td></tr><tr><td rowspan="2">Bar 1-2</td><td rowspan="1">FooBar 1-2-1</td></tr><tr><td rowspan="1">FooBar 1-2-2</td></tr><tr><td rowspan="4">Foo 2</td><td rowspan="1">Bar 2-1</td><td rowspan="1">FooBar 2-1-1</td></tr><tr><td rowspan="3">Bar 2-2</td><td rowspan="1">FooBar 2-2-1</td></tr><tr><td rowspan="1">FooBar 2-2-2</td></tr><tr><td rowspan="1">FooBar 2-2-3</td></tr></tbody></table>

here is its beautified version:

<table>
  <thead>
    <tr>
      <th>Foos</th>
      <th>Bars</th>
      <th>FooBars</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td rowspan="6">Foo 1</td>
      <td rowspan="4">Bar 1-1</td>
      <td rowspan="1">FooBar 1-1-1</td>
    </tr>
    <tr>
      <td rowspan="1">FooBar 1-1-2</td>
    </tr>
    <tr>
      <td rowspan="1">FooBar 1-1-3</td>
    </tr>
    <tr>
      <td rowspan="1">FooBar 1-1-4</td>
    </tr>
    <tr>
      <td rowspan="2">Bar 1-2</td>
      <td rowspan="1">FooBar 1-2-1</td>
    </tr>
    <tr>
      <td rowspan="1">FooBar 1-2-2</td>
    </tr>
    <tr>
      <td rowspan="4">Foo 2</td>
      <td rowspan="1">Bar 2-1</td>
      <td rowspan="1">FooBar 2-1-1</td>
    </tr>
    <tr>
      <td rowspan="3">Bar 2-2</td>
      <td rowspan="1">FooBar 2-2-1</td>
    </tr>
    <tr>
      <td rowspan="1">FooBar 2-2-2</td>
    </tr>
    <tr>
      <td rowspan="1">FooBar 2-2-3</td>
    </tr>
  </tbody>
</table>

and finally here is a simple fiddle for human friendly preview:

Ok, so what’s the difference?

  1. First of all I’m not building data ready to render, but collecting data when needed for rendering instead. With this you do not need to write another loop to render your data.
  2. I’m not using any extra flatten functions here. Look that nested iodata is handled already by Phoenix code. For readability I have called Phoenix.HTML.safe_to_string/1, so by removing this call you will have raw safe code.
  3. Because of 1st point I can have all spans and texts from foos and bars in separate variable i.e. I do not need to add those values to your input.
  4. The only thing I’m doing is to quickly put add_foo and add_bar boolean values, so I can use simple pattern matching on those and render foos and bars data only when needed (for first foos and bars rows respectively).
    Note: First foos row is also first bars row. Therefore I’m adding also bar cell when pattern matching on add_foo row.

Helpful resources:

  1. Phoenix.HTML.safe_to_string/1
  2. Phoenix.HTML.Tag.content_tag/2
  3. phoenix_html at hex.pm
2 Likes

This is great. Always wondered why one would use Phoenix.HTML.Tag. I’ll have to play with a little.