diff --git a/.github/renovate.json b/.github/renovate.json index 041d28ff..eebbae06 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -43,6 +43,17 @@ "rangeStrategy": "update-lockfile" }, { + "description": "Automatically merge patch updates in demo application", + "matchFileNames": [ + "demo/**" + ], + "matchUpdateTypes": [ + "patch" + ], + "automerge": true + }, + { + "description": "Automatically merge github actions updates", "matchManagers": [ "github-actions" ], 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 94622999..73481349 100644 --- a/demo/mix.exs +++ b/demo/mix.exs @@ -41,6 +41,7 @@ defmodule Demo.MixProject do {:tailwind_formatter, "~> 0.4.0", only: [:dev, :test], runtime: false}, {:ex_machina, "~> 2.3"}, {:faker, "~> 0.18"}, + {:phoenix_test, "~> 0.5.1", only: :test, runtime: false}, # core {:libcluster, "~> 3.2"}, diff --git a/demo/mix.lock b/demo/mix.lock index 5882fc16..1d3d08ec 100644 --- a/demo/mix.lock +++ b/demo/mix.lock @@ -22,6 +22,7 @@ "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "floki": {:hex, :floki, "0.37.0", "b83e0280bbc6372f2a403b2848013650b16640cd2470aea6701f0632223d719e", [:mix], [], "hexpm", "516a0c15a69f78c47dc8e0b9b3724b29608aa6619379f91b1ffa47109b5d0dd3"}, "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "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"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized"]}, @@ -55,6 +56,7 @@ "phoenix_live_view": {:hex, :phoenix_live_view, "1.0.2", "e7b1dd68c86326e2c45cc81da41e332cc8aa7228a7161e2c811dcd7f1dd14db1", [: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", "8a40265b0cd7d3a35f136dfa3cc048e3b198fc3718763411a78c323a44ebebee"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "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.5.1", "9d3e27f47d15ed0cda1a4afaf79eec4177162ed6d50b32276e122bc97dc00214", [:mix], [{:floki, ">= 0.30.0", [hex: :floki, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, ">= 1.0.0", [hex: :mime, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7.10", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "9bcaf413296f7a7f7f9ddade2177b2d0e4f650b05c517a7922a48843d7e456f7"}, "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"}, "postgrex": {:hex, :postgrex, "0.19.3", "a0bda6e3bc75ec07fca5b0a89bffd242ca209a4822a9533e7d3e84ee80707e19", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d31c28053655b78f47f948c85bb1cf86a9c1f8ead346ba1aa0d0df017fa05b61"}, 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..3cbfd194 --- /dev/null +++ b/demo/test/demo_web/tag_live_test.exs @@ -0,0 +1,109 @@ +defmodule DemoWeb.TagLiveTest do + use DemoWeb.ConnCase, async: false + + 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("#index-search-form", 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"}) + |> put_submitter("button[value=save]") + |> 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"}) + |> put_submitter("button[value=save]") + |> 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..f7909cd2 --- /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 _match, 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 9eb2b791..cf0dbde9 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -183,7 +183,7 @@ defmodule Backpex.HTML.Resource do |> assign(:form, form) ~H""" - <.form :if={@search_enabled} for={@form} phx-change="index-search" phx-submit="index-search"> + <.form :if={@search_enabled} id="index-search-form" for={@form} phx-change="index-search" phx-submit="index-search"> 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