lib/cldr_html_currencies.ex

if match?({:module, _}, Code.ensure_compiled(Cldr.Currency)) do
  defmodule Cldr.HTML.Currency do
    @moduledoc """
    Implements `Phoenix.HTML.Form.select/4` specifically for
    localised currency display.

    """

    @type select_options :: [
            {:currencies, [atom() | binary(), ...]}
            | {:locale, Cldr.Locale.locale_name() | Cldr.LanguageTag.t()}
            | {:collator, function()}
            | {:mapper, (Cldr.Currency.t() -> String.t())}
            | {:backend, module()}
            | {:selected, atom() | binary()}
          ]

    @doc """
    Generate an HTML select tag for a currency list
    that can be used with a `Phoenix.HTML.Form.t`.

    ## Arguments

    * A `Phoenix.HTML.Form.t()` form

    * A `Phoenix.HTML.Form.field()` field

    * A `Keyword.t()` list of options

    ## Options

    For select options see `Phoenix.HTML.Form.select/4`

    * `:currencies` defines the list of currencies to be
      displayed in the the `select` tag.  The list defaults to
      the currencies returned by `Money.known_tender_currencies/0`
      if the package [ex_money](https://hex.pm/packages/ex_money)
      is installed otherwise it is the list returned by
      `Cldr.known_currencies/0`

    * `:locale` defines the locale to be used to localise the
      description of the currencies.  The default is the locale
      returned by `Cldr.get_locale/1`

    * `:backend` is any backend module. The default is
      `Cldr.default_backend!/0`

    * `:collator` is a function used to sort the currencies
      in the selection list. It is passed a list of maps where
      each map represents a `t:Cldr.Currency`. The default collator
      sorts by `name_1 < name_2`. As a result, default collation
      sorts by code point which will not return expected results
      for scripts other than Latin.

    * `:mapper` is a function that creates the text to be
      displayed in the select tag for each currency.  It is
      passed the currency definition `t:Cldr.Currency` as returned by
      `Cldr.Currency.currency_for_code/2`.  The default function
      is `&({&1.code <> " - " <> &1.name, &1.code})`

    * `:selected` identifies the currency that is to be selected
      by default in the `select` tag.  The default is `nil`. This
      is passed unmodified to `Phoenix.HTML.Form.select/4`

    * `:prompt` is a prompt displayed at the top of the select
       box. This is passed unmodified to `Phoenix.HTML.Form.select/4`

    # Examples

         => Cldr.HTML.Currency.select(:my_form, :currency, selected: :USD)
         => Cldr.HTML.Currency.select(:my_form, :currency, currencies: ["USD", "EUR", :JPY], mapper: &({&1.name, &1.code}))

    """
    @spec select(
            form :: Phoenix.HTML.Form.t(),
            field :: Phoenix.HTML.Form.field(),
            select_options
          ) ::
            Phoenix.HTML.safe()
            | {:error, {Cldr.UnknownCurrencyError, binary()}}
            | {:error, {Cldr.UnknownLocaleError, binary()}}

    def select(form, field, options \\ [])

    def select(form, field, options) when is_list(options) do
      select(form, field, validate_options(options), options[:selected])
    end

    @doc """
    Generate a list of options for a currency list
    that can be used with `Phoenix.HTML.Form.select/4`,
    `Phoenix.HTML.Form.options_for_select/2` or
    to create a <datalist>.

    ## Arguments

    * A `Keyword.t()` list of options.

    ## Options

    See `Cldr.HTML.Currency.select/3` for options.

    """
    @spec currency_options(select_options) ::
            list(tuple())
            | {:error, {Cldr.UnknownCurrencyError, binary()}}
            | {:error, {Cldr.UnknownLocaleError, binary()}}

    def currency_options(options \\ [])

    def currency_options(options) when is_list(options) do
      options
      |> validate_options()
      |> build_currency_options()
    end

    # Invalid options
    defp select(_form, _field, {:error, reason}, _selected) do
      {:error, reason}
    end

    # Selected currency
    @omit_from_select_options [:currencies, :locale, :mapper, :collator, :backend]
    defp select(form, field, options, _selected) do
      select_options =
        options
        |> Map.drop(@omit_from_select_options)
        |> Map.to_list()

      options = build_currency_options(options)

      Phoenix.HTML.Form.select(form, field, options, select_options)
    end

    defp validate_options(options) do
      options = Map.new(options)

      with options <- Map.merge(default_options(), options),
           {:ok, options} <- validate_locale(options),
           {:ok, options} <- validate_selected(options),
           {:ok, options} <- validate_currencies(options) do
        options
      end
    end

    defp default_options do
      Map.new(
        currencies: default_currency_list(),
        locale: Cldr.get_locale(),
        backend: nil,
        collator: &default_collator/1,
        mapper: &{&1.code <> " - " <> &1.name, &1.code},
        selected: nil
      )
    end

    defp default_collator(currencies) do
      Enum.sort(currencies, &default_comparator/2)
    end

    # Note that this is not a unicode aware comparison
    defp default_comparator(currency_1, currency_2) do
      currency_1.name < currency_2.name
    end

    defp validate_selected(%{selected: nil} = options) do
      {:ok, options}
    end

    defp validate_selected(%{selected: selected} = options) do
      with {:ok, currency} <- Cldr.validate_currency(selected) do
        {:ok, Map.put(options, :selected, currency)}
      end
    end

    # Return a list of validated currencies or an error
    defp validate_currencies(%{currencies: currencies} = options) do
      validate_currencies(currencies, options)
    end

    defp validate_currencies(currencies) when is_list(currencies) do
      Enum.reduce_while(currencies, [], fn currency, acc ->
        case Cldr.validate_currency(currency) do
          {:ok, currency} -> {:cont, [currency | acc]}
          {:error, reason} -> {:halt, {:error, reason}}
        end
      end)
    end

    defp validate_currencies(currencies, options) do
      case validate_currencies(currencies) do
        {:error, reason} -> {:error, reason}
        currencies -> {:ok, Map.put(options, :currencies, Enum.reverse(currencies))}
      end
    end

    defp validate_locale(options) do
      {locale, backend} = Cldr.locale_and_backend_from(options[:locale], options[:backend])

      with {:ok, locale} <- Cldr.validate_locale(locale, backend) do
        options
        |> Map.put(:locale, locale)
        |> Map.put(:backend, locale.backend)
        |> wrap(:ok)
      end
    end

    defp wrap(term, atom) do
      {atom, term}
    end

    defp maybe_include_selected_currency(%{selected: nil} = options) do
      options
    end

    defp maybe_include_selected_currency(%{currencies: currencies, selected: selected} = options) do
      if Enum.any?(currencies, &(&1 == selected)) do
        options
      else
        Map.put(options, :currencies, [selected | currencies])
      end
    end

    defp build_currency_options(options) when is_map(options) do
      options = maybe_include_selected_currency(options)

      currencies = Map.fetch!(options, :currencies)
      collator = Map.fetch!(options, :collator)
      mapper = Map.fetch!(options, :mapper)
      backend = Map.fetch!(options, :backend)
      options = Map.to_list(options)

      currencies
      |> Enum.map(&(Cldr.Currency.currency_for_code(&1, backend, options) |> elem(1)))
      |> collator.()
      |> Enum.map(&mapper.(&1))
    end

    # Default currency list to legal tender currencies
    # if Money is available, otherwise the full list of
    # Cldr currencies (which is almost identitical to ISO4217)
    if Code.ensure_loaded?(Money) do
      defp default_currency_list() do
        Money.known_tender_currencies()
      end
    else
      defp default_currency_list() do
        Cldr.known_currencies()
      end
    end
  end
end