Repository: woylie/flop_phoenix
Pull request #229 (2023-07-08 22:32:29)

Feature: allow dynamic generation of `tr` and `td` attrs
# The What

This pull request enables the generation of dynamic tr and td attrs based on row data and column

  - allows consumers to optionally pass 1-arity functions that return a
    KeywordList of attrs for `tr` and `tds` based on row data

## The Why (Custom styling for `tr`s and `td`s that is data-specific and—in the case of `tds`—column-specific)

At the moment, the only way to provide "td-specific" styling is by passing additional _child_ elements to `:col` slots' `@inner_block` (as this allows us to access the column _and_ the data) and styling those descendent elements. However, this approach introduces additional descendent markup to the `td`, and creates specificity/hierarchy issues. Consider the case of applying padding to `td` elements. If there is then a background color only on the _child_ elements of a td, the background color can't appear to expand to the full width of the td without hard-to-maintain css hacks.

## Example Use of tr and td-Specific Styling

![image](https://github.com/woylie/flop_phoenix/assets/4386645/87c34f0a-04cf-458f-ae83-93097ed66047)

**Checklist**

- [x] I added tests that cover my proposed changes.
- [x] I updated the documentation related to my changes (if applicable).



Patch:
diff --git a/lib/flop_phoenix.ex b/lib/flop_phoenix.ex
index e773c6c..c0e190a 100644
--- a/lib/flop_phoenix.ex
+++ b/lib/flop_phoenix.ex
@@ -249,7 +249,9 @@ defmodule Flop.Phoenix do
   - `:thead_attrs`: Attributes to be added to the `<thead>` tag within the
     `<table>`. Default: `#{inspect(Table.default_opts()[:thead_attrs])}`.
   - `:tbody_tr_attrs`: Attributes to be added to each `<tr>` tag within the
-    `<tbody>`. Default: `#{inspect(Table.default_opts()[:tbody_tr_attrs])}`.
+    `<tbody>`. A function with arity of 1 may be passed to dynamically generate
+    the attrs based on row data.
+    Default: `#{inspect(Table.default_opts()[:tbody_tr_attrs])}`.
   - `:thead_th_attrs`: Attributes to be added to each `<th>` tag within the
     `<thead>`. Default: `#{inspect(Table.default_opts()[:thead_th_attrs])}`.
   - `:thead_tr_attrs`: Attributes to be added to each `<tr>` tag within the
@@ -267,7 +269,7 @@ defmodule Flop.Phoenix do
           | {:tbody_attrs, keyword}
           | {:thead_attrs, keyword}
           | {:tbody_td_attrs, keyword}
-          | {:tbody_tr_attrs, keyword}
+          | {:tbody_tr_attrs, keyword | (any -> keyword)}
           | {:th_wrapper_attrs, keyword}
           | {:thead_th_attrs, keyword}
           | {:thead_tr_attrs, keyword}
@@ -971,9 +973,12 @@ defmodule Flop.Phoenix do
       of a column this way.
       """
 
-    attr :attrs, :list,
+    attr :attrs, :any,
       doc: """
-      Any additional attributes to pass to the `<td>`.
+      Additional attributes to pass to the `<td>` element. May be provided as a
+      static keyword list, or as a 1-arity function to dynamically generate the
+      list using row data. Note that these attributes will override any
+      conflicting `tbody_td_attrs` that are set at the table level.
       """
   end
 
diff --git a/lib/flop_phoenix/table.ex b/lib/flop_phoenix/table.ex
index 1eb85b8..2bc7a79 100644
--- a/lib/flop_phoenix/table.ex
+++ b/lib/flop_phoenix/table.ex
@@ -177,13 +177,13 @@ defmodule Flop.Phoenix.Table do
         <tr
           :for={item <- @items}
           id={@row_id && @row_id.(item)}
-          {@opts[:tbody_tr_attrs]}
+          {maybe_invoke_options_callback(@opts[:tbody_tr_attrs], item)}
         >
           <%= for col <- @col do %>
             <td
               :if={show_column?(col)}
               {@opts[:tbody_td_attrs]}
-              {Map.get(col, :attrs, [])}
+              {maybe_invoke_options_callback(Map.get(col, :attrs, []), item)}
               phx-click={@row_click && @row_click.(item)}
             >
               <%= render_slot(col, @row_item.(item)) %>
@@ -203,6 +203,11 @@ defmodule Flop.Phoenix.Table do
     """
   end
 
+  defp maybe_invoke_options_callback(option, item) when is_function(option),
+    do: option.(item)
+
+  defp maybe_invoke_options_callback(option, _item), do: option
+
   defp show_column?(%{hide: true}), do: false
   defp show_column?(%{show: false}), do: false
   defp show_column?(_), do: true


Test patch:
diff --git a/test/flop_phoenix_test.exs b/test/flop_phoenix_test.exs
index afa8f81..16c4c56 100644
--- a/test/flop_phoenix_test.exs
+++ b/test/flop_phoenix_test.exs
@@ -130,6 +130,26 @@ defmodule Flop.PhoenixTest do
     """
   end
 
+  defp test_table_with_dynamically_generated_attrs(assigns) do
+    ~H"""
+    <Flop.Phoenix.table
+      event="sort"
+      items={@items}
+      opts={@opts}
+      meta={%Flop.Meta{flop: %Flop{}}}
+    >
+      <:col
+        :let={user}
+        label="Name"
+        field={:name}
+        attrs={@opts[:col_slot_attrs] || []}
+      >
+        <%= user.name %>
+      </:col>
+    </Flop.Phoenix.table>
+    """
+  end
+
   defp test_table_with_action(assigns) do
     assigns =
       assigns
@@ -872,16 +892,15 @@ defmodule Flop.PhoenixTest do
         )
 
       expected_query = fn page ->
-        default_query =
-          %{
-            "filters[0][field]" => "fur_length",
-            "filters[0][op]" => ">=",
-            "filters[0][value]" => "5",
-            "filters[1][field]" => "curiosity",
-            "filters[1][op]" => "in",
-            "filters[1][value][]" => "somewhat",
-            "page_size" => "10"
-          }
+        default_query = %{
+          "filters[0][field]" => "fur_length",
+          "filters[0][op]" => ">=",
+          "filters[0][value]" => "5",
+          "filters[1][field]" => "curiosity",
+          "filters[1][op]" => "in",
+          "filters[1][value][]" => "somewhat",
+          "page_size" => "10"
+        }
 
         if page == 1,
           do: default_query,
@@ -1296,7 +1315,10 @@ defmodule Flop.PhoenixTest do
 
       # reverse
       html =
-        render_cursor_pagination(meta: build(:meta_with_cursors), reverse: true)
+        render_cursor_pagination(
+          meta: build(:meta_with_cursors),
+          reverse: true
+        )
 
       link = Floki.find(html, "a:fl-contains('Previous')")
       assert Floki.attribute(link, "href") == ["/pets?first=10&after=C"]
@@ -1347,7 +1369,11 @@ defmodule Flop.PhoenixTest do
     test "disables previous link if on first page" do
       html =
         render_cursor_pagination(
-          meta: build(:meta_with_cursors, has_previous_page?: false)
+          meta:
+            build(
+              :meta_with_cursors,
+              has_previous_page?: false
+            )
         )
 
       previous_link = Floki.find(html, "span:fl-contains('Previous')")
@@ -1692,7 +1718,7 @@ defmodule Flop.PhoenixTest do
       assert Floki.text(td) =~ "GEORGE"
     end
 
-    test "allows to set tr and td classes" do
+    test "allows to set tr and td classes via keyword lists" do
       html =
         render_table(
           opts: [
@@ -1709,6 +1735,58 @@ defmodule Flop.PhoenixTest do
       assert [_, _, _, _, _] = Floki.find(html, "td.tolerance")
     end
 
+    test "support of functions for supplying dynamic tr attrs based on row data" do
+      html =
+        render_table(
+          [
+            items: [
+              %{name: "Bruce Wayne", age: 42, occupation: "Superhero"},
+              %{name: "April O'Neil", age: 39, occupation: "Crime Reporter"}
+            ],
+            opts: [
+              tbody_tr_attrs: fn item ->
+                class =
+                  item.occupation
+                  |> String.downcase()
+                  |> String.replace(" ", "-")
+
+                [
+                  class: class
+                ]
+              end
+            ]
+          ],
+          &test_table_with_dynamically_generated_attrs/1
+        )
+
+      assert [_] = Floki.find(html, "tr.superhero")
+      assert [_] = Floki.find(html, "tr.crime-reporter")
+    end
+
+    test "support of functions for supplying dynamic td attrs based on row data" do
+      html =
+        render_table(
+          [
+            items: [
+              %{name: "Mary Cratsworth-Shane", age: 99},
+              %{name: "Bart Harley-Jarvis", age: 1}
+            ],
+            opts: [
+              # this key is used in the test for readability/passage via assigns
+              # but it is passed to a component's :col slot's `attrs` in
+              # real-world calls.
+              col_slot_attrs: fn item ->
+                [class: if(item.age > 17, do: "adult", else: "child")]
+              end
+            ]
+          ],
+          &test_table_with_dynamically_generated_attrs/1
+        )
+
+      assert [_] = Floki.find(html, "td.adult")
+      assert [_] = Floki.find(html, "td.child")
+    end
+
     test "allows to set td class on action" do
       html =
         render_table(
@@ -1907,7 +1985,9 @@ defmodule Flop.PhoenixTest do
 
       html =
         render_table(
-          meta: %Flop.Meta{flop: %Flop{order_by: [], order_directions: []}}
+          meta: %Flop.Meta{
+            flop: %Flop{order_by: [], order_directions: []}
+          }
         )
 
       assert [th_name, th_email, th_age, th_species, _] = Floki.find(html, "th")