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.

8 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.

1 Like

I am having a similar issue.

I have something like

<%= for bla <- blabla do %>
     <div
        id={"#{bla}"}
        phx-remove={
          JS.transition({"ease-out duration-1000", "opacity-100", "opacity-0"},
            time: 1000
          )
        }
      >
        <%= bla %>
      </div >
 <% end %>

When I remove an element from the list blabla on the server, the new list of divs is re-render and the remove element is nicely faded-out except that before the element starts fading out, it first drops one level in the list. As if it was moved lower on the list of divs (I think that’s actually what’s happening in the DOM from what I can see in my inspector).

Any trick to avoid that “bump before remove animation” would be much appreciated :pray:

It’s not a solution, but it’s a hypothesis of what is happening. It sounds like the DOM updates/patches are being applied sequentially, so the item that replaced the deleted item is being placed at the newly-correct index just before the deletion is applied to the DOM, which then triggers the transition effect. This would result in the swapping of the items.

If that is the case, I don’t know off the top of my head how to solve it.

1 Like

Thanks for the hypothesis. That would make sense. You’d almost need a new phx-remove-before-reorder binding… Without something like this, there’s maybe no way around using a more involved custom JS hook.

Deleted

Ok, here is a workaround. Much hackier than one would like. And only works if the container’s display is flex. But does the job.

If you’re in a display: flex container, you can use the order CSS-attribute to control the order in which elements appear independent of their order in the DOM.

So you can quickly update the order of the soon-to-be-deleted element before the remove side-effect takes over to make sure it does not visually shifts (despite having been demoted one rank in the DOM order).

Updating my example above, it would look something like this:

<div class="flex">
  <%= for {bla, index} <- Enum.with_index(blabla) do %>
       <div
          style={"order: #{2 * index};"}
          phx-remove={
            JS.set_attribute({"style", "order: #{2 * index - 1}"})
            |> JS.transition({"ease-out duration-1000", "opacity-100", "opacity-0"},
              time: 1000
            )
          }
        >
          <%= bla %>
        </div >
   <% end %>
</div>

If you’re using Tailwind, you could try using classes instead of relying on the style attribute: Order - Tailwind CSS. But I couldn’t make my example work with classes and preset TW classes will limit the number of elements you can include in your list (preset TW order classes currently go from order-1 to order-12).

As a more elegant CSS option that removes a good chunk of your template code, could you use a calc off of a data attribute off the html tag to set the flex order? Then you could compose a .item-deleted class that does the calculation offset by one, and you simply need to set the attribute in your template and apply the utility class to the deleted item instead.

1 Like