Phoenix has nice flash messages. The problem I have with them is that by default there's only one displayed at a time, while others are hidden behind it, and clicking one of them closes all of them. And they never vanish by themselves. So I decided to fix that problem without changing the function call much.
New functions for adding flash messages
First, we need to define new functions that will send flash messages.
Let's create the MyAppWeb.Flash module:
defmodule MyAppWeb.Flash do
alias Phoenix.{LiveView, Controller, Socket}
# For live views
def put_flash!(%Socket{} = socket, kind, msg) do
{id, msg} = flash_id_and_msg(kind, msg)
LiveView.put_flash(socket, id, msg)
end
# For normal/non-live connections
def put_flash!(%Plug.Conn{} = conn, kind, msg) do
{id, msg} = flash_id_and_msg(kind, msg)
Controller.put_flash(conn, id, msg)
end
defp flash_id_and_msg(kind, msg) do
# For ordering
timestamp = DateTime.to_unix(DateTime.utc_now())
# To avoid duplicate ids, just in case
number = System.unique_integer()
{"flash-#{timestamp}-#{number}", {kind, msg}}
end
end
By default, flashes have id created from its kind which, by default, is just :info or :error.
Considering we want many of them, that's not good enough.
So we create a new id, created from the current time and random number.
With this, we can sort flashes in order of creation,
but we can also ensure that 2 flashes created at the same time will still be distinct.
JavaScript hook for flashes vanishing upon timeout
Before we write any elixir, we will create a FlashTimeout JS hook.
It will just remove the flash message after five seconds,
so it doesn't remain on the screen for too long.
So let's create assets/js/hooks/flash_timeout.js with:
export const FlashTimeout = {
mounted() {
setTimeout(() => {
this.pushEvent("lv:clear-flash", { key: this.el.id });
this.el.classList.add("hidden");
}, 5000);
},
};
This code just sends a message to the server to remove the flash message after 5 seconds. It also hides it in the frontend, so it vanishes immediately, for better UX.
With this, we can attach this hook in assets/js/app.js:
import { FlashTimeout } from "./hooks/flash_timeout";
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken },
hooks: { FlashTimeout }
})
Updating MyAppWeb.CoreComponents
After all that, we can start making changes in MyAppWeb.CoreComponents.
We know flash_group is called in layout templates like app.html.heex and live.html.heex
and it receives flashes there from @flash assign.
To edit them, we need to make the flash_group container be positioned,
as flashes will now be placed relative to that.
The other change is that we now need to iterate over available flashes to display them all.
We will sort them, to have the earliest flashes on top.
So, in MyAppWeb.CoreComponents:
def flash_group(assigns) do
~H"""
<div id={@id} class="fixed z-10000">
<.flash :for={{id, {kind, msg}} <- Enum.sort_by(@flash, &elem(&1, 0))} id={id} kind={kind}>
{msg}
</.flash>
<.flash id="client-error" no_timeout={true}>
<!-- Default attrs and content of no connection flash... -->
</.flash>
<!-- Default code for no ws connection, etc. With `no_timeout` attr added... -->
</div>
"""
end
Then we need to update the flash function:
attr :id, :string, required: true
attr :no_timeout, :boolean, default: nil
# Other attrs
def flash(assigns) do
~H"""
<div
:if={msg = render_slot(@inner_block) || Phoenix.Flash.get(@flash, @kind)}
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @id}) |> hide("##{@id}")}
phx-hook={@no_timeout && "FlashTimeout"}
{@rest}
>
<!-- Default content for flash messages function... -->
</div>
"""
end
With this, we should end up with flash messages appearing one after the other. You probably should add some more styling to make them look nice again, but all needed functionality is already here.