Custom Ash.Type.Map for translations help please?

What i want to achieve

I want to create a custom Ash.Type.Translated, which i could use like so:

attributes do
  uuid_v7_primary_key :id

  attribute :title, Ash.Type.Translated do
    constraints min_length: 2, max_length: 128, trim?: false,  allow_nil?: true
    public? true
  end
end

Which would basically result into a map in database something like so:

%{
  en: "some string in english",
  lt: "some string in lithuanian"
}

What i have tried:

I basically took the Ash.Type.Map code and tried adjusting it accordingly, hereā€™s what i have so far:

defmodule Octafest.Ash.Type.Translated do
  @primary_locale String.to_atom(OctafestCldr.default_locale().language)
  @allowed_locales OctafestCldr.known_locale_names()

  @constraints [
    max_length: [
      type: :non_neg_integer,
      doc: "Enforces a maximum length on string values"
    ],
    min_length: [
      type: :non_neg_integer,
      doc: "Enforces a minimum length on string values"
    ],
    trim?: [
      type: :boolean,
      doc: "Trims string values",
      default: true
    ],
    allow_nil?: [
      type: :boolean,
      doc: "If false, the primary locale value cannot be nil",
      default: false
    ]
  ]

  @moduledoc """
  Stores a map of translated strings in the database.

  The map is structured with locale keys and string values, e.g.:
  %{
  en: "some string in english",
  lt: "some string in lithuanian"
  }

  The primary locale (#{inspect(@primary_locale)}) follows the allow_nil? constraint,
  while other locales can always be nil.

  ### Constraints

  #{Spark.Options.docs(@constraints)}
  """
  use Ash.Type

  @impl true
  def constraints, do: @constraints

  @impl true
  def storage_type(_), do: :map

  @impl true
  def matches_type?(value, _) do
    is_map(value) && valid_map?(value)
  end

  @impl true
  def cast_input("", _), do: {:ok, nil}
  def cast_input(nil, _), do: {:ok, nil}

  def cast_input(value, constraints) when is_binary(value) do
    case Ash.Helpers.json_module().decode(value) do
      {:ok, value} ->
        cast_input(value, constraints)

      _ ->
        :error
    end
  end

  def cast_input(value, _) when is_map(value), do: {:ok, value}
  def cast_input(_, _), do: :error

  @impl true
  def cast_stored(nil, _), do: {:ok, nil}

  def cast_stored(value, _) when is_map(value), do: {:ok, value}

  def cast_stored(_, _), do: :error

  @impl true
  def dump_to_native(nil, _), do: {:ok, nil}
  def dump_to_native(value, _) when is_map(value), do: {:ok, value}
  def dump_to_native(_, _), do: :error

  @impl true
  def cast_atomic(new_value, constraints) do
    if matches_type?(new_value, constraints) do
      case apply_constraints(new_value, constraints) do
        {:ok, _} -> {:atomic, new_value}
        {:error, _} -> {:not_atomic, "Invalid value for atomic update"}
      end
    else
      {:not_atomic, "Value must be a map of locale strings"}
    end
  end

  @impl true
  def generator(constraints) do
    # Create generators for primary and non-primary locales
    primary_gen = Ash.Type.String.generator(locale_constraints(@primary_locale, constraints))
    other_gen = Ash.Type.String.generator(locale_constraints(:other, constraints))

    non_primary_locales = @allowed_locales -- [@primary_locale]

    StreamData.map(
      {primary_gen,
       StreamData.list_of(
         StreamData.tuple({StreamData.member_of(non_primary_locales), other_gen})
       )},
      fn {primary_value, other_values} ->
        other_values
        |> Map.new()
        |> Map.put(@primary_locale, primary_value)
      end
    )
  end

  @impl true
  def apply_constraints(value, constraints) do
    fields = create_fields(constraints)

    Enum.reduce(fields, {:ok, value}, fn
      {:fields, fields}, {:ok, value} ->
        check_fields(value, fields)

      {_, _}, {:error, errors} ->
        {:error, errors}
    end)
  end

  defp create_fields(constrains) do
    [
      fields:
        Enum.reduce(@allowed_locales, [], fn locale, acc ->
          Keyword.put(acc, locale, locale_constraints(locale, constrains))
        end)
    ]
  end

  defp check_fields(value, fields) do
    Enum.reduce(fields, {:ok, %{}}, fn
      {field, field_constraints}, {:ok, checked_value} ->
        case fetch_field(value, field) do
          {:ok, field_value} ->
            check_field(checked_value, field, field_value, field_constraints)

          :error ->
            if field_constraints[:allow_nil?] == false do
              {:error, [[message: "field must be present", field: field]]}
            else
              {:ok, checked_value}
            end
        end

      {_, _}, {:error, errors} ->
        {:error, errors}
    end)
  end

  defp check_field(result, field, field_value, field_constraints) do
    case Ash.Type.cast_input(:string, field_value, field_constraints || []) do
      {:ok, field_value} ->
        case Ash.Type.apply_constraints(:string, field_value, field_constraints || []) do
          {:ok, nil} ->
            if field_constraints[:allow_nil?] == false do
              {:error, [[message: "value must not be nil", field: field]]}
            else
              {:ok, Map.put(result, field, nil)}
            end

          {:ok, field_value} ->
            {:ok, Map.put(result, field, field_value)}

          {:error, errors} ->
            # {:error, Enum.map(errors, fn error -> Keyword.put(error, :field, field) end)}
            {:error, Enum.map(errors, fn error -> Keyword.put(error, :field, field) end)}
        end

      {:error, error} ->
        {:error, [error]}

      :error ->
        {:error, [[message: "invalid value", field: field]]}
    end
  end

  defp fetch_field(map, atom) when is_atom(atom) do
    case Map.fetch(map, atom) do
      {:ok, value} -> {:ok, value}
      :error -> fetch_field(map, to_string(atom))
    end
  end

  defp fetch_field(map, key), do: Map.fetch(map, key)

  # Private helper to adjust constraints based on locale
  defp locale_constraints(locale, constraints) when locale == @primary_locale do
    constraints
  end

  defp locale_constraints(_locale, constraints) do
    Keyword.put(constraints, :allow_nil?, true)
  end

  defp valid_map?(map) do
    Enum.all?(map, fn {key, value} ->
      valid_key?(key) and valid_value?(value)
    end)
  end

  defp valid_key?(key) do
    key in @allowed_locales
  end

  defp valid_value?(value) do
    is_binary(value) or is_nil(value)
  end
