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 diff --git a/lib/live_isolated_component.ex b/lib/live_isolated_component.ex index b3a86dd..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. @@ -84,7 +85,9 @@ defmodule LiveIsolatedComponent do } end) - live_isolated(build_conn(), 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/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..45ccba8 100644 --- a/lib/live_isolated_component/view.ex +++ b/lib/live_isolated_component/view.ex @@ -1,101 +1,57 @@ defmodule LiveIsolatedComponent.View do - @moduledoc false - use Phoenix.LiveView - - alias LiveIsolatedComponent.Hooks - alias LiveIsolatedComponent.StoreAgent - alias LiveIsolatedComponent.Utils - 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 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") + @moduledoc """ + This module serves as a starting point to creating your own + mock views for `LiveIsolatedComponent`. - assigns = Map.put(assigns, :assigns, new_inner_assigns) + 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. - ~H""" - <.live_component - id={@assigns.id} - module={@component} - {@assigns} - {@slots} - /> - """ - end - - def handle_info(event, socket) do - handle_info = socket |> Utils.store_agent_pid() |> StoreAgent.get_handle_info() - original_assigns = socket.assigns + ## Custom `c:Phoenix.LiveView.mount/3` - {:noreply, socket} = handle_info.(event, Utils.normalize_socket(socket, original_assigns)) + 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. - {:noreply, Utils.denormalize_socket(socket, original_assigns)} - end + ## Custom `c:Phoenix.LiveView.render/1` - def handle_event(event, params, socket) do - handle_event = socket |> Utils.store_agent_pid() |> StoreAgent.get_handle_event() - original_assigns = socket.assigns + The given assigns contain the following keys you can use to create your custom render: - result = handle_event.(event, params, Utils.normalize_socket(socket, original_assigns)) + - `@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. - Utils.send_to_test( - socket, - original_assigns, - {LiveIsolatedComponent.MessageNames.handle_event_result_message(), self(), - handle_event_result_as_event_param(result)} - ) + ## Custom `c:Phoenix.LiveView.handle_info/2` and `c:Phoenix.LiveView.handle_event/3` - denormalize_result(result, original_assigns) - end + 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 - defp handle_event_result_as_event_param({:noreply, _socket}), do: :noreply - defp handle_event_result_as_event_param({:reply, map, _socket}), do: {:reply, map} + defmacro __using__(_opts) do + quote do + use Phoenix.LiveView - defp denormalize_result({:noreply, socket}, original_assigns), - do: {:noreply, Utils.denormalize_socket(socket, original_assigns)} + @impl Phoenix.LiveView + def mount(params, session, socket), + do: {:ok, LiveIsolatedComponent.ViewUtils.mount(params, session, socket)} - defp denormalize_result({:reply, map, socket}, original_assigns), - do: {:reply, map, Utils.denormalize_socket(socket, original_assigns)} + @impl Phoenix.LiveView + defdelegate render(assigns), to: LiveIsolatedComponent.ViewUtils - defp run_on_mount(socket, params, session), - do: run_on_mount(socket.assigns.store_agent, params, session, socket) + @impl Phoenix.LiveView + defdelegate handle_info(event, socket), to: LiveIsolatedComponent.ViewUtils - 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 + @impl Phoenix.LiveView + defdelegate handle_event(event, params, socket), to: LiveIsolatedComponent.ViewUtils - defp do_run_on_mount({module, first}, params, session, socket) do - {:cont, socket} = module.on_mount(first, params, session, socket) - socket + defoverridable mount: 3, render: 1 + end 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/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 new file mode 100644 index 0000000..939ce79 --- /dev/null +++ b/lib/live_isolated_component/view_utils.ex @@ -0,0 +1,122 @@ +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`. + """ + + 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`. + + ## Options + - `:on_mount`, _boolean_, defaults to `true`. Can disable adding `on_mount` hooks. + """ + def mount(params, session, socket, opts \\ []) do + 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, assigns: component_assigns} = assigns) + when is_function(component) do + TagEngine.component( + component, + Map.merge( + component_assigns, + prerender_slots(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) + + 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