I18n within resources

Hi! Is there a way to replicate the functionality of something like Trans (https://hexdocs.pm/trans/Trans.html) in Ash? It provides a simple way to translate fields of a schema without needing extra db tables and joins. What is the idomatic way to handle I18n within resources in Ash?
23 Replies
ZachDaniel
ZachDanielβ€’3y ago
Hey there! There is no set pattern for it currently, but you can do it with calculations, for example If you wanted the translations stored in the database you could basically do exactly what that tool does and define an embedded attribute
defmodule MyApp.Post.Translations do
use Ash.Resource,
data_layer: :embedded

attributes do
attribute :en, MyApp.Post.Translation
end
end

defmodule MyApp.Post.Translation do
...

attributes do
attribute :translated_field, :field
end
end

defmodule MyApp.Post do
use Ash.Resource

attributes do
attribute :translations, MyApp.Post.Translations
end
end
defmodule MyApp.Post.Translations do
use Ash.Resource,
data_layer: :embedded

attributes do
attribute :en, MyApp.Post.Translation
end
end

defmodule MyApp.Post.Translation do
...

attributes do
attribute :translated_field, :field
end
end

defmodule MyApp.Post do
use Ash.Resource

attributes do
attribute :translations, MyApp.Post.Translations
end
end
Mykolas
Mykolasβ€’3y ago
Could we possibly have a working example, even if you point something out. This looks like an extension to me where you would write:
attributes do
attribute :slug, :translated_field, :unique
attribute :title, :translated_field
attribute :description, :translated_field
do
attributes do
attribute :slug, :translated_field, :unique
attribute :title, :translated_field
attribute :description, :translated_field
do
I'm building a page builder and have blocks not sure how would i write out the way you've described for each block? Is this possible, could you point me in the right direction? which would result in this structure in db
translations: {
en: { slug: "asdf, title: "ASdfa" , decription: "adfs" },
it: { slug: "asdf, title: "ASdfa" , decription: "adfs" }
... and so forth for all condifgured locales
}
translations: {
en: { slug: "asdf, title: "ASdfa" , decription: "adfs" },
it: { slug: "asdf, title: "ASdfa" , decription: "adfs" }
... and so forth for all condifgured locales
}
ZachDaniel
ZachDanielβ€’3y ago
Hey there, sorry about that Been a bit busy lately πŸ™‚ My initial example was a bit off Actually...maybe not so much πŸ™‚ It groups translations effectively the way you want
defmodule MyApp.Post.Translations do
use Ash.Resource,
data_layer: :embedded

attributes do
attribute :en, MyApp.Post.Translation
end
end

defmodule MyApp.Post.Translation do
...

attributes do
attribute :name, :string
end
end

defmodule MyApp.Post do
use Ash.Resource

attributes do
attribute :name, :string
attribute :translations, MyApp.Post.Translations
end
end
defmodule MyApp.Post.Translations do
use Ash.Resource,
data_layer: :embedded

attributes do
attribute :en, MyApp.Post.Translation
end
end

defmodule MyApp.Post.Translation do
...

attributes do
attribute :name, :string
end
end

defmodule MyApp.Post do
use Ash.Resource

attributes do
attribute :name, :string
attribute :translations, MyApp.Post.Translations
end
end
I updated the example to show roughly how it would look with a real field So with that you'd have a record like:
name: "foo", # this is optional, but lets you have like a primary value (i.e maybe this is `en`)
translations: %MyApp.Post.Translations{
en: %MyAPp.Post.Translation{
name: "oof"
}
}
name: "foo", # this is optional, but lets you have like a primary value (i.e maybe this is `en`)
translations: %MyApp.Post.Translations{
en: %MyAPp.Post.Translation{
name: "oof"
}
}
So then what you'd do is add all the language code as attributes
defmodule MyApp.Post.Translations do
use Ash.Resource,
data_layer: :embedded

attributes do
for language_code <- ~w(en jp the_rest)a do
attribute language_code, MyApp.Post.Translation
end
end
end
defmodule MyApp.Post.Translations do
use Ash.Resource,
data_layer: :embedded

attributes do
for language_code <- ~w(en jp the_rest)a do
attribute language_code, MyApp.Post.Translation
end
end
end
Mykolas
Mykolasβ€’2y ago
Awesome, thank you, so this means i need to define all the modules by hand to have this work properly. Do you imagine a way i could write an extension or macro to do this automatically? so in the end the resource would look like:
defmodule MyApp.Post do
use Ash.Resource

attributes do
i18n_attribute :slug, :string, required: true, unique: true
i18n_attribute :name, :string

end
end
defmodule MyApp.Post do
use Ash.Resource

attributes do
i18n_attribute :slug, :string, required: true, unique: true
i18n_attribute :name, :string

end
end
ZachDaniel
ZachDanielβ€’2y ago
I see what you mean. Yes that is possible. Not in the way you’ve shown, but it could be done with a separate DSL section. i18n_attributes [:slug, :name]
Mykolas
Mykolasβ€’2y ago
ahh that's nice! I'm currently thinking about migrating my octafest.com from the old laravel php stack to phoenix liveview and ash seems like the perfect bridge
ZachDaniel
ZachDanielβ€’2y ago
well if you'd like to work on ash_i18n I'm more than happy to help πŸ˜„
Mykolas
Mykolasβ€’2y ago
where would i start?
ZachDaniel
ZachDanielβ€’2y ago
Step one would be to get familiar with spark dsl extensions. Not a lot of guides out there, but a great example of a lightweight extension is ash_archival
Mykolas
Mykolasβ€’2y ago
ok on it
ZachDaniel
ZachDanielβ€’2y ago
The idea would be to add a dsl extension to describe the changes, and then to use transformers to apply changes the resource
Mykolas
Mykolasβ€’2y ago
where can i find a simple example implementing DSL.Entity as i want to do this:
defmodule AshI18n.Resource do
alias AshI18n.I18nField

@i18n_attribute_schema [
name: [
type: :atom,
required: true,
doc: "The name of the field"
],
validations: [
type: :keyword_list,
doc: "The basic validations for the field",
default: [],
keys: [required: [type: :atom], unique: [type: :atom]]
]
]

@i18n_attribute %Spark.Dsl.Entity{
name: :i18n_attribute,
describe: "Adds a translated field",
examples: [
"field :slug, :required, :unique",
"field :title"
],
target: I18nField,
args: [:name, :validations],
auto_set_fields: true,
schema: @i18n_attribute_schema
}

@i18n_attributes %Spark.Dsl.Section{
name: :i18n_attributes,
describe: "A section for configuring translations for a resource.",
examples: [
"""
i18n_attributes do
field :slug, [:required, :unique]
field :title, []
end
"""
],
entities: [
@i18n_attribute
]
}

use Spark.Dsl.Extension,
sections: [@i18n_attributes],
transformers: [AshI18n.Resource.Transformers.SetupI18n]
end
defmodule AshI18n.Resource do
alias AshI18n.I18nField

@i18n_attribute_schema [
name: [
type: :atom,
required: true,
doc: "The name of the field"
],
validations: [
type: :keyword_list,
doc: "The basic validations for the field",
default: [],
keys: [required: [type: :atom], unique: [type: :atom]]
]
]

@i18n_attribute %Spark.Dsl.Entity{
name: :i18n_attribute,
describe: "Adds a translated field",
examples: [
"field :slug, :required, :unique",
"field :title"
],
target: I18nField,
args: [:name, :validations],
auto_set_fields: true,
schema: @i18n_attribute_schema
}

@i18n_attributes %Spark.Dsl.Section{
name: :i18n_attributes,
describe: "A section for configuring translations for a resource.",
examples: [
"""
i18n_attributes do
field :slug, [:required, :unique]
field :title, []
end
"""
],
entities: [
@i18n_attribute
]
}

use Spark.Dsl.Extension,
sections: [@i18n_attributes],
transformers: [AshI18n.Resource.Transformers.SetupI18n]
end
here's my field:
defmodule AshI18n.I18nField do
defstruct name: "", validations: []

def i18n_attributes(resource) do
Spark.Dsl.Extension.get_entities(resource, [:i18n_attributes])
end
end
defmodule AshI18n.I18nField do
defstruct name: "", validations: []

def i18n_attributes(resource) do
Spark.Dsl.Extension.get_entities(resource, [:i18n_attributes])
end
end
but this fails with
❯ mix phx.server
Compiling 4 files (.ex)

00:50:06.367 [error] Task #PID<0.303.0> started from #PID<0.287.0> terminating
** (FunctionClauseError) no function clause matching in Keyword.merge/2
(elixir 1.14.5) lib/keyword.ex:979: Keyword.merge([], true)
(spark 1.1.11) lib/spark/dsl/extension.ex:1078: anonymous fn/9 in Spark.Dsl.Extension.do_build_section/6
(elixir 1.14.5) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
(spark 1.1.11) lib/spark/dsl/extension.ex:1075: Spark.Dsl.Extension.do_build_section/6
lib/ash_i18n/resource.ex:48: anonymous fn/3 in :elixir_compiler_3.__MODULE__/1
(elixir 1.14.5) lib/task/supervised.ex:89: Task.Supervised.invoke_mfa/2
(elixir 1.14.5) lib/task/supervised.ex:34: Task.Supervised.reply/4
(stdlib 4.3.1) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
Function: #Function<0.104469677/0 in Kernel.ParallelCompiler.async/1>
Args: []

== Compilation error in file lib/ash_i18n/resource.ex ==
** (exit) an exception was raised:
** (FunctionClauseError) no function clause matching in Keyword.merge/2
(elixir 1.14.5) lib/keyword.ex:979: Keyword.merge([], true)
(spark 1.1.11) lib/spark/dsl/extension.ex:1078: anonymous fn/9 in Spark.Dsl.Extension.do_build_section/6
(elixir 1.14.5) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
(spark 1.1.11) lib/spark/dsl/extension.ex:1075: Spark.Dsl.Extension.do_build_section/6
lib/ash_i18n/resource.ex:48: anonymous fn/3 in :elixir_compiler_3.__MODULE__/1
(elixir 1.14.5) lib/task/supervised.ex:89: Task.Supervised.invoke_mfa/2
(elixir 1.14.5) lib/task/supervised.ex:34: Task.Supervised.reply/4
(stdlib 4.3.1) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
❯ mix phx.server
Compiling 4 files (.ex)

00:50:06.367 [error] Task #PID<0.303.0> started from #PID<0.287.0> terminating
** (FunctionClauseError) no function clause matching in Keyword.merge/2
(elixir 1.14.5) lib/keyword.ex:979: Keyword.merge([], true)
(spark 1.1.11) lib/spark/dsl/extension.ex:1078: anonymous fn/9 in Spark.Dsl.Extension.do_build_section/6
(elixir 1.14.5) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
(spark 1.1.11) lib/spark/dsl/extension.ex:1075: Spark.Dsl.Extension.do_build_section/6
lib/ash_i18n/resource.ex:48: anonymous fn/3 in :elixir_compiler_3.__MODULE__/1
(elixir 1.14.5) lib/task/supervised.ex:89: Task.Supervised.invoke_mfa/2
(elixir 1.14.5) lib/task/supervised.ex:34: Task.Supervised.reply/4
(stdlib 4.3.1) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
Function: #Function<0.104469677/0 in Kernel.ParallelCompiler.async/1>
Args: []

== Compilation error in file lib/ash_i18n/resource.ex ==
** (exit) an exception was raised:
** (FunctionClauseError) no function clause matching in Keyword.merge/2
(elixir 1.14.5) lib/keyword.ex:979: Keyword.merge([], true)
(spark 1.1.11) lib/spark/dsl/extension.ex:1078: anonymous fn/9 in Spark.Dsl.Extension.do_build_section/6
(elixir 1.14.5) lib/enum.ex:2468: Enum."-reduce/3-lists^foldl/2-0-"/3
(spark 1.1.11) lib/spark/dsl/extension.ex:1075: Spark.Dsl.Extension.do_build_section/6
lib/ash_i18n/resource.ex:48: anonymous fn/3 in :elixir_compiler_3.__MODULE__/1
(elixir 1.14.5) lib/task/supervised.ex:89: Task.Supervised.invoke_mfa/2
(elixir 1.14.5) lib/task/supervised.ex:34: Task.Supervised.reply/4
(stdlib 4.3.1) proc_lib.erl:240: :proc_lib.init_p_do_apply/3
ZachDaniel
ZachDanielβ€’2y ago
auto_set_fields is meant to be a keyword list of explicit field values you probably don't need it πŸ™‚
Mykolas
Mykolasβ€’2y ago
ok got this to build πŸ˜„
i18n_attributes do
i18n_attribute(:slug, :string, constraints: [required: true, unique: true])
i18n_attribute(:title, :string)
end
i18n_attributes do
i18n_attribute(:slug, :string, constraints: [required: true, unique: true])
i18n_attribute(:title, :string)
end
now on to figuring out how to embed this, any pointers?
Mykolas
Mykolasβ€’2y ago
Does this look like i'm moving in the right direction?
Mykolas
Mykolasβ€’2y ago
Or is there a better/cleaner way to insert embeded schemas? I'm thinking if it's worth the effort it's not that hard to do what you've shown and this adds quite a bit of magic that you can't see. because this is just for adding the translated fields. I then need to modify the create changeset to include the embeds with validations add read actions which accept locale and move the fields into top level access
\ ឡឡឡ
It really depends how you want to do this: Is the translation template static for all instances of the resource? In this case, you're better off using something like Gettext (included by default with Phoenix) and then returning the interpolated string using a calculation. Are the translations something that need to be modifiable from a frontend, i.e. they should live in the database and not in code? Then there are a lot of options, and which is best still depends on whether individual resources need to have arbitrary strings or there is a single template for each locale that needs to be user-modifiable but applies to all instances of that resource.
ZachDaniel
ZachDanielβ€’2y ago
Yeah, if you’re looking to store translations in the database then that is a strategy that would work, and defining the module like that is pretty much the only way
Mykolas
Mykolasβ€’2y ago
Yeah the whole point is to have client facing editable content for pages etc. I can't get this to render the form though i've tried adding forms: [auto?: true] but nothing happens:
<.simple_form for={@create_form} phx-submit="create_post">
<.input field={@create_form[:title]} type="text" placeholder="Title..." />
<.input field={@create_form[:content]} type="textarea" placeholder="Content..." />

<.inputs_for :let={translations} field={@create_form[:translations]}>
<.inputs_for :let={translation} :for={locale <- @locales} field={translations[locale]}>
<div class="grid gap-4">
<p class="text-sm uppercase text-gray-600"><%= locale %></p>
<.input field={translation[:title]} type="text" />
<.input field={translation[:content]} type="text" />
</div>
</.inputs_for>
</.inputs_for>

<.button>
create
</.button>
</.simple_form>
<.simple_form for={@create_form} phx-submit="create_post">
<.input field={@create_form[:title]} type="text" placeholder="Title..." />
<.input field={@create_form[:content]} type="textarea" placeholder="Content..." />

<.inputs_for :let={translations} field={@create_form[:translations]}>
<.inputs_for :let={translation} :for={locale <- @locales} field={translations[locale]}>
<div class="grid gap-4">
<p class="text-sm uppercase text-gray-600"><%= locale %></p>
<.input field={translation[:title]} type="text" />
<.input field={translation[:content]} type="text" />
</div>
</.inputs_for>
</.inputs_for>

<.button>
create
</.button>
</.simple_form>
Any help is greatly appreciated.
ZachDaniel
ZachDanielβ€’2y ago
You likely need to do an add_form when you create the initial form because we don't assume that a value should be populated for translations by default I can look at it a bit more this weekend, but if you want an empty value provided for a given thing you'd need to add_form once at the beginning and then likely for each locale translation you could have a little plus button that adds an additional locale translation
Mykolas
Mykolasβ€’2y ago
It should have an empty value for each locale as i have a tabs thing where you can switch between languages. I'll have a look at the add_form thing πŸ™‚ thank you! Ash is amazing πŸ™‚ Hmm ok back at this, i have an interesting case and trying to figure out how to best do this: Octafest is a multi-tenant thing. And translations are really tenant bound and dynamic. For e.g. Tenant A has two languages English and Lithuanian. Default is Lithuanian. Tenant B has three languages English, Italian and German. Default is english. Since json columns don't really need a defined structure the way i've done it in php land is just have json columns for the translated stuff and then show the ui according to the tenants settings. Tenant settings define what languages they support and the default language. That in turn determines what get's populated in the translated field column form. Also i've switched out the relationship, so it's not:
model
-> translations
-> [
en -> [ name: 'English name', description: 'English description ],
lt -> [ name: 'Lithuanian name', description: 'Lithuanian description' ]
]
model
-> translations
-> [
en -> [ name: 'English name', description: 'English description ],
lt -> [ name: 'Lithuanian name', description: 'Lithuanian description' ]
]
But:
model -> name -> [en: 'English name', lt: 'Lithuanian name']
-> description -> [en: 'English description', lt: 'Lithuanian description']
model -> name -> [en: 'English name', lt: 'Lithuanian name']
-> description -> [en: 'English description', lt: 'Lithuanian description']
Any help is greatly appreciated Ok so created a shared embed like so:
defmodule Octafest.Shared.Translated do
@behaviour Access

use Ash.Resource,
data_layer: :embedded

attributes do
for locale <- ~w(en lt)a do
attribute locale, :string
end
end

@impl Access
def fetch(term, key), do: Map.fetch(term, key)

@impl Access
def get_and_update(data, key, func) do
Map.get_and_update(data, key, func)
end

@impl Access
def pop(data, key), do: Map.pop(data, key)

@impl Access
def get(map, key, default), do: Map.get(map, key, default)

end
defmodule Octafest.Shared.Translated do
@behaviour Access

use Ash.Resource,
data_layer: :embedded

attributes do
for locale <- ~w(en lt)a do
attribute locale, :string
end
end

@impl Access
def fetch(term, key), do: Map.fetch(term, key)

@impl Access
def get_and_update(data, key, func) do
Map.get_and_update(data, key, func)
end

@impl Access
def pop(data, key), do: Map.pop(data, key)

@impl Access
def get(map, key, default), do: Map.get(map, key, default)

end
But the problem is still having the hardcoded locales. I mean as a workaround i can have that as a list of all available locales which for now is limited and then only show the relevant ones for the tenant? This is the usage:
defmodule Octafest.Blog.Post do
use Ash.Resource,
data_layer: AshPostgres.DataLayer

postgres do
table "posts"

repo Octafest.Repo
end

code_interface do
define_for Octafest.Blog
define :create, action: :create
define :read_all, action: :read
define :update, action: :update
define :destroy, action: :destroy
define :get_by_id, args: [:id], action: :by_id
end

actions do
defaults ~w(create read update destroy)a

read :by_id do
argument :id, :uuid, allow_nil?: false
get? true

filter expr(id == ^arg(:id))
end
end

attributes do
uuid_primary_key :id

attribute :title, Octafest.Shared.Translated
attribute :content, Octafest.Shared.Translated
end
end
defmodule Octafest.Blog.Post do
use Ash.Resource,
data_layer: AshPostgres.DataLayer

postgres do
table "posts"

repo Octafest.Repo
end

code_interface do
define_for Octafest.Blog
define :create, action: :create
define :read_all, action: :read
define :update, action: :update
define :destroy, action: :destroy
define :get_by_id, args: [:id], action: :by_id
end

actions do
defaults ~w(create read update destroy)a

read :by_id do
argument :id, :uuid, allow_nil?: false
get? true

filter expr(id == ^arg(:id))
end
end

attributes do
uuid_primary_key :id

attribute :title, Octafest.Shared.Translated
attribute :content, Octafest.Shared.Translated
end
end
Mykolas
Mykolasβ€’2y ago
And this is how a basic form for creating looks like:
ZachDaniel
ZachDanielβ€’2y ago
You strategy of supporting all locales would work You can also make it a calculation that produces a map, and then use a custom type in ash_graphql. oh, you might not even be using ash_graphql? anyway, a custom map type could also do it πŸ™‚

Did you find this page helpful?