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 @@ -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]}> + + +
+
+ + {child_field_options.label} + + 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 %>
{child_field_options.label} 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
- {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) + ) + )}