LiveView: Is there a way to FIRST delete item from repo and THEN animate out the corresponding UI element?

GOAL
In one of my practice projects, I have a list of items that are stored in a Postgres database and displayed as <li></li> items in the UI. When a user requests an item to be deleted, the item should be deleted from the database and removed from the UI with a certain animation.

CURRENT SOLUTION
My current solution is to first animate out the item from the UI and on completion send a deletion request to the LiveView server. If deletion fails, the item is animated back in.

Specifically:

<!-- HTML page -->
<button class="warning" phx-click={JS.dispatch("delete-task")} data-id={@id}>Delete</button>

// JS Mounted Hook
export default Hooks = {}

Hooks.CustomEventListeners = {
  mounted() {
  
    window.addEventListener('delete-task', e => {
      const task = e.detail.dispatcher
      const taskId = task.dataset.id
      if (confirm('Please confirm you want to delete this task.')) {
        this.animateOut(taskId)
        setTimeout(() => this.pushEvent('delete', {id: taskId}), this.ANIMATION_DURATION)
      } else {
        const target = document.querySelector(`#task-${taskId}`)
        const event = new Event('cancel-menu', {bubbles: true, cancelable: true})
        target.dispatchEvent(event)
      }
    })

  },
  ANIMATION_DURATION: 500,
  animateOut(id) {
    const task = document.querySelector(`#task-${id}`)
    const targetHeight = task.offsetHeight
    task.animation = task.animate(
      [
        {height: `${targetHeight}px`, transform: 'none', margin: '8px 0px'},
        {height: '0px', transform: 'rotateY(-90deg)', margin: '-4px 0px'}
      ],
      {
        duration: this.ANIMATION_DURATION,
        easing: 'cubic-bezier(.36, -0.64, .34, 1.76)',
        fill: 'both'
      }
    )
  }
}

# LiveView

  def mount(_params, _session, socket) do
    if connected?(socket), do: Tasks.subscribe()

    socket =
      socket
      |> assign(...)

    {:ok, socket}
  end

  def handle_info({:task_deleted, task}, socket) do
    tasks =
      socket.assigns[:tasks]
      |> Enum.filter(fn t -> t.id != task.id end)

    socket =
      socket
      |> assign(tasks: tasks)
    {:noreply, socket}
  end

  def handle_event("delete", %{"id" => id}, socket) do
    case Tasks.delete_task_by_id(id) do
      {:ok, _} ->
        socket =
          socket
          |> put_flash(:info, "Task deleted.")
        {:noreply, socket}
      {_, _} ->
        socket =
          socket
          |> put_flash(:error, "Could not delete task.")
          |> push_redirect(to: "/", replace: true)
        {:noreply, socket}
    end
  end

PREFERRED SOLUTION
It bugs me, however, that I haven’t been able to figure out a way to reverse the order: first delete the item from the database, and in case of success, animate the UI element out. I would like to master the control over LiveView, in combination with JavaScript, to be able to solve this, and similar, problems.

In a JS-only application you would be able to separate the database deletion and the removal of the corresponding element from the DOM. I haven’t figured out how to do the equivalent with LiveView. Since there isn’t a thread blocking beforeDestroy hook, I got the impression it might not be in the stars altogether at this point. But I love to be proven wrong.

Curious to your ideas…

you shouldn’t need any of the javascript. Something like this should be it (provided the tasks have their own DOM ids), replace tailwind classes with whatever you have:

~H"""
<%= for task <- @tasks do %>
  <div id={"task-#{task.id}"} phx-remove={fade_out()}>...
<% end %>
"""

def fade_out(%JS{} \\ js) do
  JS.hide(transition:
    {"transition-all transform ease-in duration-300", "opacity-100 scale-100", "opacity-0 scale-95"}
  )
end

The phx-remove will apply when the container is removed from the DOM, so you can simply let the LV remove the task.

6 Likes

That’s great. Happily proven wrong. Thank you.

Think I’ll dive into LiveView’s source code also to get a detailed look at what happens in LiveView.JS. Curious what that js struct is that if passed to the Client Utility Commands, like JS.hide.

I got this solution to work… mostly. A list item is deleted and the DOM element of that item first animates out and then gets removed from the DOM. However, the list element jumps to another position in the list at the instance that its item is deleted from the database. If I don’t set a setTimeout on the delete-from-database request, the UI item jump to another position immediately. If I do set a setTimeout the UI element starts the animation in the right list position, but as soon as the timeout is over the item jumps. So, if I set a timeout equal to the animation duration, the user sees the animation as it is intended.

Also, I was wondering whether onBeforeElUpdated (docs) could also be used to animate out the list items. In the docs it is mentioned that onBeforeElUpdated can be used for “For integration with client-side libraries which require a broader access to full DOM management, …”. I am not making a library, but I try to get a feeling for when onBeforeElUpdated is the right tool for the job at hand.

I noticed for example that when I simply console log the from and to of onBeforeElUpdated(from, to), I don’t get logs when I add a new list item. My initial suspicion was that the to would still log a DOM node, since LiveView does update.

I am interested in a possible alternative solution for the one that was posted yesterday for the sake of learning but also because I have JS calculations (e.g. Window.getComputedStyle()) that need to be done before some of my animations.