diff --git a/demo/config/test.exs b/demo/config/test.exs index a766cabb..c5348f26 100644 --- a/demo/config/test.exs +++ b/demo/config/test.exs @@ -15,3 +15,5 @@ config :demo, DemoWeb.Endpoint, server: false config :demo, Demo.Repo, pool: Ecto.Adapters.SQL.Sandbox config :phoenix, :plug_init_mode, :runtime + +config :phoenix_test, :endpoint, DemoWeb.Endpoint diff --git a/demo/mix.exs b/demo/mix.exs index 3380be48..e3e99d4d 100644 --- a/demo/mix.exs +++ b/demo/mix.exs @@ -66,7 +66,9 @@ defmodule Demo.MixProject do {:tesla, "~> 1.4"}, {:jason, ">= 1.0.0"}, {:bandit, "~> 1.0"}, - {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.1.5", sparse: "optimized", app: false, compile: false} + {:heroicons, github: "tailwindlabs/heroicons", tag: "v2.1.5", sparse: "optimized", app: false, compile: false}, + {:floki, ">= 0.30.0", only: :test}, + {:phoenix_test, "~> 0.3.1", only: :test, runtime: false} ] end diff --git a/demo/mix.lock b/demo/mix.lock index 0534d526..ca79b35a 100644 --- a/demo/mix.lock +++ b/demo/mix.lock @@ -8,6 +8,7 @@ "csv": {:hex, :csv, "3.2.1", "6d401f1ed33acb2627682a9ab6021e96d33ca6c1c6bccc243d8f7e2197d032f5", [:mix], [], "hexpm", "8f55a0524923ae49e97ff2642122a2ce7c61e159e7fe1184670b2ce847aee6c8"}, "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, "ecto": {:hex, :ecto, "3.11.2", "e1d26be989db350a633667c5cda9c3d115ae779b66da567c68c80cfb26a8c9ee", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3c38bca2c6f8d8023f2145326cc8a80100c3ffe4dcbd9842ff867f7fc6156c65"}, "ecto_psql_extras": {:hex, :ecto_psql_extras, "0.8.0", "440719cd74f09b3f01c84455707a2c3972b725c513808e68eb6c5b0ab82bf523", [:mix], [{:ecto_sql, "~> 3.7", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16.0 or ~> 0.17.0 or ~> 0.18.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:table_rex, "~> 3.1.1 or ~> 4.0.0", [hex: :table_rex, repo: "hexpm", optional: false]}], "hexpm", "f1512812dc196bcb932a96c82e55f69b543dc125e9d39f5e3631a9c4ec65ef12"}, @@ -18,6 +19,7 @@ "expo": {:hex, :expo, "1.0.0", "647639267e088717232f4d4451526e7a9de31a3402af7fcbda09b27e9a10395a", [:mix], [], "hexpm", "18d2093d344d97678e8a331ca0391e85d29816f9664a25653fd7e6166827827c"}, "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, "gen_smtp": {:hex, :gen_smtp, "1.2.0", "9cfc75c72a8821588b9b9fe947ae5ab2aed95a052b81237e0928633a13276fd3", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "5ee0375680bca8f20c4d85f58c2894441443a743355430ff33a783fe03296779"}, "gettext": {:hex, :gettext, "0.25.0", "98a95a862a94e2d55d24520dd79256a15c87ea75b49673a2e2f206e6ebc42e5d", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "38e5d754e66af37980a94fb93bb20dcde1d2361f664b0a19f01e87296634051f"}, "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, @@ -49,6 +51,7 @@ "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"}, "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"}, + "phoenix_test": {:hex, :phoenix_test, "0.3.1", "adf5d67cb152fa1e0220527a9827db7f865a20817c7c0161315ba6fe86a8946c", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:floki, ">= 0.30.0", [hex: :floki, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7.10", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "03f24332a966673ff40d35c45f427c7671537ea508b5777141dd60e60cdae22a"}, "phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"}, "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [: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", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, diff --git a/demo/test/demo_web/tag_live_test.exs b/demo/test/demo_web/tag_live_test.exs new file mode 100644 index 00000000..df459339 --- /dev/null +++ b/demo/test/demo_web/tag_live_test.exs @@ -0,0 +1,107 @@ +defmodule DemoWeb.TagLiveTest do + use DemoWeb.ConnCase + + import Demo.Factory + import Phoenix.LiveViewTest + import DemoWeb.LiveResourceTests + + describe "tags live resource index" do + test "is rendered", %{conn: conn} do + insert_list(3, :tag) + + conn + |> visit("/admin/tags") + |> assert_has("h1", text: "Tags", exact: true) + |> assert_has("button", text: "New Tag", exact: true) + |> assert_has("button[disabled]", text: "Delete", exact: true) + |> assert_has("div", text: "Items 1 to 3 (3 total)", exact: true) + end + + test "search for items", %{conn: conn} do + insert(:tag, %{name: "Elixir"}) + insert(:tag, %{name: "Phoenix"}) + + conn + |> visit("/admin/tags") + |> assert_has(".table tbody tr", count: 2) + |> unwrap(fn view -> + view + |> form("form[phx-change='index-search']", %{"index_search[value]" => "Elixir"}) + |> render_change() + end) + |> assert_has(".table tbody tr", count: 1) + |> refute_has("tr", text: "Phoenix") + |> assert_has("tr", text: "Elixir") + end + + test "basic functionality", %{conn: conn} do + tags = insert_list(3, :tag) + + test_table_rows_count(conn, "/admin/tags", Enum.count(tags)) + test_delete_button_disabled_enabled(conn, "/admin/tags", tags) + test_show_action_redirect(conn, "/admin/tags", tags) + test_edit_action_redirect(conn, "/admin/tags", tags) + end + end + + describe "tags live resource show" do + test "is rendered", %{conn: conn} do + tag = insert(:tag) + + conn + |> visit("/admin/tags/#{tag.id}/show") + |> assert_has("h1", text: "Tag", exact: true) + |> assert_has("p", text: "Name", exact: true) + |> assert_has("p", text: "Inserted At", exact: true) + |> assert_has("p", text: tag.name, exact: true) + end + end + + describe "tags live resource edit" do + test "is rendered", %{conn: conn} do + tag = insert(:tag) + + conn + |> visit("/admin/tags/#{tag.id}/edit") + |> assert_has("h1", text: "Edit Tag", exact: true) + |> assert_has("button", text: "Cancel", exact: true) + |> assert_has("button", text: "Save", exact: true) + end + + test "submit form", %{conn: conn} do + tag = insert(:tag, %{name: "Elixir"}) + + conn + |> visit("/admin/tags/#{tag.id}/edit") + |> unwrap(fn view -> + view + |> form("#resource-form", %{"change[name]" => "Phoenix"}) + |> render_submit() + end) + |> assert_has(".table tbody tr", count: 1) + |> assert_has("p", text: "Phoenix", exact: true) + end + end + + describe "tags live resource new" do + test "is rendered", %{conn: conn} do + conn + |> visit("/admin/tags/new") + |> assert_has("h1", text: "New Tag", exact: true) + |> assert_has("button", text: "Cancel", exact: true) + |> assert_has("button", text: "Save", exact: true) + end + + test "submit form", %{conn: conn} do + conn + |> visit("/admin/tags/new") + |> unwrap(fn view -> + view + |> form("#resource-form", %{"change[name]" => "Phoenix"}) + |> render_submit() + end) + |> assert_has(".table tbody tr", count: 1) + |> assert_has("p", text: "Phoenix", exact: true) + end + end +end diff --git a/demo/test/support/conn_case.ex b/demo/test/support/conn_case.ex new file mode 100644 index 00000000..b3a03784 --- /dev/null +++ b/demo/test/support/conn_case.ex @@ -0,0 +1,40 @@ +defmodule DemoWeb.ConnCase do + @moduledoc """ + This module defines the test case to be used by + tests that require setting up a connection. + + Such tests rely on `Phoenix.ConnTest` and also + import other functionality to make it easier + to build common data structures and query the data layer. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use DemoWeb.ConnCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + using do + quote do + # The default endpoint for testing + @endpoint DemoWeb.Endpoint + + use DemoWeb, :verified_routes + + import PhoenixTest + + # Import conveniences for testing with connections + import Plug.Conn + import Phoenix.ConnTest + import DemoWeb.ConnCase + end + end + + setup tags do + Demo.DataCase.setup_sandbox(tags) + {:ok, conn: Phoenix.ConnTest.build_conn()} + end +end diff --git a/demo/test/support/data_case.ex b/demo/test/support/data_case.ex new file mode 100644 index 00000000..bf4377c4 --- /dev/null +++ b/demo/test/support/data_case.ex @@ -0,0 +1,60 @@ +defmodule Demo.DataCase do + @moduledoc """ + This module defines the setup for tests requiring + access to the application's data layer. + + You may define functions here to be used as helpers in + your tests. + + Finally, if the test case interacts with the database, + we enable the SQL sandbox, so changes done to the database + are reverted at the end of every test. If you are using + PostgreSQL, you can even run database tests asynchronously + by setting `use Demo.DataCase, async: true`, although + this option is not recommended for other databases. + """ + + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + + using do + quote do + alias Demo.Repo + + import Ecto + import Ecto.Changeset + import Ecto.Query + import Demo.DataCase + end + end + + setup tags do + Demo.DataCase.setup_sandbox(tags) + :ok + end + + @doc """ + Sets up the sandbox based on the test tags. + """ + def setup_sandbox(tags) do + pid = Sandbox.start_owner!(Demo.Repo, shared: not tags[:async]) + on_exit(fn -> Sandbox.stop_owner(pid) end) + end + + @doc """ + A helper that transforms changeset errors into a map of messages. + + assert {:error, changeset} = Accounts.create_user(%{password: "short"}) + assert "password is too short" in errors_on(changeset).password + assert %{password: ["password is too short"]} = errors_on(changeset) + + """ + def errors_on(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {message, opts} -> + Regex.replace(~r"%{(\w+)}", message, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + end +end diff --git a/demo/test/support/live_resource_tests.ex b/demo/test/support/live_resource_tests.ex new file mode 100644 index 00000000..95a61994 --- /dev/null +++ b/demo/test/support/live_resource_tests.ex @@ -0,0 +1,100 @@ +defmodule DemoWeb.LiveResourceTests do + @moduledoc """ + Defines macros that can be used to include basic live resource tests. + """ + + @doc """ + Tests whether the table body contains expected amount of rows. + """ + defmacro test_table_rows_count(conn, base_path, expected_rows_count) do + quote do + conn = unquote(conn) + base_path = unquote(base_path) + expected_rows_count = unquote(expected_rows_count) + + conn + |> visit(base_path) + |> assert_has(".table tbody tr", count: expected_rows_count) + end + end + + @doc """ + Tests whether delete button becomes enabled when clicking checkbox. + """ + defmacro test_delete_button_disabled_enabled(conn, base_path, items) do + quote do + conn = unquote(conn) + base_path = unquote(base_path) + items = unquote(items) + + if Enum.empty?(items) do + raise "Cannot test delete button with 0 items" + end + + [%{id: first_item_id} | _items] = items + + conn + |> visit(base_path) + |> refute_has("button:not([disabled])", text: "Delete") + |> assert_has("#select-input-#{first_item_id}") + |> unwrap(fn view -> + view + |> element("#select-input-#{first_item_id}") + |> render_click() + end) + |> assert_has("button:not([disabled])", text: "Delete", exact: true) + end + end + + @doc """ + Tests whether the show item action actually redirects to the show view. + """ + defmacro test_show_action_redirect(conn, base_path, items) do + quote do + conn = unquote(conn) + base_path = unquote(base_path) + items = unquote(items) + + if Enum.empty?(items) do + raise "Cannot test show redirect with 0 items" + end + + [%{id: first_item_id} | _items] = items + + conn + |> visit(base_path) + |> unwrap(fn view -> + view + |> element("button[aria-label='Show'][phx-value-item-id='#{first_item_id}']") + |> render_click() + end) + |> assert_path("#{base_path}/#{first_item_id}/show") + end + end + + @doc """ + Tests whether the edit item action actually redirects to the edit view. + """ + defmacro test_edit_action_redirect(conn, base_path, items) do + quote do + conn = unquote(conn) + base_path = unquote(base_path) + items = unquote(items) + + if Enum.empty?(items) do + raise "Cannot test edit redirect with 0 items" + end + + [%{id: first_item_id} | _items] = items + + conn + |> visit(base_path) + |> unwrap(fn view -> + view + |> element("button[aria-label='Edit'][phx-value-item-id='#{first_item_id}']") + |> render_click() + end) + |> assert_path("#{base_path}/#{first_item_id}/edit") + end + end +end diff --git a/lib/backpex/html/resource.ex b/lib/backpex/html/resource.ex index 00710ff3..234ea136 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -383,15 +383,19 @@ defmodule Backpex.HTML.Resource do def pagination_info(assigns) do %{query_options: %{page: page, per_page: per_page}} = assigns - assigns = - assigns - |> assign(:from, (page - 1) * per_page + 1) - |> assign(:to, min(page * per_page, assigns.total)) + from = (page - 1) * per_page + 1 + to = min(page * per_page, assigns.total) + + from_to_string = Backpex.translate({"Items %{from} to %{to}", %{from: from, to: to}}) + total_string = "(#{assigns.total} #{Backpex.translate("total")})" + + label = from_to_string <> " " <> total_string + + assigns = assign(assigns, :label, label) ~H"""
0} class="text-base-content pr-2 text-sm"> - <%= Backpex.translate({"Items %{from} to %{to}", %{from: @from, to: @to}}) %> - <%= "(#{@total} #{Backpex.translate("total")})" %> + <%= @label %>
""" end