Repository: livebook-dev/kino (elixir)

Issue:
Support structs in data_table
We should not show fields starting with underscore by default. We could add a `show_underscored: true` if someone wants to disable this behaviour. This will allow us to cleanly handle things like `__struct__` and `__meta__` fields.

Pull request description:
Support structs in DataTable
Closes #32.

![image](https://user-images.githubusercontent.com/17034772/126506578-c588eb6d-6d9f-4b0d-92af-30d1cd2454ff.png)


Patch:
diff --git a/lib/kino/data_table.ex b/lib/kino/data_table.ex
index c61b109..ccc716a 100644
--- a/lib/kino/data_table.ex
+++ b/lib/kino/data_table.ex
@@ -3,8 +3,7 @@ defmodule Kino.DataTable do
   A widget for interactively viewing enumerable data.
 
   The data must be an enumerable of records, where each
-  record is either map, keyword list or tuple. Structs
-  however must be manually converted to maps.
+  record is either map, struct, keyword list or tuple.
 
   ## Examples
 
@@ -52,6 +51,9 @@ defmodule Kino.DataTable do
       Sorting requires traversal of the whole enumerable, so it may not be
       desirable for lazy enumerables. Defaults to `true` if data is a list
       and `false` otherwise.
+
+    * `:show_underscored` - whether to include record keys starting with underscore.
+      This option is ignored if `:keys` is also given. Defaults to `false`.
   """
   @spec new(Enum.t(), keyword()) :: t()
   def new(data, opts \\ []) do
@@ -60,7 +62,15 @@ defmodule Kino.DataTable do
     parent = self()
     keys = opts[:keys]
     sorting_enabled = Keyword.get(opts, :sorting_enabled, is_list(data))
-    opts = [data: data, parent: parent, keys: keys, sorting_enabled: sorting_enabled]
+    show_underscored = Keyword.get(opts, :show_underscored, false)
+
+    opts = [
+      data: data,
+      parent: parent,
+      keys: keys,
+      sorting_enabled: sorting_enabled,
+      show_underscored: show_underscored
+    ]
 
     {:ok, pid} = DynamicSupervisor.start_child(Kino.WidgetSupervisor, {__MODULE__, opts})
 
@@ -75,13 +85,9 @@ defmodule Kino.DataTable do
   defp validate_data!(data) when is_list(data) do
     Enum.reduce(data, nil, fn record, type ->
       case record_type(record) do
-        :struct ->
-          raise ArgumentError,
-                "struct records are not supported, you need to convert them to maps explicitly"
-
         :other ->
           raise ArgumentError,
-                "expected record to be either map, tuple or keyword list, got: #{inspect(record)}"
+                "expected record to be either map, struct, tuple or keyword list, got: #{inspect(record)}"
 
         first_type when type == nil ->
           first_type
@@ -119,6 +125,7 @@ defmodule Kino.DataTable do
     parent = Keyword.fetch!(opts, :parent)
     keys = Keyword.fetch!(opts, :keys)
     sorting_enabled = Keyword.fetch!(opts, :sorting_enabled)
+    show_underscored = Keyword.fetch!(opts, :show_underscored)
 
     parent_monitor_ref = Process.monitor(parent)
 
@@ -130,7 +137,8 @@ defmodule Kino.DataTable do
        data: data,
        total_rows: total_rows,
        keys: keys,
-       sorting_enabled: sorting_enabled
+       sorting_enabled: sorting_enabled,
+       show_underscored: show_underscored
      }}
   end
 
@@ -161,6 +169,12 @@ defmodule Kino.DataTable do
         {:initial, state.keys}
       else
         columns = columns_structure(records)
+
+        columns =
+          if state.show_underscored,
+            do: columns,
+            else: Enum.reject(columns, &underscored?(&1.key))
+
         keys = Enum.map(columns, & &1.key)
         {columns, keys}
       end
@@ -246,10 +260,14 @@ defmodule Kino.DataTable do
     end
   end
 
-  defp get_field(record, key) when is_map(record) or is_list(record) do
+  defp get_field(record, key) when is_list(record) do
     record[key]
   end
 
+  defp get_field(record, key) when is_map(record) do
+    Map.get(record, key)
+  end
+
   defp record_to_row(record, keys) do
     fields =
       Map.new(keys, fn key ->
@@ -260,4 +278,10 @@ defmodule Kino.DataTable do
     # Note: id is opaque to the client, and we don't need it for now
     %{id: nil, fields: fields}
   end
+
+  defp underscored?(key) when is_atom(key) do
+    key |> Atom.to_string() |> String.starts_with?("_")
+  end
+
+  defp underscored?(_key), do: false
 end


Test patch:
diff --git a/test/kino/data_table_test.exs b/test/kino/data_table_test.exs
index 4c84ad6..3dd180d 100644
--- a/test/kino/data_table_test.exs
+++ b/test/kino/data_table_test.exs
@@ -2,20 +2,9 @@ defmodule Kino.DataTableTest do
   use ExUnit.Case, async: true
 
   describe "new/1" do
-    test "raises an error when structs are given" do
-      assert_raise ArgumentError,
-                   "struct records are not supported, you need to convert them to maps explicitly",
-                   fn ->
-                     Kino.DataTable.new([
-                       URI.parse("https://elixir-lang.org"),
-                       URI.parse("https://www.erlang.org")
-                     ])
-                   end
-    end
-
     test "raises an error when records have invalid data type" do
       assert_raise ArgumentError,
-                   "expected record to be either map, tuple or keyword list, got: \"value\"",
+                   "expected record to be either map, struct, tuple or keyword list, got: \"value\"",
                    fn ->
                      Kino.DataTable.new(["value"])
                    end
@@ -128,6 +117,52 @@ defmodule Kino.DataTableTest do
                       }}
     end
 
+    defmodule User do
+      defstruct [:__meta__, :id, :name]
+    end
+
+    test "columns don't include underscored attributes by default" do
+      data = [
+        %User{id: 1, name: "Sherlock Holmes"},
+        %User{id: 2, name: "John Watson"}
+      ]
+
+      widget = Kino.DataTable.new(data)
+      connect_self(widget)
+
+      send(widget.pid, {:get_rows, self(), @default_rows_spec})
+
+      assert_receive {:rows,
+                      %{
+                        columns: [
+                          %{key: :id, label: ":id"},
+                          %{key: :name, label: ":name"}
+                        ]
+                      }}
+    end
+
+    test "columns include underscored attributes if the :show_underscored option is set" do
+      data = [
+        %User{id: 1, name: "Sherlock Holmes"},
+        %User{id: 2, name: "John Watson"}
+      ]
+
+      widget = Kino.DataTable.new(data, show_underscored: true)
+      connect_self(widget)
+
+      send(widget.pid, {:get_rows, self(), @default_rows_spec})
+
+      assert_receive {:rows,
+                      %{
+                        columns: [
+                          %{key: :__meta__, label: ":__meta__"},
+                          %{key: :__struct__, label: ":__struct__"},
+                          %{key: :id, label: ":id"},
+                          %{key: :name, label: ":name"}
+                        ]
+                      }}
+    end
+
     test "columns accommodate for the longest record when records are tuples of mixed length" do
       data = [
         {1, "Sherlock Holmes", 100},