Using LiveView client hook to format a phone number that is saved as 10 digits in Ecto

Hi,

I’m curious if anyone has any suggestions for how to handle situations where the formatting of a field in the UI (via a LiveView client hook in JS) differs from how the data is stored in Ecto.

I’m storing a phone number in Ecto as a string of 10 digits with no formatting. I’m now trying to use the PhoneNumber hook at https://hexdocs.pm/phoenix_live_view/js-interop.html#client-hooks to format the phone number in JS (slightly different JS code but same idea). This works fine when changing the phone number field, however when I make changes to other fields that validate server side on blur, the changeset is sent back from server to client including the phone number as a string of 10 digits, which then shows up as just 10 digits in the form.

It feels like the cleanest way to handle this will be to have the JS code intercept sends to the server (removing formatting before doing so) and intercepts receives from the server (adding formatting back). This way my Elixir code can keep functioning as it does throughout the code base.

Has anyone else dealt with an issue like this? I thought perhaps the updated or beforeUpdate client hooks could be used to intercept incoming server side changes, but that didn’t work and validation of other fields can still leave me in a state where the previously formatted phone number shows up as 10 digits.

Any guidance or tips are appreciated :pray:

Thanks.
Justin

Here is one way I can make this work.

First, I make sure my changeset strips out non-numbers for all phone number fields.

  def strip_non_numbers(changeset, fields) do
    Enum.reduce(fields, changeset, fn field, acc ->
      acc
      |> Ecto.Changeset.update_change(field, fn value ->
        value = if is_nil(value), do: "", else: value

        Regex.replace(~r{\D}, value, "")
      end)
    end)
  end

Next, before returning the socket that contains the changeset in assigns, I add some formatted fields to assigns, as follows.

  defp assign_formatted_phone_numbers(socket, phone_fields) do
    Enum.reduce(phone_fields, socket, fn field, acc ->
      formatted =
        socket.assigns.changeset
        |> Ecto.Changeset.get_change(field, "")
        |> format_phone_number()

      acc
      |> assign(:"formatted_#{field}", formatted)
    end)
  end

Then in my form I explicitly specify the value using the formatted assigns.

<%= text_input f, :primary_phone_number, phx_debounce: "blur", class: "my-class", type: "tel", phx_hook: "PhoneNumber", value: @formatted_primary_phone_number %>

I still have to duplicate the formatting logic in JS, but that does seem horrible. I’m open to any input. Thanks.

1 Like

It’s an interesting use case…

I tend to say that it’s a “presentation” issue, so it’s not the responsibility of your backend…
But with LiveView, we can safely consider that it is your Frontend and its responsibility to format.

So I want to say that it’s correct to have the formatting in your LiveView as you did.

You could also use Ecto’s prepare_changes (seems to be what you’re doing in strip_non_numbers) but I’m not sure if it’s the correct way.
In a sense, yes, it’s just like your own type and casting (maybe take also a look at Ecto.Type for a custom Type).

If it’s correctly handled at the Ecto boundary you don’t need to duplicate that logic in JS. The hook will still be here for providing formatting as-you-type, though, if I correctly understood your needs.

Sorry, if I cannot provide much ideas but I’m interested to see how this will turn…

Please, keep up to date…

1 Like

Not sure if this will help you, but I wrote a Medium guide on how I implemented an international phone number field in my Phoenix Live View multi-step form.

It’s part 3 of a series on swapping the registration flow from phx_gen_auth over to Live View.

:orange_heart:

1 Like

You can use your own type

 alias Connect.PhoneType
schema "contacts" do
    field :mobile, PhoneType
end
defmodule Connect.PhoneType do
  use Ecto.Type
  def type, do: :string

  alias Connect.Utils

  def cast(ph_num) when is_binary(ph_num) do
    is_group = if Regex.run(~r/@g.us/, ph_num), do: true, else: false

    {_international, local, _raw} =
      case Utils.parsePhone(ph_num) do
        {:ok, i, l, r} -> {i, l, r}
        _ -> {ph_num, ph_num, ph_num}
      end

    local = if is_group, do: ph_num, else: local

    {:ok, local}
  end

  def load(ph_num) when is_binary(ph_num) do
    is_group = if Regex.run(~r/@g.us/, ph_num), do: true, else: false

    {ref, _international, local, _raw} =
      case Utils.parsePhone(ph_num) do
        {:ok, i, l, r} -> {:ok, i, l, r}
        _ -> {:ok, ph_num, ph_num, ph_num}
      end

    local = if is_group, do: ph_num, else: local

    {ref, local}
  end

  def dump(ph_num) do
# dump will return the raw phone +17189999999
    is_group = if Regex.run(~r/@g.us/, ph_num), do: true, else: false

    {_international, _local, raw} =
      case Utils.parsePhone(ph_num) do
        {:ok, i, l, r} -> {i, l, r}
        _ -> {ph_num, ph_num, ph_num}
      end

    raw = if is_group, do: ph_num, else: raw

    {:ok, raw}
  end

  defp process_loaders(_, {:error, _num}, _adapter) do
    {:ok}
  end
end