Enable/disable a button in Phoenix 1.7 depending on state

Background

I have a button that may be disabled or not, depending on a set of conditions. I want to disable/enable the button without having to reload the page.

To achieve this I am using the following code:

my_app_live.ex

#provided as a sample
def disable_buttton? do
  if :rand.uniform(100) > 50 do
    "true"
  else
    "false"
  end
end

my_app.html.heex

<.button disabled={disable_button?)}>Execute Command</.button>

core_components.ex

  def button(assigns) do
    extra = assigns_to_attributes(assigns, [:disabled])

    assigns = assign(assigns, :disabled?, case Map.get(extra[:rest], :disabled) do
      "true" -> true
      _ -> false
    end)

    ~H"""
    <p><%= "INSIDE BUTTON: #{inspect(@disabled?)}" %></p>
    <button
      type={@type}
      class={if @disabled? do
      [
        "rounded-md bg-slate-400 px-3 py-2 text-sm font-semibold text-white shadow-sm",
        @class
      ]
    else
      [
        "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm",
        "active:text-white/80",
        @class
      ]
    end}
      {@rest}
    >
      <%= render_slot(@inner_block) %>
    </button>
    """
  end

Problem

For some reason, <p><%= "INSIDE BUTTON: #{inspect(@disabled?)}" %></p> always shows true and thus the button is always disabled.

In reality disable_buttton? may return true/false depending on which buttons are selected (if all forms have a value, then the button should be enabled), however I am unaware of any pattern to do this in Phoenix.

I am also not convinced I am using assigns properly.

Questions

  1. How can I enable/disable a button in Phoenix, depending on state?
  2. Am I using assigns_to_attributes and assign correctly in this sample?

I read Phoenix.Component — Phoenix LiveView v0.19.5 but I still don’t quite understand what I am missing here.

Not really. If you’re pulling out values out of assigns.rest you’re doing something wrong. The attribute shouldn’t be put into the catch all @rest in the first place, but be an explicit attr.

attr :disabled, :boolean, [opts]
attr :rest, :global, [opts]

def button(assigns) do
  # assigns[:disabled] or assigns.disabled (if you configure a default or the attr is required)
  …
end

Are you sure this is not releated to your :rand usage / function call? Do things work if you manually pass true or false?

1 Like

There is no visible change happening in this code. So Phoenix will render it once and never change it unless you reload the page. If instead you have a button or a timer trigger the rand value and store it in an assign, it will work.

You could say “well, there is a change, it is random”. But if Phoenix assumed that every code had a random component, then we would not be able to change tracking anything ever.

TL;DR: Use assigns for changing state.

4 Likes

I am confused. After reading Global Attributes (https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html#module-global-attributes) I thought I could simply do @disabled because the button function is defined with the following attributes:

  attr :type, :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(disabled form name value)

  slot :inner_block, required: true

However, when using @disabled the code crashes. So I am guessing I am doing something wrong. What have I missed?

If I pass the value manually, i.e., <.button disabled={"false"}>Execute Command</.button>, then the button has the correct colour and appearance, but when I click it nothing happens, which means it is still disabled, even though the value is false.

This is rather confusing to me.

Very well, I have added a timer that changes random_number every second and I have assigned it with assigns |> assign(assigns, :random_numer, val).

Let us assume for this sample, that @random_number will be between 0 - 100 and it changes periodically.
As a consequence, the code is now:

my_app.html.heex

<.button disabled={"#{@random_number > 50}"}>Execute Command</.button>

Which, in conjunction with the core_components.ex:

  attr :type, :string, default: nil
  attr :class, :string, default: nil
  attr :rest, :global, include: ~w(disabled form name value)

  slot :inner_block, required: true

  def button(assigns) do
    extra = assigns_to_attributes(assigns, [:disabled])

    assigns = assign(assigns, :disabled?, case Map.get(extra[:rest], :disabled) do
      "true" -> true
      _ -> false
    end)

    ~H"""

    <button
      type={@type}
      class={if @disabled? do
      [
        "rounded-md bg-slate-400 px-3 py-2 text-sm font-semibold text-white shadow-sm",
        @class
      ]
    else
      [
        "rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm",
        "active:text-white/80",
        @class
      ]
    end}
      {@rest}
    >
      <%= render_slot(@inner_block) %>
    </button>
    """
  end

Now changes the colour of the button correctly, from bg-slate-400 to bg-indigo-600. However, I have more questions now:

  1. Is there a way to move the condition @random_number > 50 to a function inside my_app_live.ex instead of simply having it in the heex file and still have this behaviour work?
  2. As mentioned in the reply to @LostKobrakai , the button still works as if it was disabled. Am I missing something from the correct usage of the global attribute @rest ?
  3. What would be the proper way to access disabled ?

Thanks everyone for the replies and help!

@rest is mean as a “catch all” for all the attributes you’re not explicitly interested in / you don’t use besides passing them onwards.

Any other attributes should have an explicit attr definition.

You’re not passing false, you’re passing the string "false", which is a truthy value in elixir.

2 Likes

So I understand you advocate I should change to:

  attr :type, :string, default: nil
  attr :class, :string, default: nil
  attr :disabled, boolean, default: false
  attr :rest, :global, include: ~w(form name value)

  slot :inner_block, required: true

correct?

So, in Phoenix LV, if the value is false the attribute disabled is removed from the node, while if it is any other thing, the node gets the attribute as disabled="something else", which means that because disabled is still present, irrespective of value, it is considered as a disabled button.

I didn’t know about this. So this is yet another thing I learned :smiley:

So, for those who are curious, this is the code I ended up with:

core_components.ex

This is my modified button function:

  @doc """
  Renders a button.

  ## Examples

      <.button>Send!</.button>
      <.button phx-click="go" class="ml-2" disabled=false>Send!</.button>
  """
  attr :type, :string, default: nil
  attr :class, :string, default: nil
  attr :disabled, :boolean, default: false
  attr :rest, :global, include: ~w(form name value)

  slot :inner_block, required: true

  def button(assigns) do
    ~H"""
    <button
      type={@type}
      class={if @disabled do
      [
        "rounded-md bg-slate-400 px-3 py-2 text-sm font-semibold text-white shadow-sm",
        @class
      ]
    else
      [
        "phx-submit-loading:opacity-75 rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm",
        "hover:bg-indigo-500 active:text-white/80",
        @class
      ]
    end}
      {@rest}
      disabled={@disabled}
    >
      <%= render_slot(@inner_block) %>
    </button>
    """
  end

And here is how I use it:

my_app_live.html.heex

<.button class="min-w-full" disabled={disable_button?(@param1, @state)}>Execute Command</.button>

This depends on the state, because the state changes with time, which means my button’s appearance will also change with time!

thanks everyone for the help!

2 Likes