├── .formatter.exs ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── elixir.yml ├── .gitignore ├── .tool-versions ├── CHANGELOG.md ├── README.md ├── lib ├── phoenix_better_table.ex └── phoenix_better_table.html.heex ├── mix.exs ├── mix.lock ├── static └── phoenix-better-table.gif └── test ├── phoenix_better_table_test.exs ├── support └── table_helpers.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mwhitworth] 4 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: mix 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | open-pull-requests-limit: 99 9 | insecure-external-code-execution: allow 10 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Elixir CI 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | pull_request: 12 | branches: [ "master" ] 13 | 14 | permissions: 15 | contents: read 16 | 17 | jobs: 18 | build: 19 | 20 | name: Build and test 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Set up Elixir 26 | uses: erlef/setup-beam@61e01a43a562a89bfc54c7f9a378ff67b03e4a21 # v1.16.0 27 | with: 28 | elixir-version: '1.15.2' # [Required] Define the Elixir version 29 | otp-version: '26.0' # [Required] Define the Erlang/OTP version 30 | - name: Restore dependencies cache 31 | uses: actions/cache@v3 32 | with: 33 | path: deps 34 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 35 | restore-keys: ${{ runner.os }}-mix- 36 | - name: Install dependencies 37 | run: mix deps.get 38 | - name: Run CI 39 | run: mix ci 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | phoenix_better_table-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.15.5 2 | erlang 26.0.2 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.0 (2024-05-26) 4 | 5 | ### New features 6 | 7 | * optional filter slot to customize filter display (#39) 8 | * optional sort slot to customize sort display (#40) 9 | 10 | ### Fixes 11 | 12 | * ensure rows is a list before processing (#38) 13 | * dependency upgrades (#31, #32) 14 | * add FUNDING.yml (#33) 15 | 16 | ## 0.4.3 (2024-03-12) 17 | 18 | * add styling options to table header and body (#26) 19 | * dependency upgrades (#23) 20 | * documentation updates (#17, #18) 21 | * migrate to Github Actions (#24) 22 | 23 | ## 0.4.2 (2024-02-08) 24 | 25 | * support Phoenix LiveView from 0.18.0 (#15) 26 | 27 | ## 0.4.1 (2024-01-14) 28 | 29 | * allow a filter function that generates text to be passed (#12) 30 | 31 | ## 0.4.0 (2024-01-14) 32 | 33 | * basic filtering support (#10, #11) 34 | * basic CI setup (#9) 35 | * bump dependencies (#8) 36 | 37 | ## 0.3.0 (2024-01-13) 38 | 39 | * custom sorter function for column (#5) 40 | * pin Phoenix LiveView to earlier dependency (#3) 41 | 42 | ## 0.2.0 (2023-11-22) 43 | 44 | * optional custom render function for column (#2) 45 | 46 | ## 0.1.0 (2023-11-18) 47 | 48 | initial release 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PhoenixBetterTable 2 | 3 | PhoenixBetterTable is a Phoenix Live Component that presents a filterable and sortable table component given table metadata and rows. 4 | 5 | ## Why? 6 | 7 | It is designed to fill the space between `` and fully featured data tables backed by Ecto, such as those in [flop_phoenix](https://hex.pm/packages/flop_phoenix). 8 | 9 | ## Features 10 | 11 | - (optionally) sortable columns 12 | - (optionally) filterable columns 13 | - (optionally) add custom classes to the table header 14 | - (optionally) add custom classes to the table body 15 | - custom render functions for each column 16 | 17 | ## Usage 18 | 19 | ```elixir 20 | <.live_component 21 | id="123" 22 | module={PhoenixBetterTable} 23 | rows={[%{text: "Hello", number: 123}, %{text: "World", number: 456}]} 24 | meta={%{headers: [%{id: :text, label: "string", sort: false}, %{id: :number}]}} /> 25 | ``` 26 | 27 | produces a table with two columns ("Hello" and "World"), one sortable column ("World), and filtering by column contents: 28 | 29 | PhoenixBetterTable example 30 | 31 | ### Custom render functions 32 | Suppose you have a column with an action button you want to render. You can define a custom render function for that column: 33 | 34 | ```elixir 35 | def render_button(assigns) do 36 | ~H""" 37 | 38 | """ 39 | end 40 | ``` 41 | 42 | and pass it to the `meta` map: 43 | 44 | ```elixir 45 | <.live_component 46 | id="123" 47 | module={PhoenixBetterTable} 48 | rows={[%{action: "Hello", number: 123}, %{action: "World", number: 456}]} 49 | meta={%{headers: [%{id: :action, label: "Action", sort: false, render: &render_button/1}, %{id: :number}]}} /> 50 | ``` 51 | 52 | ## Installation 53 | 54 | The package can be installed by adding `phoenix_better_table` to your list of dependencies in `mix.exs`: 55 | 56 | ```elixir 57 | def deps do 58 | [ 59 | {:phoenix_better_table, "~> 0.5.0"} 60 | ] 61 | end 62 | ``` 63 | 64 | The docs can be found at . 65 | -------------------------------------------------------------------------------- /lib/phoenix_better_table.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixBetterTable do 2 | @moduledoc """ 3 | A table component whose contents can be sorted by clicking on each header. 4 | 5 | ## Assigns 6 | 7 | * `:meta` - a map containing a `:headers` key: 8 | * `headers:` - a list of maps, each representing a header in the table: 9 | * `:id` - the column's id, which will be used as the key for rendering and sorting 10 | * `:label` - the column's label (optional) 11 | * `:filter` - a boolean indicating whether the column is filterable (optional, default true), or a 1-arity function that returns text to be filtered (default `to_string/1`) 12 | * `:sort` - either a boolean indicating whether the column is sortable (optional, default true), or a compare/2 function that returns true if the first argument precedes or is in the same place as the second one. 13 | * `:render` - an optional component that renders cells in the column 14 | * `:rows` - a list of maps, each representing a row in the table 15 | * `:sort` - a tuple containing the column id and the sort order (`:asc` or `:desc`) (optional) 16 | * `:class` - a string containing additional classes to be added to the table (optional) 17 | * `:body_class` - a string containing additional classes to be added to the table body (optional) 18 | * `:header_class` - a string containing additional classes to be added to the table header (optional) 19 | 20 | ## Slots 21 | 22 | * `:filter_control` - an optional slot that takes a single argument, a tuple of `{active?, id, myself}`. The interactive element should 23 | set `phx-click="filter_toggle"`, `phx-value-header={id}`, and `phx-target={myself}` for the event to be routed correctly. 24 | * `:sort_control` - an optional slot that takes a single argument, a tuple of `{direction, id, myself}`. See above. 25 | """ 26 | 27 | use Phoenix.LiveComponent 28 | 29 | @impl true 30 | def handle_event( 31 | "sort", 32 | %{"header" => header_id}, 33 | socket 34 | ) do 35 | sort_column = String.to_existing_atom(header_id) 36 | 37 | socket = 38 | update(socket, :sort, fn 39 | nil -> {sort_column, :asc} 40 | {^sort_column, :asc} -> {sort_column, :desc} 41 | {^sort_column, :desc} -> {sort_column, :asc} 42 | _ -> {sort_column, :asc} 43 | end) 44 | 45 | {:noreply, process_rows(socket)} 46 | end 47 | 48 | def handle_event( 49 | "filter_toggle", 50 | %{"header" => header_id}, 51 | socket 52 | ) do 53 | filter_column = String.to_existing_atom(header_id) 54 | 55 | socket = 56 | update(socket, :filter, fn 57 | %{^filter_column => _} -> Map.delete(socket.assigns.filter, filter_column) 58 | _ -> Map.put(socket.assigns.filter, filter_column, "") 59 | end) 60 | 61 | {:noreply, process_rows(socket)} 62 | end 63 | 64 | def handle_event( 65 | "filter_change", 66 | %{"header" => header_id, "value" => value}, 67 | socket 68 | ) do 69 | filter_column = String.to_existing_atom(header_id) 70 | 71 | socket = 72 | update(socket, :filter, fn 73 | state -> Map.put(state, filter_column, value) 74 | end) 75 | 76 | {:noreply, process_rows(socket)} 77 | end 78 | 79 | @impl true 80 | def mount(socket) do 81 | {:ok, socket} 82 | end 83 | 84 | @impl true 85 | def update(%{meta: %{headers: []}}, _socket) do 86 | raise ArgumentError, "meta.headers must not be empty" 87 | end 88 | 89 | def update(assigns, socket) do 90 | socket 91 | |> assign(assigns) 92 | |> assign_new(:engine_module, fn -> engine_module() end) 93 | |> assign_new(:sort, fn -> nil end) 94 | |> assign_new(:filter, fn -> %{} end) 95 | |> assign_new(:sort_control, fn -> nil end) 96 | |> assign_new(:filter_control, fn -> nil end) 97 | |> assign_new(:class, fn -> "" end) 98 | |> assign_new(:body_class, fn -> "" end) 99 | |> assign_new(:header_class, fn -> "" end) 100 | |> process_rows() 101 | |> then(&{:ok, &1}) 102 | end 103 | 104 | # 105 | 106 | defp process_rows(%{assigns: %{rows: rows}} = socket) when is_list(rows) do 107 | assign(socket, :processed_rows, rows |> filter_rows(socket) |> sort_rows(socket)) 108 | end 109 | 110 | defp filter_rows(rows, %{assigns: %{filter: nil}}) do 111 | rows 112 | end 113 | 114 | defp filter_rows(rows, %{assigns: %{filter: filter, meta: %{headers: headers}}}) do 115 | processed_filters = 116 | Enum.map(filter, fn {column, value} -> 117 | {column, value |> String.trim() |> String.downcase()} 118 | end) 119 | 120 | Enum.filter(rows, fn row -> 121 | Enum.all?(processed_filters, fn {column, value} -> 122 | filter_func = column_filter(Enum.find(headers, &(&1.id == column))) 123 | 124 | String.contains?( 125 | Map.get(row, column) |> filter_func.() |> String.downcase(), 126 | value 127 | ) 128 | end) 129 | end) 130 | end 131 | 132 | defp sort_rows(rows, %{assigns: %{sort: {column, order}, meta: %{headers: headers}}}) do 133 | sorter = column_sorter(order, Enum.find(headers, &(&1.id == column))) 134 | Enum.sort_by(rows, &Map.get(&1, column), sorter) 135 | end 136 | 137 | defp sort_rows(rows, _socket) do 138 | rows 139 | end 140 | 141 | # 142 | 143 | defp column_sorter(order, %{sort: sort}) when is_function(sort) do 144 | if order == :asc, do: sort, else: &sort.(&2, &1) 145 | end 146 | 147 | defp column_sorter(order, %{sort: module}) when is_atom(module) do 148 | {order, module} 149 | end 150 | 151 | defp column_sorter(order, _), do: if(order == :asc, do: &<=/2, else: &>=/2) 152 | 153 | defp column_filter(%{filter: filter}) when is_function(filter, 1) do 154 | filter 155 | end 156 | 157 | defp column_filter(_), do: &to_string/1 158 | 159 | defp column_sort_order({column, order}, column), do: order 160 | defp column_sort_order(_, _), do: nil 161 | 162 | attr(:direction, :atom, required: true) 163 | attr(:rest, :global) 164 | 165 | defp sort_control(assigns) do 166 | ~H""" 167 | 168 | <%= case @direction do 169 | nil -> "—" 170 | :asc -> "▲" 171 | :desc -> "▼" 172 | end %> 173 | 174 | """ 175 | end 176 | 177 | attr(:active?, :boolean, required: true) 178 | attr(:rest, :global) 179 | 180 | defp filter_control(assigns) do 181 | ~H""" 182 | 183 | ⫧ 184 | 185 | """ 186 | end 187 | 188 | defp engine_module do 189 | if Kernel.function_exported?(Phoenix.LiveView.TagEngine, :component, 3) do 190 | # After LiveView 0.18.18 191 | Phoenix.LiveView.TagEngine 192 | else 193 | Phoenix.LiveView.HTMLEngine 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/phoenix_better_table.html.heex: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 20 | 21 | 0}> 22 | 25 | 26 | 27 | 28 | 29 | 30 | 40 | 41 | 42 |
5 | <%= Map.get(header, :label, header.id) %> 6 | <%= if Map.get(header, :sort, true) do %> 7 | <%= if @sort_control do %> 8 | <%= render_slot(@sort_control, {column_sort_order(@sort, header.id), header.id, @myself}) %> 9 | <% else %> 10 | <.sort_control direction={column_sort_order(@sort, header.id)} phx-click="sort" phx-value-header={header.id} phx-target={@myself} /> 11 | <% end %> 12 | <% end %> 13 |   14 | <%= if @filter_control do %> 15 | <%= render_slot(@filter_control, {Map.has_key?(@filter, header.id), header.id, @myself}) %> 16 | <% else %> 17 | <.filter_control active?={Map.has_key?(@filter, header.id)} phx-click="filter_toggle" phx-value-header={header.id} phx-target={@myself} /> 18 | <% end %> 19 |
23 | 24 |
31 | <%= if Map.has_key?(header, :render) do %> 32 | <%= @engine_module.component( 33 | header.render, 34 | [value: row[header.id]], 35 | {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line}) %> 36 | <% else %> 37 | <%= row[header.id] %> 38 | <% end %> 39 |
43 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixBetterTable.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :phoenix_better_table, 7 | version: "0.5.0", 8 | elixir: "~> 1.14", 9 | elixirc_paths: elixirc_paths(Mix.env()), 10 | start_permanent: Mix.env() == :prod, 11 | deps: deps(), 12 | description: "A better table for Phoenix LiveView", 13 | package: package(), 14 | aliases: aliases(), 15 | test_coverage: [tool: ExCoveralls], 16 | preferred_cli_env: ["test.watch": :test, cover: :test, ci: :test], 17 | docs: [ 18 | extras: ["README.md"] 19 | ] 20 | ] 21 | end 22 | 23 | # Run "mix help compile.app" to learn about applications. 24 | def application do 25 | [ 26 | extra_applications: [:logger] 27 | ] 28 | end 29 | 30 | # Specifies which paths to compile per environment. 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | defp elixirc_paths(_), do: ["lib"] 33 | 34 | # Run "mix help deps" to learn about dependencies. 35 | defp deps do 36 | [ 37 | {:excoveralls, "~> 0.14", only: :test}, 38 | {:floki, ">= 0.30.0", only: :test}, 39 | {:mix_test_watch, "~> 1.0", only: [:dev, :test], runtime: false}, 40 | {:live_isolated_component, "~> 0.8.0", only: [:dev, :test]}, 41 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 42 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 43 | {:phoenix_live_view, ">= 0.18.0"} 44 | ] 45 | end 46 | 47 | def package do 48 | [ 49 | files: ["lib", "mix.exs", "README.md"], 50 | licenses: ["MIT"], 51 | links: %{"GitHub" => "https://github.com/mwhitworth/phoenix_better_table"} 52 | ] 53 | end 54 | 55 | def aliases do 56 | [ 57 | ci: [ 58 | "format --check-formatted", 59 | "credo --strict", 60 | "compile --warnings-as-errors --force", 61 | "test" 62 | ], 63 | cover: ["coveralls.html"] 64 | ] 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.6", "ffc42f110ebfdafab0ea159cd43d31365fa0af0ce4a02ecebf1707ae619ee727", [:mix], [], "hexpm", "374c6e7ca752296be3d6780a6d5b922854ffcc74123da90f2f328996b962d33a"}, 4 | "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, 5 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 6 | "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, 7 | "excoveralls": {:hex, :excoveralls, "0.18.0", "b92497e69465dc51bc37a6422226ee690ab437e4c06877e836f1c18daeb35da9", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1109bb911f3cb583401760be49c02cbbd16aed66ea9509fc5479335d284da60b"}, 8 | "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, 9 | "floki": {:hex, :floki, "0.36.1", "712b7f2ba19a4d5a47dfe3e74d81876c95bbcbee44fe551f0af3d2a388abb3da", [:mix], [], "hexpm", "21ba57abb8204bcc70c439b423fc0dd9f0286de67dc82773a14b0200ada0995f"}, 10 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 11 | "live_isolated_component": {:hex, :live_isolated_component, "0.8.0", "f1c396610abe266bf3dfc0f1c465c0ca7d9a4ffafa47714e4425f59ef8901943", [:mix], [{:phoenix, "~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19.0 or ~> 0.20.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "895820621381c7d83af9c37b12268f832929e695c7d30dd937494f8a8c5f2099"}, 12 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 13 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 14 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 15 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 16 | "mix_test_watch": {:hex, :mix_test_watch, "1.2.0", "1f9acd9e1104f62f280e30fc2243ae5e6d8ddc2f7f4dc9bceb454b9a41c82b42", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "278dc955c20b3fb9a3168b5c2493c2e5cffad133548d307e0a50c7f2cfbf34f6"}, 17 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 18 | "phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"}, 19 | "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, 20 | "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.14", "70fa101aa0539e81bed4238777498f6215e9dda3461bdaa067cad6908110c364", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "82f6d006c5264f979ed5eb75593d808bbe39020f20df2e78426f4f2d570e2402"}, 21 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 22 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 23 | "plug": {:hex, :plug, "1.15.3", "712976f504418f6dff0a3e554c40d705a9bcf89a7ccef92fc6a5ef8f16a30a97", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4365a3c010a56af402e0809208873d113e9c38c401cabd88027ef4f5c01fd2"}, 24 | "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, 25 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 26 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 27 | "websock_adapter": {:hex, :websock_adapter, "0.5.6", "0437fe56e093fd4ac422de33bf8fc89f7bc1416a3f2d732d8b2c8fd54792fe60", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "e04378d26b0af627817ae84c92083b7e97aca3121196679b73c73b99d0d133ea"}, 28 | } 29 | -------------------------------------------------------------------------------- /static/phoenix-better-table.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mwhitworth/phoenix_better_table/9bd7962cbe89c3ef07b3e80bfeea93567da3171b/static/phoenix-better-table.gif -------------------------------------------------------------------------------- /test/phoenix_better_table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PhoenixBetterTableTest do 2 | defmodule TestEndpoint do 3 | use Phoenix.Endpoint, otp_app: :phoenix_better_table 4 | 5 | defoverridable config: 1, config: 2 6 | 7 | def config(:live_view), do: [signing_salt: "112345678212345678312345678412"] 8 | def config(:secret_key_base), do: String.duplicate("57689", 50) 9 | def config(which), do: super(which) 10 | def config(which, default), do: super(which, default) 11 | end 12 | 13 | @endpoint PhoenixBetterTableTest.TestEndpoint 14 | 15 | use ExUnit.Case, async: true 16 | 17 | import Phoenix.Component, only: [sigil_H: 2] 18 | import Phoenix.LiveViewTest 19 | import LiveIsolatedComponent 20 | import PhoenixBetterTable.Support.TableHelpers, only: [assert_table_matches: 2] 21 | 22 | setup do 23 | start_supervised!(PhoenixBetterTableTest.TestEndpoint) 24 | :ok 25 | end 26 | 27 | test "errors when meta.headers is empty" do 28 | assert_raise ArgumentError, fn -> 29 | live_isolated_component(PhoenixBetterTable, %{ 30 | meta: %{headers: []}, 31 | rows: [%{name: "John", age: 30}, %{name: "Jane", age: 25}] 32 | }) 33 | end 34 | end 35 | 36 | test "renders basic table with headers" do 37 | {:ok, _view, html} = 38 | live_isolated_component(PhoenixBetterTable, %{ 39 | meta: %{headers: [%{id: :name}, %{id: :age}]}, 40 | rows: [%{name: "John", age: 30}, %{name: "Jane", age: 25}] 41 | }) 42 | 43 | assert_table_matches(html, """ 44 | name age 45 | John 30 46 | Jane 25 47 | """) 48 | end 49 | 50 | test "renders a label for a header, if supplied" do 51 | {:ok, _view, html} = 52 | live_isolated_component(PhoenixBetterTable, %{ 53 | meta: %{headers: [%{id: :name, label: "Real Name"}, %{id: :age}]}, 54 | rows: [%{name: "John", age: 30}, %{name: "Jane", age: 25}] 55 | }) 56 | 57 | assert_table_matches(html, """ 58 | Real Name age 59 | John 30 60 | Jane 25 61 | """) 62 | end 63 | 64 | test "disables sorting if header sort is false" do 65 | {:ok, _view, html} = 66 | live_isolated_component(PhoenixBetterTable, %{ 67 | meta: %{headers: [%{id: :name, sort: false}, %{id: :age, sort: false}]}, 68 | rows: [%{name: "John", age: 30}, %{name: "Jane", age: 25}] 69 | }) 70 | 71 | assert [] == Floki.parse_document!(html) |> Floki.find("a[phx-click='sort']") 72 | end 73 | 74 | test "for a single column, sorts table ascending, then descending, then back to ascending" do 75 | {:ok, view, _html} = 76 | live_isolated_component(PhoenixBetterTable, %{ 77 | meta: %{headers: [%{id: :name}, %{id: :age, sort: false}]}, 78 | rows: [%{name: "John", age: 30}, %{name: "Jane", age: 25}] 79 | }) 80 | 81 | # Sort ascending 82 | html = view |> element("a[phx-click='sort']") |> render_click() 83 | 84 | assert_table_matches(html, """ 85 | name age 86 | Jane 25 87 | John 30 88 | """) 89 | 90 | # Sort descending 91 | html = view |> element("a[phx-click='sort']") |> render_click() 92 | 93 | assert_table_matches(html, """ 94 | name age 95 | John 30 96 | Jane 25 97 | """) 98 | 99 | # Sort ascending again 100 | html = view |> element("a[phx-click='sort']") |> render_click() 101 | 102 | assert_table_matches(html, """ 103 | name age 104 | Jane 25 105 | John 30 106 | """) 107 | end 108 | 109 | test "reprocesses rows for sort if assign is changed" do 110 | {:ok, view, _html} = 111 | live_isolated_component(PhoenixBetterTable, %{ 112 | meta: %{headers: [%{id: :name}, %{id: :age, sort: false}]}, 113 | rows: [%{name: "John", age: 30}, %{name: "Jane", age: 25}] 114 | }) 115 | 116 | view |> element("a[phx-click='sort']") |> render_click() 117 | 118 | live_assign(view, :rows, [ 119 | %{name: "John", age: 30}, 120 | %{name: "Jane", age: 25}, 121 | %{name: "Bob", age: 40} 122 | ]) 123 | 124 | html = render(view) 125 | 126 | assert_table_matches(html, """ 127 | name age 128 | Bob 40 129 | Jane 25 130 | John 30 131 | """) 132 | end 133 | 134 | test "renders cell using a function component, if :render is supplied for a column" do 135 | {:ok, _view, html} = 136 | live_isolated_component(PhoenixBetterTable, %{ 137 | meta: %{ 138 | headers: [ 139 | %{ 140 | id: :name, 141 | render: fn assigns -> 142 | ~H""" 143 | <%= String.reverse(@value) %> 144 | """ 145 | end 146 | } 147 | ] 148 | }, 149 | rows: [%{name: "John"}, %{name: "Jane"}] 150 | }) 151 | 152 | assert_table_matches(html, """ 153 | name 154 | nhoJ 155 | enaJ 156 | """) 157 | end 158 | 159 | test "custom sorter can be passed to a header using :sort" do 160 | {:ok, view, _html} = 161 | live_isolated_component(PhoenixBetterTable, %{ 162 | meta: %{ 163 | headers: [ 164 | %{ 165 | id: :date, 166 | sort: Date 167 | }, 168 | %{ 169 | id: :name, 170 | sort: &(String.length(&1) <= String.length(&2)) 171 | } 172 | ] 173 | }, 174 | rows: [%{date: ~D[2023-01-01], name: "X"}, %{date: ~D[2022-02-01], name: "Long"}] 175 | }) 176 | 177 | # Sort date ascending 178 | html = 179 | view |> element("th[data-test-table-header='date'] > a[phx-click='sort']") |> render_click() 180 | 181 | assert_table_matches(html, """ 182 | date name 183 | 2022-02-01 Long 184 | 2023-01-01 X 185 | """) 186 | 187 | # Sort date descending 188 | html = 189 | view |> element("th[data-test-table-header='date'] > a[phx-click='sort']") |> render_click() 190 | 191 | assert_table_matches(html, """ 192 | date name 193 | 2023-01-01 X 194 | 2022-02-01 Long 195 | """) 196 | 197 | # Sort name descending 198 | view |> element("th[data-test-table-header='name'] > a[phx-click='sort']") |> render_click() 199 | 200 | html = 201 | view |> element("th[data-test-table-header='name'] > a[phx-click='sort']") |> render_click() 202 | 203 | assert_table_matches(html, """ 204 | date name 205 | 2022-02-01 Long 206 | 2023-01-01 X 207 | """) 208 | end 209 | 210 | test "supports filtering rows by per-column filters" do 211 | {:ok, view, _html} = 212 | live_isolated_component(PhoenixBetterTable, %{ 213 | meta: %{ 214 | headers: [ 215 | %{ 216 | id: :name 217 | } 218 | ] 219 | }, 220 | rows: [%{name: "John"}, %{name: "Jane"}] 221 | }) 222 | 223 | # Filter by name 224 | view |> element("a[phx-click='filter_toggle']") |> render_click() 225 | html = view |> element("input") |> render_keyup(%{"header" => "name", "value" => "JO"}) 226 | 227 | assert_table_matches(html, """ 228 | name 229 | John 230 | """) 231 | 232 | # Switch off filter by clicking toggle again 233 | html = view |> element("a[phx-click='filter_toggle']") |> render_click() 234 | 235 | assert_table_matches(html, """ 236 | name 237 | John 238 | Jane 239 | """) 240 | end 241 | 242 | test "reprocesses rows for filter if assign is changed" do 243 | {:ok, view, _html} = 244 | live_isolated_component(PhoenixBetterTable, %{ 245 | meta: %{headers: [%{id: :name}]}, 246 | rows: [%{name: "John"}, %{name: "Jane"}] 247 | }) 248 | 249 | view |> element("a[phx-click='filter_toggle']") |> render_click() 250 | view |> element("input") |> render_keyup(%{"header" => "name", "value" => "Jo"}) 251 | 252 | live_assign(view, :rows, [ 253 | %{name: "John"}, 254 | %{name: "John-Paul"}, 255 | %{name: "Jane"} 256 | ]) 257 | 258 | view 259 | |> render() 260 | |> assert_table_matches(""" 261 | name 262 | John 263 | John-Paul 264 | """) 265 | end 266 | 267 | test "custom filter text can be passed to a header using :filter" do 268 | {:ok, view, _html} = 269 | live_isolated_component(PhoenixBetterTable, %{ 270 | meta: %{ 271 | headers: [ 272 | %{ 273 | id: :date, 274 | filter: fn _ -> "hello" end 275 | } 276 | ] 277 | }, 278 | rows: [%{date: ~D[2023-01-01]}] 279 | }) 280 | 281 | view |> element("a[phx-click='filter_toggle']") |> render_click() 282 | html = view |> element("input") |> render_keyup(%{"header" => "date", "value" => "hello"}) 283 | 284 | assert_table_matches(html, """ 285 | date 286 | 2023-01-01 287 | """) 288 | end 289 | 290 | test "custom sort control can be passed as slot" do 291 | {:ok, view, _html} = 292 | live_isolated_component(PhoenixBetterTable, 293 | assigns: %{ 294 | meta: %{headers: [%{id: :name}]}, 295 | rows: [%{name: "John"}, %{name: "Jane"}] 296 | }, 297 | slots: %{ 298 | sort_control: 299 | slot(let: {direction, id, myself}) do 300 | ~H[Sort (<%= direction %>)] 301 | end 302 | } 303 | ) 304 | 305 | html = view |> element("span[phx-click='sort']") |> render_click() 306 | 307 | assert_table_matches(html, """ 308 | name 309 | Jane 310 | John 311 | """) 312 | end 313 | 314 | test "custom filter control can be passed as a slot" do 315 | {:ok, view, _html} = 316 | live_isolated_component(PhoenixBetterTable, 317 | assigns: %{ 318 | meta: %{headers: [%{id: :name}]}, 319 | rows: [%{name: "John"}, %{name: "Jane"}] 320 | }, 321 | slots: %{ 322 | filter_control: 323 | slot(let: {_active?, id, myself}) do 324 | ~H[Filter] 325 | end 326 | } 327 | ) 328 | 329 | view |> element("span[phx-click='filter_toggle']") |> render_click() 330 | html = view |> element("input") |> render_keyup(%{"header" => "name", "value" => "jo"}) 331 | 332 | assert_table_matches(html, """ 333 | name 334 | John 335 | """) 336 | end 337 | end 338 | -------------------------------------------------------------------------------- /test/support/table_helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule PhoenixBetterTable.Support.TableHelpers do 2 | @moduledoc false 3 | 4 | alias __MODULE__, as: TableHelpers 5 | 6 | defmacro assert_table_matches(actual, expected) do 7 | quote location: :keep do 8 | actual_table = TableHelpers.parse_actual_table(unquote(actual)) 9 | expected_table = TableHelpers.parse_expected_table(unquote(expected)) 10 | assert actual_table == expected_table 11 | end 12 | end 13 | 14 | def parse_actual_table(content) do 15 | [table] = Floki.parse_document!(content) |> Floki.find("table") 16 | 17 | table 18 | |> Floki.find("tr") 19 | |> Enum.map(&parse_table_row/1) 20 | |> Enum.reject(fn row -> Enum.all?(row, &(&1 == "")) end) 21 | end 22 | 23 | def parse_table_row(row) do 24 | Floki.find(row, "th, td") |> Enum.map(&(Floki.text(&1, deep: false) |> String.trim())) 25 | end 26 | 27 | def parse_expected_table(content) do 28 | content 29 | |> String.split("\n") 30 | |> Enum.map(&String.trim/1) 31 | |> Enum.reject(&(&1 == "")) 32 | |> Enum.map(&String.split(&1, ~r/\s\s+/)) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :error) 2 | 3 | ExUnit.start() 4 | --------------------------------------------------------------------------------