All that’s left from our design is to create the formatted table. The following would be a nice interface:
| def process({user, project, count}) do |
| Issues.GithubIssues.fetch(user, project) |
| |> decode_response() |
| |> sort_into_ascending_order() |
| |> last(count) |
» | |> print_table_for_columns(["number", "created_at", "title"]) |
| end |
We pass the formatter the list of columns to include in the table, and it writes the table to standard output. The formatter doesn’t add any new project- or design-related techniques, so we’ll just show the listing.
| defmodule Issues.TableFormatter do |
| |
| import Enum, only: [ each: 2, map: 2, map_join: 3, max: 1 ] |
| |
| def print_table_for_columns(rows, headers) do |
| with data_by_columns = split_into_columns(rows, headers), |
| column_widths = widths_of(data_by_columns), |
| format = format_for(column_widths) |
| do |
| puts_one_line_in_columns(headers, format) |
| IO.puts(separator(column_widths)) |
| puts_in_columns(data_by_columns, format) |
| end |
| end |
| |
| def split_into_columns(rows, headers) do |
| for header <- headers do |
| for row <- rows, do: printable(row[header]) |
| end |
| end |
| |
| def printable(str) when is_binary(str), do: str |
| def printable(str), do: to_string(str) |
| |
| def widths_of(columns) do |
| for column <- columns, do: column |> map(&String.length/1) |> max |
| end |
| |
| def format_for(column_widths) do |
| map_join(column_widths, " | ", fn width -> "~-#{width}s" end) <> "~n" |
| end |
| |
| def separator(column_widths) do |
| map_join(column_widths, "-+-", fn width -> List.duplicate("-", width) end) |
| end |
| |
| def puts_in_columns(data_by_columns, format) do |
| data_by_columns |
| |> List.zip |
| |> map(&Tuple.to_list/1) |
| |> each(&puts_one_line_in_columns(&1, format)) |
| end |
| |
| def puts_one_line_in_columns(fields, format) do |
| :io.format(format, fields) |
| end |
| end |
And here are the tests for it:
| defmodule TableFormatterTest do |
| use ExUnit.Case # bring in the test functionality |
| import ExUnit.CaptureIO # And allow us to capture stuff sent to stdout |
| |
| alias Issues.TableFormatter, as: TF |
| |
| @simple_test_data [ |
| [ c1: "r1 c1", c2: "r1 c2", c3: "r1 c3", c4: "r1+++c4" ], |
| [ c1: "r2 c1", c2: "r2 c2", c3: "r2 c3", c4: "r2 c4" ], |
| [ c1: "r3 c1", c2: "r3 c2", c3: "r3 c3", c4: "r3 c4" ], |
| [ c1: "r4 c1", c2: "r4++c2", c3: "r4 c3", c4: "r4 c4" ] |
| ] |
| |
| @headers [ :c1, :c2, :c4 ] |
| |
| def split_with_three_columns do |
| TF.split_into_columns(@simple_test_data, @headers) |
| end |
| |
| test "split_into_columns" do |
| columns = split_with_three_columns() |
| assert length(columns) == length(@headers) |
| assert List.first(columns) == ["r1 c1", "r2 c1", "r3 c1", "r4 c1"] |
| assert List.last(columns) == ["r1+++c4", "r2 c4", "r3 c4", "r4 c4"] |
| end |
| |
| test "column_widths" do |
| widths = TF.widths_of(split_with_three_columns()) |
| assert widths == [ 5, 6, 7 ] |
| end |
| |
| test "correct format string returned" do |
| assert TF.format_for([9, 10, 11]) == "~-9s | ~-10s | ~-11s~n" |
| end |
| |
| |
| test "Output is correct" do |
| result = capture_io fn -> |
| TF.print_table_for_columns(@simple_test_data, @headers) |
| end |
| assert result == """ |
| c1 | c2 | c4 |
| ------+--------+-------- |
| r1 c1 | r1 c2 | r1+++c4 |
| r2 c1 | r2 c2 | r2 c4 |
| r3 c1 | r3 c2 | r3 c4 |
| r4 c1 | r4++c2 | r4 c4 |
| """ |
| end |
| end |
(Although you can’t see it here, the output we compare against in the last test contains trailing whitespace.)
Rather than clutter the process function in the CLI module with a long module name, I chose to use import to make the print function available without a module qualifier. This goes near the top of cli.ex.
| defmodule Issues.CLI do |
| |
| import Issues.TableFormatter, only: [ print_table_for_columns: 2 ] |
This code also uses a great Elixir testing feature. By importing ExUnit.CaptureIO, we get access to the capture_io function. This runs the code passed to it but captures anything written to standard output, returning it as a string.