end

But i have a few problems:

  1. This line basically fails:
{:error, errors} ->
  {:error, Enum.map(errors, fn error -> Keyword.put(error, :field, field) end)}

With this error:

** (Ash.Error.Unknown) 
Bread Crumbs:
  > building changeset for Octafest.Festival.Edition.create

Unknown Error

* ** (FunctionClauseError) no function clause matching in Keyword.put/3
  (elixir 1.18.0) lib/keyword.ex:780: Keyword.put({:message, "length must be greater than or equal to %{min}"}, :field, :en)

And while i can fix it by doing {:error, Keyword.put(error, :field, field)} but i donā€™t think this is intended.

and the second thing:

  1. Iā€™m not sure how to structure the inputs, this seems to work, but the error never comes through.
defmodule OctafestUI.FormField do
  @moduledoc """
  Form Field components
  """
  use OctafestUI, :component

  @primary_locale String.to_atom(OctafestCldr.default_locale().language)
  @allowed_locales OctafestCldr.known_locale_names()

  attr :id, :any, default: nil, doc: "The unique identifier for the input element."
  attr :name, :any
  attr :value, :map
  attr :label, :string, default: nil
  attr :type, :string, default: "text", values: ~w(text)
  attr :errors, :list, default: []
  attr :class, :string, default: ""

  attr :field, Phoenix.HTML.FormField,
    default: nil,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"

  def translated_input(%{field: %Phoenix.HTML.FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    IO.inspect(field.errors, label: "____field.errors")
    IO.inspect(errors, label: "____errors")

    assigns
    |> assign(:errors, Enum.map(errors, &translate_error(&1)))
    |> assign_new(:name, fn -> field.name end)
    |> assign_new(:value, fn -> field.value || %{} end)
    |> assign_new(:id, fn -> field.id end)
    |> assign(:field, nil)
    |> translated_input()
  end

  def translated_input(assigns) do
    ~H"""
    <div class="translated-fields">
      <%= for locale <- list_locales() do %>
        <div class="mb-4">
        <label for={}>{"#{@label} #{locale}" <;> if is_primary_locale?(locale), do: " (Primary)", else: ""}</label>
          <input
            type="text"
            id={"#{@id}_#{locale}"}
            name={"#{@name}[#{locale}]"}
            value={Map.get(@value, locale)}
            phx-debounce="300"
          />
        </div>
      <% end %>
    </div>
    """
  end

  defp list_locales do
    @allowed_locales
  end

  defp is_primary_locale?(locale) do
    locale == @primary_locale
  end

  defp translate_error({msg, opts}) do
    Enum.reduce(opts, msg, fn {key, value}, acc ->
      String.replace(acc, "%{#{key}}", fn _ -> to_string(value) end)
    end)
  end
end
1 Like

Ok after testing out the the actual Ash.Type.Map it fails with the same error.

setup:

    attribute :title, :map do
      constraints fields: [
                    en: [
                      type: :string,
                      allow_nil?: false,
                      constraints: [
                        min_length: 3,
                        max_length: 5
                      ]
                    ],
                    lt: [
                      type: :string,
                      allow_nil?: true,
                      constraints: [
                        min_length: 3,
                        max_length: 5
                      ]
                    ]
                  ]

      public? true
    end

That same line breaks with the same error, so I think itā€™s a bug in Ash Core.

[debug] HANDLE EVENT "validate" in OctafestWeb.Admin.ArchiveLive
  Component: OctafestWeb.Admin.ArchiveLive.FormComponent
  Parameters: %{"_target" => ["edition", "title", "en"], "edition" => %{"_unused_content_types" => [""], "_unused_features" => [""], "content_types" => [""], "features" => [""], "title" => %{"_unused_en" => "", "_unused_lt" => "", "en" => "a", "lt" => ""}}}
[error] GenServer #PID<0.10549.0> terminating
** (Ash.Error.Unknown) 
Bread Crumbs:
  > building changeset for Octafest.Festival.Edition.create

Unknown Error

* ** (FunctionClauseError) no function clause matching in Keyword.put/3
  (elixir 1.18.0) lib/keyword.ex:780: Keyword.put({:message, "length must be greater than or equal to %{min}"}, :field, :en)
  (elixir 1.18.0) lib/enum.ex:1714: Enum."-map/2-lists^map/1-1-"/2
  (ash 3.4.51) lib/ash/type/map.ex:193: Ash.Type.Map.check_field/4
  (elixir 1.18.0) lib/enum.ex:2546: Enum."-reduce/3-lists^foldl/2-0-"/3
  (ash 3.4.51) lib/ash/type/type.ex:1142: Ash.Type.apply_constraints/3
  (ash 3.4.51) lib/ash/changeset/changeset.ex:5315: Ash.Changeset.do_change_attribute/4
  (stdlib 6.0) maps.erl:860: :maps.fold_1/4

Can you open an issue on the ash repo? Ideally with a reproduction or a failing test? Iā€™ll look into it soon.

1 Like

Issue resolved in main

1 Like

Thank you thatā€™s fixed.

Now all i need is to get these errors in my AshPhoenix Forms :smiley:

I havenā€™t tried doing what youā€™re describing, but the errors should have the proper path attached to them, but you may need to set the errors manually with AshPhoenix.Form.errors(form, for_path: [....])

There is also a library designed to help with this that I havenā€™t personally used. Home ā€” ash_trans v0.1.1

1 Like