Skip to content

Commit

Permalink
Create ViewUtils and let the mock view be passed in. (#42)
Browse files Browse the repository at this point in the history
* Extract mount to ViewUtils

* Fix compilation

* Add simple API for creating mock views

* Move dialyzer out of Elixir CI

* Document LiveIsolatedComponent.View`

* Improve docs
  • Loading branch information
Serabe authored Dec 22, 2024
1 parent b7c794e commit 1c2f19d
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 100 deletions.
40 changes: 40 additions & 0 deletions .github/workflows/dialyzer.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 3 additions & 12 deletions .github/workflows/elixir-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lib/live_isolated_component.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 1 addition & 3 deletions lib/live_isolated_component/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
124 changes: 40 additions & 84 deletions lib/live_isolated_component/view.ex
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions lib/live_isolated_component/view/live_view.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
defmodule LiveIsolatedComponent.View.LiveView do
@moduledoc false
use LiveIsolatedComponent.View
end
122 changes: 122 additions & 0 deletions lib/live_isolated_component/view_utils.ex
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 1c2f19d

Please sign in to comment.