How to show a modal from the server and hide with Phoenix.LiveView.JS and repeat that

I want to trigger the show of a modal from the server every 2 seconds. The user can hide it by clicking on it but after another 2 seconds it should reappear.

With this code I can show the modal once and have the user hide it but not again. Not in a loop.

Is that possible at all? How?

defmodule Demo3Web.TestLive do
  use Demo3Web, :live_view
  @refresh_rate 2000

  alias Phoenix.LiveView.JS

  def mount(_params, _session, socket) do
    if connected?(socket), do: Process.send_after(self(), :tick, @refresh_rate)

    socket =
      socket
      |> assign(show_modal: false)

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <%= if @show_modal == true do %>
      <div id="modal" class="phx-modal" phx-remove={hide_modal()}>
        <div
          id="modal-content"
          class="phx-modal-content"
          phx-click-away={hide_modal()}
          phx-window-keydown={hide_modal()}
          phx-key="escape"
        >
          <button class="phx-modal-close" phx-click={hide_modal()}>✖</button>
          <p>Time is up. Again!</p>
        </div>
      </div>
    <% end %>
    """
  end

  def hide_modal(js \\ %JS{}) do
    js
    |> JS.hide(to: "#item")
    |> JS.hide(transition: "fade-out", to: "#modal")
    |> JS.hide(transition: "fade-out-scale", to: "#modal-content")
  end

  def handle_info(:tick, socket) do
    Process.send_after(self(), :tick, @refresh_rate)

    socket =
      socket
      |> assign(show_modal: true)

    {:noreply, socket}
  end
end

@show_modal is never set to false after you set it to true. So you hide it on the client and then it stays hidden.

You need to make it so closing the modal still sends an event to the server, so you actually hide it on the server too. There is JS.push.

Alternatively, move the whole logic to JS. Right now the control is split both ways. The best way to use the JS stuff is to:

  1. Do actions earlier in the browser that would be done by the server anyway (similar to optimistic UI)

  2. Do actions fully on the client without involving the server

1 Like

Thanks @josevalim !

I updated the example code accordingly.

defmodule Demo3Web.TestLive do
  use Demo3Web, :live_view
  @refresh_rate 5000

  alias Phoenix.LiveView.JS

  def mount(_params, _session, socket) do
    if connected?(socket), do: Process.send_after(self(), :tick, @refresh_rate)

    socket =
      socket
      |> assign(show_modal: false)

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <%= if @show_modal == true do %>
      <div id="modal" class="phx-modal" phx-remove={hide_modal()}>
        <div
          id="modal-content"
          class="phx-modal-content"
          phx-click-away={hide_modal()}
          phx-window-keydown={hide_modal()}
          phx-key="escape"
        >
          <button class="phx-modal-close" phx-click={hide_modal()}>✖</button>
          <p>Time is up. Again!</p>
        </div>
      </div>
    <% end %>
    """
  end

  def hide_modal(js \\ %JS{}) do
    js
    |> JS.hide(to: "#item")
    |> JS.hide(transition: "fade-out", to: "#modal")
    |> JS.hide(transition: "fade-out-scale", to: "#modal-content")
    |> JS.push("clicked")
  end

  def handle_info(:tick, socket) do
    Process.send_after(self(), :tick, @refresh_rate)

    socket =
      socket
      |> assign(show_modal: true)

    {:noreply, socket}
  end

  def handle_event("clicked", _, socket) do
    socket =
      socket
      |> assign(show_modal: false)

    {:noreply, socket}
  end
end

Another way of showing the modal from the server is to push_event/3 to a hook and then liveSocket.execJS.

https://hexdocs.pm/phoenix_live_view/0.17.2/js-interop.html#executing-js-commands

1 Like

tl;dr It seems like it should be very simple to essentially trigger a hide_modal based on the response of a handle_params but I can’t seem to figure it out… am I crazy?

I’m running into an interesting problem on this front w/ the browser back button.

Imagine a list view where you can click on an item and you want to:

  1. Show the modal right away but in a loading state
  2. Load the record async
  3. Update the modal w/ the content

My link looks like this:

phx-click={show_modal("my-modal") |> JS.push("load_modal_content", value: %{"id" => some_id})}

I’m using using a live_action to indicate a show route.

If you hit the back button the URL updates to remove the ID, the handle_params fires showing the param is no longer there and you can clear out the socket state, but the modal is still open, presumably because we manually opened it on phx-click. I’m at a loss on how to close the modal…

Hmm, that is an interesting problem and I imagine one that applies more broadly to any client interactions that change the UI with JS Commands that execute utility operations only on the client.

Have you tried pairing push_event/3 and liveSocket.execJS as suggested by @nwjlyons? For example, push an event that ensures all modals are hidden when handle_params fires without the id param.

def handle_params(params, url, socket) do
    unless params["id"], do: push_event(socket, "ensure-modals-hidden")
    # or
    unless Map.has_key?(params, "id"), do: push_event(socket, "ensure-modals-hidden")
    ...
end
let liveSocket = new LiveSocket(...)
window.addEventListener(`phx:ensure-modals-hidden`, (e) => {
  // adapted this from the example in the liveSocket.execJS docs
  // but this could be refactored/tailored for your use case
  document.querySelectorAll(`[data-handle-hide-modal]`).forEach(el => {
    liveSocket.execJS(el, el.getAttribute("data-handle-hide-modal"))
  })
})
<div id={"item-#{item.id}"} class="item"
  data-handle-hide-modal={hide_modal("my-modal")} <!-- this could also be on the modal itself --> 
  phx-click={show_modal("my-modal") |> JS.push("load_modal_content", value: %{"id" => some_id})}
>
    ...
</div>