Skip to content
4 changes: 4 additions & 0 deletions demo/lib/demo/ecto_factory.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,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)
}
Expand Down
14 changes: 14 additions & 0 deletions demo/lib/demo/product.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand All @@ -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
17 changes: 17 additions & 0 deletions demo/lib/demo_web/live/product_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,23 @@ 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",
rows: 5
}
]
},
suppliers: %{
module: Backpex.Fields.InlineCRUD,
label: "Suppliers",
Expand Down
Original file line number Diff line number Diff line change
@@ -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
86 changes: 53 additions & 33 deletions lib/backpex/fields/inline_crud.ex
Original file line number Diff line number Diff line change
@@ -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` or `:assoc`.",
type: {:in, [:embed, :assoc, :embed_one]},
required: true
],
child_fields: [
Expand All @@ -23,15 +23,19 @@ defmodule Backpex.Fields.InlineCRUD do
]

@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 either an `embeds_many`, `embeds_one`, or `has_many` (association) type column.

## Field-specific options

See `Backpex.Field` for general field options.

#{NimbleOptions.docs(@config_schema)}

### EmbedsMany
> #### Important {: .info}
>
> Everything is currently handled by plain text input.

### 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.

Expand All @@ -42,8 +46,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
Expand Down Expand Up @@ -112,6 +116,13 @@ defmodule Backpex.Fields.InlineCRUD do

@impl Backpex.Field
def render_value(assigns) do
assigns =
assigns
|> assign(
:value,
if(assigns[:field_options].type == :embed_one, do: [get_value(assigns, :value)], else: assigns[:value])
)

~H"""
<div class="ring-base-content/10 rounded-box overflow-x-auto ring-1">
<table class="table">
Expand Down Expand Up @@ -176,35 +187,37 @@ defmodule Backpex.Fields.InlineCRUD do
)
)}
</div>

<div class={if f_nested.index == 0, do: "mt-5", else: nil}>
<label for={"#{@name}-checkbox-delete-#{f_nested.index}"}>
<input
id={"#{@name}-checkbox-delete-#{f_nested.index}"}
type="checkbox"
name={"change[#{@name}_delete][]"}
value={f_nested.index}
class="hidden"
/>

<div class="btn btn-outline btn-error">
<span class="sr-only">{Backpex.__("Delete", @live_resource)}</span>
<Backpex.HTML.CoreComponents.icon name="hero-trash" class="size-5" />
</div>
</label>
</div>
<%= if @field_options.type != :embed_one do %>
<div class={if f_nested.index == 0, do: "mt-5", else: nil}>
<label for={"#{@name}-checkbox-delete-#{f_nested.index}"}>
<input
id={"#{@name}-checkbox-delete-#{f_nested.index}"}
type="checkbox"
name={"change[#{@name}_delete][]"}
value={f_nested.index}
class="hidden"
/>
<div class="btn btn-outline btn-error">
<span class="sr-only">{Backpex.__("Delete", @live_resource)}</span>
<Backpex.HTML.CoreComponents.icon name="hero-trash" class="size-5" />
</div>
</label>
</div>
<% end %>
</div>
</.inputs_for>

<input type="hidden" name={"change[#{@name}_delete][]"} tabindex="-1" aria-hidden="true" />
<%= if @field_options.type != :embed_one do %>
<input type="hidden" name={"change[#{@name}_delete][]"} tabindex="-1" aria-hidden="true" />
<% end %>
</div>
<input
name={"change[#{@name}_order][]"}
type="checkbox"
aria-label={Backpex.__("Add entry", @live_resource)}
class="btn btn-outline btn-sm btn-primary"
/>

<%= if @field_options.type != :embed_one do %>
<input
name={"change[#{@name}_order][]"}
type="checkbox"
aria-label={Backpex.__("Add entry", @live_resource)}
class="btn btn-outline btn-sm btn-primary"
/>
<% end %>
<%= if help_text = Backpex.Field.help_text(@field_options, assigns) do %>
<Backpex.HTML.Form.help_text class="mt-1">{help_text}</Backpex.HTML.Form.help_text>
<% end %>
Expand All @@ -215,7 +228,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
Expand All @@ -226,4 +239,11 @@ defmodule Backpex.Fields.InlineCRUD do
defp child_field_class(%{class: class} = _child_field_options, assigns) when is_function(class), do: class.(assigns)
defp child_field_class(%{class: class} = _child_field_options, _assigns) when is_binary(class), do: class
defp child_field_class(_child_field_options, _assigns), do: "flex-1"

defp get_value(assigns, field) do
case Map.get(assigns, field, %{}) do
nil -> %{}
value -> value
end
end
end