diff --git a/.tool-versions b/.tool-versions
index b2f3ccbc5..bf44d4afd 100644
--- a/.tool-versions
+++ b/.tool-versions
@@ -1,2 +1,2 @@
erlang 28.1
-elixir 1.18.4
+elixir 1.18.4-otp-28
diff --git a/demo/lib/demo/ecto_factory.ex b/demo/lib/demo/ecto_factory.ex
index c06a08114..159ce73fd 100644
--- a/demo/lib/demo/ecto_factory.ex
+++ b/demo/lib/demo/ecto_factory.ex
@@ -5,6 +5,7 @@ defmodule Demo.EctoFactory do
alias Demo.Address
alias Demo.Category
+ alias Demo.Entity
alias Demo.FilmReview
alias Demo.Post
alias Demo.Product
@@ -13,6 +14,8 @@ defmodule Demo.EctoFactory do
alias Demo.Tag
alias Demo.User
+ alias Faker.Phone.EnUs, as: FakerPhone
+
def user_factory do
%User{
username: Faker.Internet.user_name(),
@@ -61,6 +64,10 @@ defmodule Demo.EctoFactory do
quantity: Enum.random(0..1_000),
manufacturer: "https://example.com/",
price: Enum.random(50..5_000_000),
+ more_info: %{
+ weight: Enum.random(1..100),
+ goes_well_with: Faker.Food.description()
+ },
suppliers: build_list(Enum.random(0..5), :supplier),
short_links: build_list(Enum.random(0..5), :short_link)
}
@@ -102,4 +109,32 @@ defmodule Demo.EctoFactory do
%{label: Enum.at(labels, index), url: "https://example.com/"}
end
end
+
+ def car_factory do
+ %Entity{
+ identity: Faker.Vehicle.En.make_and_model(),
+ type: "car",
+ fields: %{
+ "engine_size" => Enum.random([1800, 2000, 2300, 2500, 3000, 3400, 4000, 5000]),
+ "colour" => Faker.Color.En.name(),
+ "year" => Enum.random(1900..2025)
+ }
+ }
+ end
+
+ def person_factory do
+ first_name = Faker.Person.first_name()
+ last_name = Faker.Person.last_name()
+
+ %Entity{
+ identity: "#{first_name} #{last_name}",
+ type: "person",
+ fields: %{
+ "email" => Faker.Internet.email(),
+ "phone" => FakerPhone.phone(),
+ "age" => Enum.random(18..85),
+ "weight" => Enum.random(45..130)
+ }
+ }
+ end
end
diff --git a/demo/lib/demo/entity.ex b/demo/lib/demo/entity.ex
new file mode 100644
index 000000000..26a3b886e
--- /dev/null
+++ b/demo/lib/demo/entity.ex
@@ -0,0 +1,23 @@
+defmodule Demo.Entity do
+ @moduledoc false
+ use Ecto.Schema
+
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ schema "entities" do
+ field :identity, :string
+ field :type, :string
+ field :fields, :map, default: %{}
+
+ timestamps()
+ end
+
+ @required_fields [:identity, :type, :fields]
+ @doc false
+ def changeset(address, attrs, _metadata \\ []) do
+ address
+ |> cast(attrs, @required_fields)
+ |> validate_required(@required_fields)
+ end
+end
diff --git a/demo/lib/demo/product.ex b/demo/lib/demo/product.ex
index 7baa1bfad..cb4d9bc6a 100644
--- a/demo/lib/demo/product.ex
+++ b/demo/lib/demo/product.ex
@@ -17,6 +17,11 @@ defmodule Demo.Product do
field :price, Money.Ecto.Amount.Type
+ embeds_one :more_info, MoreInfo do
+ field :weight, :integer
+ field :goes_well_with, :string
+ end
+
has_many :suppliers, Supplier, on_replace: :delete, on_delete: :delete_all
has_many :short_links, ShortLink, on_replace: :delete, on_delete: :delete_all, foreign_key: :product_id
@@ -29,6 +34,9 @@ defmodule Demo.Product do
def changeset(product, attrs, _metadata \\ []) do
product
|> cast(attrs, @required_fields ++ @optional_fields)
+ |> cast_embed(:more_info,
+ with: &more_info_changeset/2
+ )
|> cast_assoc(:suppliers,
with: &Demo.Supplier.changeset/2,
sort_param: :suppliers_order,
@@ -42,4 +50,10 @@ defmodule Demo.Product do
|> validate_required(@required_fields)
|> validate_length(:images, max: 2)
end
+
+ def more_info_changeset(more_info, attrs) do
+ more_info
+ |> cast(attrs, [:weight, :goes_well_with])
+ |> validate_required([:weight])
+ end
end
diff --git a/demo/lib/demo_web/components/layouts/admin.html.heex b/demo/lib/demo_web/components/layouts/admin.html.heex
index 048401a82..91f3888c3 100644
--- a/demo/lib/demo_web/components/layouts/admin.html.heex
+++ b/demo/lib/demo_web/components/layouts/admin.html.heex
@@ -91,7 +91,20 @@
Tickets
+
+ <:label>Polymorphic Example
+
+ Entities
+
+
+ Persons
+
+
+ Cars
+
+
+
{render_slot(@inner_block)}
diff --git a/demo/lib/demo_web/live/car_live.ex b/demo/lib/demo_web/live/car_live.ex
new file mode 100644
index 000000000..679249aa7
--- /dev/null
+++ b/demo/lib/demo_web/live/car_live.ex
@@ -0,0 +1,92 @@
+defmodule DemoWeb.CarLive do
+ @moduledoc """
+ This module is similar to `PersonLive` in that it demos the use of a InlineCRUD set for type `:map`.
+ It also demos how you can use the power of `Backpex` to do a poor man's polymorphism by using `Backpex.LiveResource`
+ to control the values entered into the generic `:map` field while reusing the same schema
+ for different entity types.
+
+ It also is an example of how you could validate the values in the `:map` field by using a `validate`
+ function and calling `Backpex.Fields.InlineCRUD.changeset` inside the `changeset` function.
+ """
+ use Backpex.LiveResource,
+ adapter_config: [
+ schema: Demo.Entity,
+ repo: Demo.Repo,
+ update_changeset: &__MODULE__.changeset/3,
+ create_changeset: &__MODULE__.changeset/3,
+ item_query: &__MODULE__.item_query/3
+ ],
+ layout: {DemoWeb.Layouts, :admin},
+ fluid?: true
+
+ import Ecto.Query, only: [where: 3]
+
+ alias Backpex.Fields.InlineCRUD
+ alias Demo.Entity
+
+ @impl Backpex.LiveResource
+ def singular_name, do: "Car"
+
+ @impl Backpex.LiveResource
+ def plural_name, do: "Cars"
+
+ #
+ # Added the item_query function to filter entities by type
+ #
+ def item_query(query, _view, _assigns) do
+ query
+ |> where([entity], entity.type == "car")
+ end
+
+ #
+ # Added the changeset function to create a new entity with type "car",
+ # and validate the child fields in the entity
+ #
+ def changeset(entity, params, metadata \\ []) do
+ entity
+ |> Entity.changeset(params |> Map.put("type", "car"))
+ |> InlineCRUD.changeset(:fields, metadata)
+ end
+
+ @impl Backpex.LiveResource
+ def fields do
+ [
+ identity: %{
+ module: Backpex.Fields.Text,
+ label: "Model",
+ searchable: true
+ },
+ fields: %{
+ module: Backpex.Fields.InlineCRUD,
+ label: "Information",
+ type: :map,
+ except: [:index],
+ child_fields: [
+ engine_size: %{
+ module: Backpex.Fields.Text,
+ label: "Engine Size (cc)",
+ input_type: :integer
+ },
+ colour: %{
+ module: Backpex.Fields.Text,
+ label: "Colour"
+ },
+ year: %{
+ module: Backpex.Fields.Text,
+ label: "Year",
+ input_type: :integer
+ }
+ ],
+ validate: fn changeset ->
+ changeset
+ |> Ecto.Changeset.validate_required([:colour, :year])
+ |> Ecto.Changeset.validate_number(:year,
+ greater_than: 1900,
+ less_than: Date.utc_today().year + 1,
+ message: "must be between 1900 and #{Date.utc_today().year}"
+ )
+ end
+ }
+ ]
+ end
+end
diff --git a/demo/lib/demo_web/live/entity_live.ex b/demo/lib/demo_web/live/entity_live.ex
new file mode 100644
index 000000000..2c0fef296
--- /dev/null
+++ b/demo/lib/demo_web/live/entity_live.ex
@@ -0,0 +1,54 @@
+defmodule DemoWeb.EntityLive do
+ use Backpex.LiveResource,
+ adapter_config: [
+ schema: Demo.Entity,
+ repo: Demo.Repo,
+ update_changeset: &Demo.Entity.changeset/3,
+ create_changeset: &Demo.Entity.changeset/3
+ ],
+ layout: {DemoWeb.Layouts, :admin},
+ fluid?: true
+
+ @impl Backpex.LiveResource
+ def singular_name, do: "Entity"
+
+ @impl Backpex.LiveResource
+ def plural_name, do: "Entities"
+
+ @impl Backpex.LiveResource
+ def item_actions(default_actions) do
+ default_actions
+ |> Keyword.delete(:delete)
+ end
+
+ @impl Backpex.LiveResource
+ def can?(_assigns, :index, _item), do: true
+
+ @impl Backpex.LiveResource
+ def can?(_assigns, _action, _item), do: false
+
+ @impl Backpex.LiveResource
+ def fields do
+ [
+ identity: %{
+ module: Backpex.Fields.Text,
+ label: "Identity",
+ searchable: true
+ },
+ type: %{
+ module: Backpex.Fields.Text,
+ label: "Type",
+ searchable: true,
+ readonly: true
+ },
+ fields: %{
+ module: Backpex.Fields.Textarea,
+ rows: 10,
+ label: "Fields",
+ searchable: true,
+ readonly: true,
+ except: [:index]
+ }
+ ]
+ end
+end
diff --git a/demo/lib/demo_web/live/person_live.ex b/demo/lib/demo_web/live/person_live.ex
new file mode 100644
index 000000000..979723678
--- /dev/null
+++ b/demo/lib/demo_web/live/person_live.ex
@@ -0,0 +1,79 @@
+defmodule DemoWeb.PersonLive do
+ @moduledoc """
+ This module just demos the use of a InlineCRUD set for type `:map`.
+ It also demos how you can use the power of `Backpex` to do a poor man's polymorphism by using `Backpex.LiveResource`
+ to control the values entered into the generic `:map` field while reusing the same schema
+ for different entity types.
+ """
+ use Backpex.LiveResource,
+ adapter_config: [
+ schema: Demo.Entity,
+ repo: Demo.Repo,
+ update_changeset: &__MODULE__.changeset/3,
+ create_changeset: &__MODULE__.changeset/3,
+ item_query: &__MODULE__.item_query/3
+ ],
+ layout: {DemoWeb.Layouts, :admin},
+ fluid?: true
+
+ import Ecto.Query, only: [where: 3]
+
+ alias Demo.Entity
+
+ @impl Backpex.LiveResource
+ def singular_name, do: "Person"
+
+ @impl Backpex.LiveResource
+ def plural_name, do: "Persons"
+
+ #
+ # Added the item_query function to filter entities by type
+ #
+ def item_query(query, _view, _assigns) do
+ query
+ |> where([entity], entity.type == "person")
+ end
+
+ #
+ # Added the changeset function to create a changeset for a person entity
+ #
+ def changeset(entity, params, _metadata \\ []) do
+ entity
+ |> Entity.changeset(params |> Map.put("type", "person"))
+ end
+
+ @impl Backpex.LiveResource
+ def fields do
+ [
+ identity: %{
+ module: Backpex.Fields.Text,
+ label: "Name",
+ searchable: true
+ },
+ fields: %{
+ module: Backpex.Fields.InlineCRUD,
+ label: "Information",
+ type: :map,
+ except: [:index],
+ child_fields: [
+ email: %{
+ module: Backpex.Fields.Text,
+ label: "Email"
+ },
+ phone: %{
+ module: Backpex.Fields.Text,
+ label: "Phone"
+ },
+ age: %{
+ module: Backpex.Fields.Text,
+ label: "Age"
+ },
+ weight: %{
+ module: Backpex.Fields.Text,
+ label: "Weight (kg)"
+ }
+ ]
+ }
+ ]
+ end
+end
diff --git a/demo/lib/demo_web/live/product_live.ex b/demo/lib/demo_web/live/product_live.ex
index ca17f1eb6..38c8ef431 100644
--- a/demo/lib/demo_web/live/product_live.ex
+++ b/demo/lib/demo_web/live/product_live.ex
@@ -85,6 +85,24 @@ defmodule DemoWeb.ProductLive do
label: "Price",
align: :right
},
+ more_info: %{
+ module: Backpex.Fields.InlineCRUD,
+ label: "More Info",
+ type: :embed_one,
+ except: [:index],
+ child_fields: [
+ weight: %{
+ module: Backpex.Fields.Text,
+ label: "Ave. Weight (kg)"
+ },
+ goes_well_with: %{
+ module: Backpex.Fields.Textarea,
+ label: "Goes well with",
+ input_type: :textarea,
+ rows: 5
+ }
+ ]
+ },
suppliers: %{
module: Backpex.Fields.InlineCRUD,
label: "Suppliers",
diff --git a/demo/lib/demo_web/router.ex b/demo/lib/demo_web/router.ex
index bba4d8c63..0bb31c104 100644
--- a/demo/lib/demo_web/router.ex
+++ b/demo/lib/demo_web/router.ex
@@ -45,6 +45,9 @@ defmodule DemoWeb.Router do
live_resources "/film-reviews", FilmReviewLive
live_resources "/short-links", ShortLinkLive
live_resources "/tickets", TicketLive
+ live_resources "/entities", EntityLive
+ live_resources "/persons", PersonLive
+ live_resources "/cars", CarLive
end
end
end
diff --git a/demo/priv/repo/migrations/20251018130237_products_add_more_info.exs b/demo/priv/repo/migrations/20251018130237_products_add_more_info.exs
new file mode 100644
index 000000000..0669b52d0
--- /dev/null
+++ b/demo/priv/repo/migrations/20251018130237_products_add_more_info.exs
@@ -0,0 +1,9 @@
+defmodule Demo.Repo.Migrations.ProductsAddMoreInfo do
+ use Ecto.Migration
+
+ def change do
+ alter table(:products) do
+ add :more_info, :map
+ end
+ end
+end
diff --git a/demo/priv/repo/migrations/20251020033241_create_entity.exs b/demo/priv/repo/migrations/20251020033241_create_entity.exs
new file mode 100644
index 000000000..3681b8bdc
--- /dev/null
+++ b/demo/priv/repo/migrations/20251020033241_create_entity.exs
@@ -0,0 +1,12 @@
+defmodule Demo.Repo.Migrations.CreateEntities do
+ use Ecto.Migration
+
+ def change do
+ create table(:entities) do
+ add :identity, :string, null: false
+ add :type, :string, null: false
+ add :fields, :map
+ timestamps()
+ end
+ end
+end
diff --git a/demo/priv/repo/seeds.exs b/demo/priv/repo/seeds.exs
index 704854805..d5199ae91 100644
--- a/demo/priv/repo/seeds.exs
+++ b/demo/priv/repo/seeds.exs
@@ -23,6 +23,10 @@ insert_list(10, :product)
insert_list(10, :address)
+insert_list(10, :car)
+
+insert_list(10, :person)
+
insert!(Demo.Helpdesk.Ticket, count: 10)
:code.priv_dir(:demo)
diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex
index 9fca07f26..7f1e56d52 100644
--- a/lib/backpex/fields/inline_crud.ex
+++ b/lib/backpex/fields/inline_crud.ex
@@ -1,8 +1,8 @@
defmodule Backpex.Fields.InlineCRUD do
@config_schema [
type: [
- doc: "The type of the field.",
- type: {:in, [:embed, :assoc]},
+ doc: "The type of the field. One of `:embed`, `:embed_one`, `:assoc`, or `:map`.",
+ type: {:in, [:embed, :assoc, :embed_one, :map]},
required: true
],
child_fields: [
@@ -16,11 +16,19 @@ defmodule Backpex.Fields.InlineCRUD do
""",
type: :keyword_list,
required: true
+ ],
+ validate: [
+ doc: """
+ An optional validation function used to validate `:map` child fields. It takes the changeset
+ and returns a changeset. You can use it to validate the `child_fields`
+ of a `map`, see the examples.
+ """,
+ type: {:fun, 1}
]
]
@moduledoc """
- A field to handle inline CRUD operations. It can be used with either an `embeds_many` or `has_many` (association) type column.
+ A field to handle inline CRUD operations. It can be used with columns of type `map`, `embeds_many`, `embeds_one`, or `has_many` (for associations).
## Field-specific options
@@ -32,7 +40,7 @@ defmodule Backpex.Fields.InlineCRUD do
>
> Everything is currently handled by plain text input.
- ### EmbedsMany
+ ### EmbedsMany and EmbedsOne
The field in the migration must be of type `:map`. You also need to use ecto's `cast_embed/2` in the changeset.
@@ -43,8 +51,8 @@ defmodule Backpex.Fields.InlineCRUD do
...
|> cast_embed(:your_field,
with: &your_field_changeset/2,
- sort_param: :your_field_order,
- drop_param: :your_field_delete
+ sort_param: :your_field_order, # not required for embeds_one
+ drop_param: :your_field_delete # not required for embeds_one
)
...
end
@@ -84,7 +92,59 @@ defmodule Backpex.Fields.InlineCRUD do
}
]
end
+ ### Map
+
+ By using the `:map` type you can use the `InlineCRUD` to control what fields can be stored in a `map`.
+ With the `:map` type the InlineCRUD uses the `input_type` option to build out the `types` map needed
+ to create a changeset. If the `input_type` is not set, it defaults to `:string`.
+ Another option is the `validate` callback that you can add to `fields` settings to
+ process the `child_fields` as in the example below.
+
+ @impl Backpex.LiveResource
+ def fields do
+ [
+ model: %{
+ module: Backpex.Fields.Text,
+ label: "Model",
+ searchable: true
+ },
+ info: %{
+ module: Backpex.Fields.InlineCRUD,
+ label: "Information",
+ type: :map,
+ except: [:index],
+ child_fields: [
+ engine_size: %{
+ module: Backpex.Fields.Text,
+ label: "Engine Size (cc)",
+ input_type: :integer
+ },
+ colour: %{
+ module: Backpex.Fields.Text,
+ label: "Colour"
+ },
+ year: %{
+ module: Backpex.Fields.Text,
+ label: "Year",
+ input_type: :integer
+ }
+ ],
+ validate: fn changeset ->
+ changeset
+ |> Ecto.Changeset.validate_required([:colour, :year])
+ |> Ecto.Changeset.validate_number(:year,
+ greater_than: 1900,
+ less_than: Date.utc_today().year + 1,
+ message: "must be between 1901 and #{Date.utc_today().year}"
+ )
+ end
+ }
+ ]
+ end
+
+ Then use the function `Backpex.Fields.InlineCRUD.changeset` in your schema's changeset to invoke the `validate` function.
"""
+
use Backpex.Field, config_schema: @config_schema
require Backpex
@@ -97,6 +157,13 @@ defmodule Backpex.Fields.InlineCRUD do
@impl Backpex.Field
def render_value(assigns) do
+ assigns =
+ assigns
+ |> assign(
+ :value,
+ if(assigns[:field_options].type in [:embed_one, :map], do: [get_value(assigns, :value)], else: assigns[:value])
+ )
+
~H"""
@@ -110,7 +177,12 @@ defmodule Backpex.Fields.InlineCRUD do
- {HTML.pretty_value(Map.get(row, name))}
+ {HTML.pretty_value(
+ Map.get(
+ row,
+ if(@field_options.type == :map, do: Atom.to_string(name), else: name)
+ )
+ )}
@@ -133,61 +205,92 @@ defmodule Backpex.Fields.InlineCRUD do
- <.inputs_for :let={f_nested} field={@form[@name]}>
-
+ <%= if @field_options.type != :map do %>
+ <.inputs_for :let={f_nested} field={@form[@name]}>
+
+
+
+
+
+ Atom.to_string()}
+ field={f_nested[child_field_name]}
+ aria-labelledby={"inline-crud-header-label-#{@name}-#{child_field_name} inline-crud-label-#{@name}"}
+ translate_error_fun={Backpex.Field.translate_error_fun(child_field_options, assigns)}
+ phx-debounce={Backpex.Field.debounce(child_field_options, assigns)}
+ phx-throttle={Backpex.Field.throttle(child_field_options, assigns)}
+ />
+
+ <%= if @field_options.type != :embed_one do %>
+
+ <% end %>
+
+
+ <%= if @field_options.type != :embed_one do %>
+
+ <% end %>
+ <% else %>
Atom.to_string()}
- field={f_nested[child_field_name]}
+ name={"change[#{Atom.to_string(@name)}][#{Atom.to_string(child_field_name)}]"}
+ value={changeset_value(assigns, child_field_name)}
+ errors={changeset_errors(assigns, child_field_name)}
aria-labelledby={"inline-crud-header-label-#{@name}-#{child_field_name} inline-crud-label-#{@name}"}
translate_error_fun={Backpex.Field.translate_error_fun(child_field_options, assigns)}
phx-debounce={Backpex.Field.debounce(child_field_options, assigns)}
phx-throttle={Backpex.Field.throttle(child_field_options, assigns)}
/>
-
-
-
+ <% end %>
+ <%= if @field_options.type in [:embed, :assoc] do %>
+
+ <% end %>
-
+ <%= if help_text = Backpex.Field.help_text(@field_options, assigns) do %>
+
{help_text}
+ <% end %>
-
-
- <%= if help_text = Backpex.Field.help_text(@field_options, assigns) do %>
- {help_text}
- <% end %>
"""
@@ -195,7 +298,7 @@ defmodule Backpex.Fields.InlineCRUD do
@impl Backpex.Field
def association?({_name, %{type: :assoc}} = _field), do: true
- def association?({_name, %{type: :embed}} = _field), do: false
+ def association?({_name, %{type: _type}} = _field), do: false
@impl Backpex.Field
def schema({name, _field_options}, schema) do
@@ -211,4 +314,85 @@ defmodule Backpex.Fields.InlineCRUD do
do: input_type
defp input_type(_child_field_options), do: :text
+
+ defp get_value(assigns, field) do
+ case Map.get(assigns, field, %{}) do
+ nil -> %{}
+ value -> value
+ end
+ end
+
+ @doc """
+ Use this function to create a changeset for your `:map` within
+ your schema's changeset function. Then you can use the `changeset`
+ it returns for the `:map` to validate your map fields in detail.
+
+ ## Parameters
+
+ * `form_changeset`: The `changeset` of your `schema`
+ * `map_field`: The name of the map field in your schema
+ * `metadata`: The `Backpex` metadata.
+
+ """
+ def changeset(form_changeset, map_field, metadata) do
+ field_info = get_in(metadata, [:assigns, :fields, map_field])
+
+ if is_nil(field_info) do
+ form_changeset
+ else
+ types =
+ field_info[:child_fields]
+ |> Map.new(fn {k, settings} ->
+ {k, settings |> Map.get(:input_type, :string)}
+ end)
+
+ case field_info[:validate] do
+ nil ->
+ form_changeset
+
+ validator ->
+ fields_changeset =
+ {%{}, types}
+ |> Ecto.Changeset.cast(Ecto.Changeset.get_field(form_changeset, map_field), Map.keys(types))
+ |> validator.()
+
+ form_changeset
+ |> copy_errors(fields_changeset)
+ |> copy_values(map_field, fields_changeset)
+ end
+ end
+ end
+
+ defp copy_errors(dest_changeset, src_changeset) do
+ Enum.reduce(src_changeset.errors, dest_changeset, fn {field, error}, acc ->
+ {msg, opts} = error
+ Ecto.Changeset.add_error(acc, field, msg, opts)
+ end)
+ end
+
+ defp copy_values(dest_changeset, map_field, src_changeset) do
+ dest_changeset
+ |> Ecto.Changeset.put_change(
+ map_field,
+ Ecto.Changeset.apply_changes(src_changeset)
+ |> Map.new(fn {k, v} -> {Atom.to_string(k), v} end)
+ )
+ end
+
+ defp changeset_value(assigns, field) when is_atom(field), do: changeset_value(assigns, Atom.to_string(field))
+
+ defp changeset_value(assigns, field) when is_binary(field) do
+ case Ecto.Changeset.get_field(assigns.changeset, assigns.name) do
+ nil -> nil
+ map -> Map.get(map, field)
+ end
+ end
+
+ defp changeset_errors(assigns, field) when is_binary(field),
+ do: changeset_errors(assigns, String.to_existing_atom(field))
+
+ defp changeset_errors(assigns, field) when is_atom(field) do
+ for {^field, {error, _opts}} <- assigns.changeset.errors,
+ do: error
+ end
end