From 4f6141d60ea3aefeecefecfac81cdf79243c7696 Mon Sep 17 00:00:00 2001 From: Sergio Arbeo Date: Fri, 6 Dec 2024 01:16:44 +0100 Subject: [PATCH 1/6] Extract mount to ViewUtils --- lib/live_isolated_component.ex | 2 +- lib/live_isolated_component/utils.ex | 4 +- lib/live_isolated_component/view.ex | 32 +-------------- lib/live_isolated_component/view_utils.ex | 49 +++++++++++++++++++++++ 4 files changed, 53 insertions(+), 34 deletions(-) create mode 100644 lib/live_isolated_component/view_utils.ex diff --git a/lib/live_isolated_component.ex b/lib/live_isolated_component.ex index b3a86dd..978aabe 100644 --- a/lib/live_isolated_component.ex +++ b/lib/live_isolated_component.ex @@ -84,7 +84,7 @@ defmodule LiveIsolatedComponent do } end) - live_isolated(build_conn(), LiveIsolatedComponent.View, + live_isolated(build_conn(), Keyword.get(opts, :mock_view, LiveIsolatedComponent.View), session: %{ unquote(LiveIsolatedComponent.MessageNames.store_agent_key()) => store_agent } diff --git a/lib/live_isolated_component/utils.ex b/lib/live_isolated_component/utils.ex index f568cf6..1aae9a2 100644 --- a/lib/live_isolated_component/utils.ex +++ b/lib/live_isolated_component/utils.ex @@ -10,11 +10,9 @@ defmodule LiveIsolatedComponent.Utils do def update_socket_from_store_agent(socket) do agent = store_agent_pid(socket) - component = StoreAgent.get_component(agent) - socket |> assign(:assigns, StoreAgent.get_assigns(agent)) - |> assign(:component, component) + |> assign(:component, StoreAgent.get_component(agent)) |> assign(:slots, StoreAgent.get_slots(agent)) end diff --git a/lib/live_isolated_component/view.ex b/lib/live_isolated_component/view.ex index 2a76e73..8854567 100644 --- a/lib/live_isolated_component/view.ex +++ b/lib/live_isolated_component/view.ex @@ -5,17 +5,10 @@ defmodule LiveIsolatedComponent.View do alias LiveIsolatedComponent.Hooks alias LiveIsolatedComponent.StoreAgent alias LiveIsolatedComponent.Utils + alias LiveIsolatedComponent.ViewUtils alias Phoenix.LiveView.TagEngine - def mount(params, session, socket) do - socket = - socket - |> assign(:store_agent, session[LiveIsolatedComponent.MessageNames.store_agent_key()]) - |> run_on_mount(params, session) - |> Utils.update_socket_from_store_agent() - - {:ok, socket} - end + def mount(params, session, socket), do: ViewUtils.mount(params, session, socket) def render(%{component: component, store_agent: agent, assigns: component_assigns} = _assigns) when is_function(component) do @@ -77,25 +70,4 @@ defmodule LiveIsolatedComponent.View do defp denormalize_result({:reply, map, socket}, original_assigns), do: {:reply, map, Utils.denormalize_socket(socket, original_assigns)} - - defp run_on_mount(socket, params, session), - do: run_on_mount(socket.assigns.store_agent, params, session, socket) - - defp run_on_mount(agent, params, session, socket) do - agent - |> StoreAgent.get_on_mount() - |> add_lic_hooks() - |> Enum.reduce(socket, &do_run_on_mount(&1, params, session, &2)) - end - - defp do_run_on_mount({module, first}, params, session, socket) do - {:cont, socket} = module.on_mount(first, params, session, socket) - socket - end - - defp do_run_on_mount(module, params, session, socket), - do: do_run_on_mount({module, :default}, params, session, socket) - - defp add_lic_hooks(list), - do: [Hooks.HandleEventSpyHook, Hooks.HandleInfoSpyHook, Hooks.AssignsUpdateSpyHook | list] end diff --git a/lib/live_isolated_component/view_utils.ex b/lib/live_isolated_component/view_utils.ex new file mode 100644 index 0000000..34caf71 --- /dev/null +++ b/lib/live_isolated_component/view_utils.ex @@ -0,0 +1,49 @@ +defmodule LiveIsolatedComponent.ViewUtils do + @moduledoc """ + Collection of utils for people that want to write their own + mock LiveView to use with `m:LiveIsolatedComponent.live_isolated_component/2`. + """ + + alias LiveIsolatedComponent.Hooks + alias LiveIsolatedComponent.MessageNames + alias LiveIsolatedComponent.StoreAgent + alias LiveIsolatedComponent.Utils + + @doc """ + Run this in your mock view `c:Phoenix.LiveView.mount/3`. + + ## Options + - `:on_mmount`, _boolean_, defaults to `true`. Can disable adding `on_mount` hooks. + """ + def mount(params, session, socket, opts \\ []) do + socket = + socket + |> assign(:store_agent, session[MessageNames.store_agent_key()]) + |> run_on_mount(params, session, opts) + |> Utils.update_socket_from_store_agent() + + {:ok, socket} + end + + defp run_on_mount(socket, params, session, opts), + do: run_on_mount(socket.assigns.store_agent, params, session, socket, opts) + + defp run_on_mount(agent, params, session, socket, opts) do + on_mount = if Keyword.get(opts, :on_mount, true), do: StoreAgent.get_on_mount(agent), else: [] + + on_mount + |> add_lic_hooks() + |> Enum.reduce(socket, &do_run_on_mount(&1, params, session, &2)) + end + + defp do_run_on_mount({module, first}, params, session, socket) do + {:cont, socket} = module.on_mount(first, params, session, socket) + socket + end + + defp do_run_on_mount(module, params, session, socket), + do: do_run_on_mount({module, :default}, params, session, socket) + + defp add_lic_hooks(list), + do: [Hooks.HandleEventSpyHook, Hooks.HandleInfoSpyHook, Hooks.AssignsUpdateSpyHook | list] +end From 22a81db1a7dc3047871981d57b105df8bfd7c6ab Mon Sep 17 00:00:00 2001 From: Sergio Arbeo Date: Fri, 6 Dec 2024 01:19:53 +0100 Subject: [PATCH 2/6] Fix compilation --- lib/live_isolated_component/view.ex | 1 - lib/live_isolated_component/view_utils.ex | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/live_isolated_component/view.ex b/lib/live_isolated_component/view.ex index 8854567..f70592b 100644 --- a/lib/live_isolated_component/view.ex +++ b/lib/live_isolated_component/view.ex @@ -2,7 +2,6 @@ defmodule LiveIsolatedComponent.View do @moduledoc false use Phoenix.LiveView - alias LiveIsolatedComponent.Hooks alias LiveIsolatedComponent.StoreAgent alias LiveIsolatedComponent.Utils alias LiveIsolatedComponent.ViewUtils diff --git a/lib/live_isolated_component/view_utils.ex b/lib/live_isolated_component/view_utils.ex index 34caf71..b63a69f 100644 --- a/lib/live_isolated_component/view_utils.ex +++ b/lib/live_isolated_component/view_utils.ex @@ -8,6 +8,7 @@ defmodule LiveIsolatedComponent.ViewUtils do alias LiveIsolatedComponent.MessageNames alias LiveIsolatedComponent.StoreAgent alias LiveIsolatedComponent.Utils + alias Phoenix.Component @doc """ Run this in your mock view `c:Phoenix.LiveView.mount/3`. @@ -18,7 +19,7 @@ defmodule LiveIsolatedComponent.ViewUtils do def mount(params, session, socket, opts \\ []) do socket = socket - |> assign(:store_agent, session[MessageNames.store_agent_key()]) + |> Component.assign(:store_agent, session[MessageNames.store_agent_key()]) |> run_on_mount(params, session, opts) |> Utils.update_socket_from_store_agent() From fd5f9b237dccc8a4ff99bef7c9a3ce7f200062b6 Mon Sep 17 00:00:00 2001 From: Sergio Arbeo Date: Sun, 22 Dec 2024 23:52:46 +0100 Subject: [PATCH 3/6] Add simple API for creating mock views --- lib/live_isolated_component.ex | 4 +- lib/live_isolated_component/view.ex | 74 ++++--------------- lib/live_isolated_component/view/live_view.ex | 4 + lib/live_isolated_component/view_utils.ex | 70 ++++++++++++++++++ 4 files changed, 90 insertions(+), 62 deletions(-) create mode 100644 lib/live_isolated_component/view/live_view.ex diff --git a/lib/live_isolated_component.ex b/lib/live_isolated_component.ex index 978aabe..156c668 100644 --- a/lib/live_isolated_component.ex +++ b/lib/live_isolated_component.ex @@ -84,7 +84,9 @@ defmodule LiveIsolatedComponent do } end) - live_isolated(build_conn(), Keyword.get(opts, :mock_view, LiveIsolatedComponent.View), + live_isolated( + build_conn(), + Keyword.get(opts, :mock_view, LiveIsolatedComponent.View.LiveView), session: %{ unquote(LiveIsolatedComponent.MessageNames.store_agent_key()) => store_agent } diff --git a/lib/live_isolated_component/view.ex b/lib/live_isolated_component/view.ex index f70592b..8783a4e 100644 --- a/lib/live_isolated_component/view.ex +++ b/lib/live_isolated_component/view.ex @@ -2,71 +2,23 @@ defmodule LiveIsolatedComponent.View do @moduledoc false use Phoenix.LiveView - alias LiveIsolatedComponent.StoreAgent - alias LiveIsolatedComponent.Utils - alias LiveIsolatedComponent.ViewUtils - alias Phoenix.LiveView.TagEngine + defmacro __using__(_opts) do + quote do + use Phoenix.LiveView - def mount(params, session, socket), do: ViewUtils.mount(params, session, socket) + @impl Phoenix.LiveView + defdelegate mount(params, session, socket), to: LiveIsolatedComponent.ViewUtils - def render(%{component: component, store_agent: agent, assigns: component_assigns} = _assigns) - when is_function(component) do - TagEngine.component( - component, - Map.merge( - component_assigns, - StoreAgent.get_slots(agent, component_assigns) - ), - {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} - ) - end + @impl Phoenix.LiveView + defdelegate render(assigns), to: LiveIsolatedComponent.ViewUtils - def render(assigns) do - new_inner_assigns = Map.put_new(assigns.assigns, :id, "some-unique-id") + @impl Phoenix.LiveView + defdelegate handle_info(event, socket), to: LiveIsolatedComponent.ViewUtils - assigns = Map.put(assigns, :assigns, new_inner_assigns) + @impl Phoenix.LiveView + defdelegate handle_event(event, params, socket), to: LiveIsolatedComponent.ViewUtils - ~H""" - <.live_component - id={@assigns.id} - module={@component} - {@assigns} - {@slots} - /> - """ + defoverridable mount: 3, render: 1 + end end - - def handle_info(event, socket) do - handle_info = socket |> Utils.store_agent_pid() |> StoreAgent.get_handle_info() - original_assigns = socket.assigns - - {:noreply, socket} = handle_info.(event, Utils.normalize_socket(socket, original_assigns)) - - {:noreply, Utils.denormalize_socket(socket, original_assigns)} - end - - def handle_event(event, params, socket) do - handle_event = socket |> Utils.store_agent_pid() |> StoreAgent.get_handle_event() - original_assigns = socket.assigns - - result = handle_event.(event, params, Utils.normalize_socket(socket, original_assigns)) - - Utils.send_to_test( - socket, - original_assigns, - {LiveIsolatedComponent.MessageNames.handle_event_result_message(), self(), - handle_event_result_as_event_param(result)} - ) - - denormalize_result(result, original_assigns) - end - - defp handle_event_result_as_event_param({:noreply, _socket}), do: :noreply - defp handle_event_result_as_event_param({:reply, map, _socket}), do: {:reply, map} - - defp denormalize_result({:noreply, socket}, original_assigns), - do: {:noreply, Utils.denormalize_socket(socket, original_assigns)} - - defp denormalize_result({:reply, map, socket}, original_assigns), - do: {:reply, map, Utils.denormalize_socket(socket, original_assigns)} end diff --git a/lib/live_isolated_component/view/live_view.ex b/lib/live_isolated_component/view/live_view.ex new file mode 100644 index 0000000..431ac43 --- /dev/null +++ b/lib/live_isolated_component/view/live_view.ex @@ -0,0 +1,4 @@ +defmodule LiveIsolatedComponent.View.LiveView do + @moduledoc false + use LiveIsolatedComponent.View +end diff --git a/lib/live_isolated_component/view_utils.ex b/lib/live_isolated_component/view_utils.ex index b63a69f..0e6205b 100644 --- a/lib/live_isolated_component/view_utils.ex +++ b/lib/live_isolated_component/view_utils.ex @@ -4,11 +4,14 @@ defmodule LiveIsolatedComponent.ViewUtils do mock LiveView to use with `m:LiveIsolatedComponent.live_isolated_component/2`. """ + import Phoenix.Component, only: [live_component: 1, sigil_H: 2] + alias LiveIsolatedComponent.Hooks alias LiveIsolatedComponent.MessageNames alias LiveIsolatedComponent.StoreAgent alias LiveIsolatedComponent.Utils alias Phoenix.Component + alias Phoenix.LiveView.TagEngine @doc """ Run this in your mock view `c:Phoenix.LiveView.mount/3`. @@ -26,6 +29,73 @@ defmodule LiveIsolatedComponent.ViewUtils do {:ok, socket} end + @doc """ + This function renders the given component in `component` (be it a function or a module) + with the given assigns and slots. + """ + def render(%{component: component, store_agent: agent, assigns: component_assigns} = _assigns) + when is_function(component) do + TagEngine.component( + component, + Map.merge( + component_assigns, + StoreAgent.get_slots(agent, component_assigns) + ), + {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} + ) + end + + def render(assigns) do + new_inner_assigns = Map.put_new(assigns.assigns, :id, "some-unique-id") + + assigns = Map.put(assigns, :assigns, new_inner_assigns) + + ~H""" + <.live_component + id={@assigns.id} + module={@component} + {@assigns} + {@slots} + /> + """ + end + + @doc false + def handle_info(event, socket) do + handle_info = socket |> Utils.store_agent_pid() |> StoreAgent.get_handle_info() + original_assigns = socket.assigns + + {:noreply, socket} = handle_info.(event, Utils.normalize_socket(socket, original_assigns)) + + {:noreply, Utils.denormalize_socket(socket, original_assigns)} + end + + @doc false + def handle_event(event, params, socket) do + handle_event = socket |> Utils.store_agent_pid() |> StoreAgent.get_handle_event() + original_assigns = socket.assigns + + result = handle_event.(event, params, Utils.normalize_socket(socket, original_assigns)) + + Utils.send_to_test( + socket, + original_assigns, + {LiveIsolatedComponent.MessageNames.handle_event_result_message(), self(), + handle_event_result_as_event_param(result)} + ) + + denormalize_result(result, original_assigns) + end + + defp handle_event_result_as_event_param({:noreply, _socket}), do: :noreply + defp handle_event_result_as_event_param({:reply, map, _socket}), do: {:reply, map} + + defp denormalize_result({:noreply, socket}, original_assigns), + do: {:noreply, Utils.denormalize_socket(socket, original_assigns)} + + defp denormalize_result({:reply, map, socket}, original_assigns), + do: {:reply, map, Utils.denormalize_socket(socket, original_assigns)} + defp run_on_mount(socket, params, session, opts), do: run_on_mount(socket.assigns.store_agent, params, session, socket, opts) From 8452be2a352b7fb08147573e5292bd33e0491018 Mon Sep 17 00:00:00 2001 From: Sergio Arbeo Date: Mon, 23 Dec 2024 00:21:35 +0100 Subject: [PATCH 4/6] Move dialyzer out of Elixir CI --- .github/workflows/dialyzer.yml | 40 +++++++++++++++++++++++++++++++++ .github/workflows/elixir-ci.yml | 15 +++---------- 2 files changed, 43 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/dialyzer.yml diff --git a/.github/workflows/dialyzer.yml b/.github/workflows/dialyzer.yml new file mode 100644 index 0000000..6c33389 --- /dev/null +++ b/.github/workflows/dialyzer.yml @@ -0,0 +1,40 @@ +name: Dialyzer + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] +env: + MIX_ENV: test + phoenix-version: 1.7.0 + phoenix-live-view-version: 1.0.0 + elixir: 1.18.0 + otp: 27.0 + +jobs: + test: + name: Build and test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Elixir + uses: ./.github/actions/setup-elixir + with: + elixir-version: ${{ env.elixir }} + otp-version: ${{ env.otp }} + phoenix-live-view-version: ${{ env.phoenix-live-view-version }} + phoenix-version: ${{ env.phoenix-version }} + - name: Retrieve PLT Cache + uses: actions/cache@v3 + id: plt-cache + with: + path: priv/plts + key: plts-v.2-${{ runner.os }}-${{ env.otp }}-${{ env.elixir }}-${{ env.phoenix-version }}-${{ env.phoenix-live-view-version }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + - name: Create PLTs + if: steps.plt-cache.outputs.cache-hit != 'true' + run: | + mkdir -p priv/plts + mix dialyzer --plt + - run: mix dialyzer \ No newline at end of file diff --git a/.github/workflows/elixir-ci.yml b/.github/workflows/elixir-ci.yml index 873cd9f..9e9582c 100644 --- a/.github/workflows/elixir-ci.yml +++ b/.github/workflows/elixir-ci.yml @@ -48,6 +48,9 @@ jobs: - elixir: 1.17.3 otp: 27.0 phoenix-live-view-version: 1.0.0 + - elixir: 1.18.0 + otp: 27.0 + phoenix-live-view-version: 1.0.0 steps: - uses: actions/checkout@v2 @@ -62,18 +65,6 @@ jobs: - run: mix test - run: mix format --check-formatted - run: mix credo --strict - - name: Retrieve PLT Cache - uses: actions/cache@v3 - id: plt-cache - with: - path: priv/plts - key: plts-v.2-${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ env.phoenix-version }}-${{ matrix.phoenix-live-view-version }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} - - name: Create PLTs - if: steps.plt-cache.outputs.cache-hit != 'true' - run: | - mkdir -p priv/plts - mix dialyzer --plt - - run: mix dialyzer - name: Run test app tests run: | cd test_app From 106b35bc8a1d05e616bbbd28eccd1f0f5f5ef7a1 Mon Sep 17 00:00:00 2001 From: Sergio Arbeo Date: Mon, 23 Dec 2024 00:40:18 +0100 Subject: [PATCH 5/6] Document LiveIsolatedComponent.View` --- lib/live_isolated_component/view.ex | 37 +++++++++++++++++++++-- lib/live_isolated_component/view_utils.ex | 22 ++++++++------ 2 files changed, 47 insertions(+), 12 deletions(-) diff --git a/lib/live_isolated_component/view.ex b/lib/live_isolated_component/view.ex index 8783a4e..45ccba8 100644 --- a/lib/live_isolated_component/view.ex +++ b/lib/live_isolated_component/view.ex @@ -1,5 +1,37 @@ defmodule LiveIsolatedComponent.View do - @moduledoc false + @moduledoc """ + This module serves as a starting point to creating your own + mock views for `LiveIsolatedComponent`. + + You might want to use custom mock views for multiple reasons + (using some custom UI library like `Surface`, having important + logic in hooks...). In any case, think whether or not the test + can work and test effectively the isolated behaviour of your + component. If that is not the case, you are welcomed to use + your own mock view. + + ## Custom `c:Phoenix.LiveView.mount/3` + + Just override the callback and make sure to call `LiveIsolatedComponent.ViewUtils.mount/3` + to properly initialize the socket to work with `LiveIsolatedComponent`. Refer to the + documentation of the util and to the callback for more specific usages. + + ## Custom `c:Phoenix.LiveView.render/1` + + The given assigns contain the following keys you can use to create your custom render: + + - `@component` contains the passed in component, be it a function or a module. + - `@assigns` contains the list of given assigns for the component. + - `@slots` for the given slots. If you are having problems rendering slots, use `LiveIsolatedComponent.ViewUtils.prerender_slots/1` + with the full assigns to get a pre-rendered list of slots. + + ## Custom `c:Phoenix.LiveView.handle_info/2` and `c:Phoenix.LiveView.handle_event/3` + + Either use an `m:Phoenix.LiveView.on_mount/1` hook or one of the options in + `m:LiveIsolatedComponent.live_isolated_component/2`. There is some convoluted + logic in these handles and already some work put on making them extensible with these + mechanisms to make overriding them worthy. + """ use Phoenix.LiveView defmacro __using__(_opts) do @@ -7,7 +39,8 @@ defmodule LiveIsolatedComponent.View do use Phoenix.LiveView @impl Phoenix.LiveView - defdelegate mount(params, session, socket), to: LiveIsolatedComponent.ViewUtils + def mount(params, session, socket), + do: {:ok, LiveIsolatedComponent.ViewUtils.mount(params, session, socket)} @impl Phoenix.LiveView defdelegate render(assigns), to: LiveIsolatedComponent.ViewUtils diff --git a/lib/live_isolated_component/view_utils.ex b/lib/live_isolated_component/view_utils.ex index 0e6205b..939ce79 100644 --- a/lib/live_isolated_component/view_utils.ex +++ b/lib/live_isolated_component/view_utils.ex @@ -17,29 +17,31 @@ defmodule LiveIsolatedComponent.ViewUtils do Run this in your mock view `c:Phoenix.LiveView.mount/3`. ## Options - - `:on_mmount`, _boolean_, defaults to `true`. Can disable adding `on_mount` hooks. + - `:on_mount`, _boolean_, defaults to `true`. Can disable adding `on_mount` hooks. """ def mount(params, session, socket, opts \\ []) do - socket = - socket - |> Component.assign(:store_agent, session[MessageNames.store_agent_key()]) - |> run_on_mount(params, session, opts) - |> Utils.update_socket_from_store_agent() - - {:ok, socket} + socket + |> Component.assign(:store_agent, session[MessageNames.store_agent_key()]) + |> run_on_mount(params, session, opts) + |> Utils.update_socket_from_store_agent() end + @doc """ + Use this function to get the slot list if for some reason is not working for you. + """ + def prerender_slots(assigns), do: StoreAgent.get_slots(assigns.store_agent, assigns.assigns) + @doc """ This function renders the given component in `component` (be it a function or a module) with the given assigns and slots. """ - def render(%{component: component, store_agent: agent, assigns: component_assigns} = _assigns) + def render(%{component: component, assigns: component_assigns} = assigns) when is_function(component) do TagEngine.component( component, Map.merge( component_assigns, - StoreAgent.get_slots(agent, component_assigns) + prerender_slots(assigns) ), {__ENV__.module, __ENV__.function, __ENV__.file, __ENV__.line} ) From 4204a79e6d9d52cd05319d7bc0028a290984456e Mon Sep 17 00:00:00 2001 From: Sergio Arbeo Date: Mon, 23 Dec 2024 00:45:10 +0100 Subject: [PATCH 6/6] Improve docs --- lib/live_isolated_component.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/live_isolated_component.ex b/lib/live_isolated_component.ex index 156c668..bbac46f 100644 --- a/lib/live_isolated_component.ex +++ b/lib/live_isolated_component.ex @@ -46,6 +46,7 @@ defmodule LiveIsolatedComponent do - `:assigns` accepts a map of assigns for the component. - `:handle_event` accepts a handler for the `handle_event` callback in the LiveView. - `:handle_info` accepts a handler for the `handle_info` callback in the LiveView. + - `:mock_view` accepts a Phoenix.LiveView to use as mock view. Please, refer to `LiveIsolatedComponent.View` for more info on how to customise these views. - `:on_mount` accepts a list of either modules or tuples `{Module, parameter}`. See `Phoenix.LiveView.on_mount/1` for more info on the parameters. - `:slots` accepts different slot descriptors.