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:
- 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:
- 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