Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions demo/lib/demo_web/live/user_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -193,9 +193,10 @@ defmodule DemoWeb.UserLive do
]
},
permissions: %{
module: Backpex.Fields.MultiSelect,
module: Backpex.Fields.CheckboxGroup,
label: "Permissions",
options: fn _assigns -> [{"Delete", "delete"}, {"Edit", "edit"}, {"Show", "show"}] end
options: fn _assigns -> [{"Delete", "delete"}, {"Edit", "edit"}, {"Show", "show"}] end,
columns: 3
}
]
end
Expand Down
281 changes: 281 additions & 0 deletions lib/backpex/fields/checkbox_group.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
defmodule Backpex.Fields.CheckboxGroup do
@config_schema [
options: [
doc: "List of options or function that receives the assigns.",
type: {:or, [{:list, :any}, {:fun, 1}]},
required: true
],
columns: [
doc: "Number of columns to display checkboxes (1-4). Defaults to 2.",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • default is automatically documented
  • in case we use a range below, it should also be documented already (not tested)
Suggested change
doc: "Number of columns to display checkboxes (1-4). Defaults to 2.",
doc: "Number of columns to display checkboxes.",

type: :integer,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a bit clearer:

Suggested change
type: :integer,
type: {:in, 1..4},

Not tested.

default: 2
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe 1 is even the better default value?

@Flo0807 WDYT?

Suggested change
default: 2
default: 1

],
prompt: [
doc: "The text to be displayed when no option is selected or function that receives the assigns.",
type: {:or, [:string, {:fun, 1}]}
],
not_found_text: [
doc: """
The text to be displayed when no options are found.

The default value is `"No options found"`.
""",
type: :string
]
]

@moduledoc """
A field for handling multiple selections with checkboxes.

## Field-specific options

See `Backpex.Field` for general field options.

#{NimbleOptions.docs(@config_schema)}

## Example

@impl Backpex.LiveResource
def fields do
[
categories: %{
module: Backpex.Fields.CheckboxGroup,
label: "Categories",
options: fn _assigns ->
Repo.all(Category)
|> Enum.map(fn category -> {category.name, category.id} end)
end,
columns: 2
}
]
end
"""
use Backpex.Field, config_schema: @config_schema
alias Backpex.HTML.Form
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app-1       |     warning: unused alias Form
app-1       |     │
app-1       |  54 │   alias Backpex.HTML.Form
app-1       |     │   ~
app-1       |     │
app-1       |     └─ lib/backpex/fields/checkbox_group.ex:54:3

require Backpex

@impl Phoenix.LiveComponent
def update(assigns, socket) do
socket
|> Phoenix.Component.assign(assigns)
|> Phoenix.Component.assign(
:not_found_text,
assigns.field_options[:not_found_text] || Backpex.__("No options found", assigns.live_resource)
)
|> Phoenix.Component.assign(:prompt, prompt(assigns, assigns.field_options))
|> assign_options()
|> assign_selected()
|> ok()
end

defp prompt(assigns, field_options) do
case Map.get(field_options, :prompt) do
nil -> Backpex.__("Select options...", assigns.live_resource)
prompt when is_function(prompt) -> prompt.(assigns)
prompt -> prompt
end
end

defp assign_options(socket) do
%{assigns: %{field_options: field_options} = assigns} = socket

options =
case field_options[:options] do
options_fun when is_function(options_fun, 1) -> options_fun.(assigns)
options when is_list(options) -> options
end
|> Enum.map(fn {label, value} ->
{to_string(label), to_string(value)}
end)

Phoenix.Component.assign(socket, :options, options)
end

defp assign_selected(socket) do
%{assigns: %{type: type, options: options, item: item, name: name} = assigns} = socket

selected_ids =
if type == :form do
values =
case Phoenix.HTML.Form.input_value(assigns.form, name) do
value when is_binary(value) -> [value]
value when is_list(value) -> value
_value -> []
end

Enum.map(values, &to_string/1)
else
value = Map.get(item, name)

if value, do: value, else: []
end

selected =
Enum.reduce(options, [], fn {_label, value} = option, acc ->
if value in selected_ids do
[option | acc]
else
acc
end
end)
|> Enum.reverse()

Phoenix.Component.assign(socket, :selected, selected)
end

defp get_column_class(columns) when is_integer(columns) and columns >= 1 and columns <= 4 do
case columns do
1 -> ""
2 -> "grid-cols-2"
3 -> "grid-cols-3"
4 -> "grid-cols-4"
end
end
defp get_column_class(_), do: ""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would let it fail here because it is not supported anyway.

Suggested change
defp get_column_class(_), do: ""


@impl Backpex.Field
def render_value(assigns) do
selected_labels = Enum.map(assigns.selected, fn {label, _value} = _option -> label end)

assigns = Phoenix.Component.assign(assigns, :selected_labels, selected_labels)

~H"""
<div class={[@live_action in [:index, :resource_action] && "truncate"]}>
<%= if @selected_labels == [], do: Phoenix.HTML.raw("&mdash;") %>

<div class={["flex", @live_action == :show && "flex-wrap"]}>
<%= for {item, index} <- Enum.with_index(@selected_labels) do %>
<p>
<%= Backpex.HTML.pretty_value(item) %>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uses old syntax

Suggested change
<%= Backpex.HTML.pretty_value(item) %>
{Backpex.HTML.pretty_value(item)}

</p>
<%= if index < length(@selected_labels) - 1 do %>
<span>,&nbsp;</span>
<% end %>
<% end %>
</div>
</div>
"""
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please try to use :if and :for special attributes in this template

end

@impl Backpex.Field
def render_form(assigns) do
columns = Map.get(assigns.field_options, :columns, 2)
column_class = get_column_class(columns)

selected_values = Enum.map(assigns.selected, fn {_label, value} -> value end)

assigns = assigns
|> Phoenix.Component.assign(:column_class, column_class)
|> Phoenix.Component.assign(:selected_values, selected_values)
Comment on lines +167 to +169
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file seems to be unformatted.


~H"""
<div id={@name} phx-hook="CheckboxGroupHook" data-field={@name}>
<Backpex.HTML.Layout.field_container>
<:label align={Backpex.Field.align_label(@field_options, assigns)}>
<Backpex.HTML.Layout.input_label text={@field_options[:label]} />
</:label>
<div>
<div class={"grid gap-2 " <> if(@column_class != "", do: @column_class, else: "")}>
<%= for {label, value} <- @options do %>
<div>
<label class="flex items-center cursor-pointer">
<input
type="checkbox"
name={"change[#{@name}][]"}
value={value}
checked={value in @selected_values}
class="checkbox checkbox-primary mr-2"
phx-click="toggle-option"
phx-value-id={value}
phx-target={@myself}
/>
<span class="text-sm cursor-pointer">
<%= label %>
</span>
</label>
</div>
<% end %>
</div>
Comment on lines +178 to +198
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably add a reusable <.input type="checkbox" multiple={true} ...> component.



<input type="hidden" name={"change[#{@name}][]"} value="" />
<script>
window.addEventListener('phx:update', () => {
if (!window.CheckboxGroupHook) {
window.CheckboxGroupHook = {
mounted() {
this.handleEvent(`checklist:${this.el.dataset.field}:changed`, ({values}) => {
// Update the actual checkboxes
const checkboxes = this.el.querySelectorAll('input[type="checkbox"]');
checkboxes.forEach(checkbox => {
checkbox.checked = values.includes(checkbox.value);
});
});
}
};
}
});
</script>
Comment on lines +202 to +218
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you move this to a hook?

</div>
</Backpex.HTML.Layout.field_container>
</div>
"""
end

@impl Backpex.Field
def render_index_form(assigns), do: render_form(assigns)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should allow that.

Bildschirmfoto 2025-05-05 um 16 51 32


@impl Backpex.Field
def render_form_readonly(assigns), do: render_value(assigns)

@impl Backpex.Field
def display_field({name, _field_options}), do: name

@impl Backpex.Field
def schema(_field, schema), do: schema

@impl Backpex.Field
def association?(_field), do: false

@impl Backpex.Field
def assign_uploads(_field, socket), do: socket

@impl Backpex.Field
def before_changeset(changeset, _attrs, _metadata, _repo, _field, _assigns), do: changeset

@impl Backpex.Field
def search_condition(schema_name, field_name, search_string) do
import Ecto.Query

dynamic(
[{^schema_name, schema_name}],
ilike(fragment("CAST(? AS TEXT)", field(schema_name, ^field_name)), ^search_string)
)
end
Comment on lines +228 to +254
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaults can be removed.

Suggested change
@impl Backpex.Field
def render_form_readonly(assigns), do: render_value(assigns)
@impl Backpex.Field
def display_field({name, _field_options}), do: name
@impl Backpex.Field
def schema(_field, schema), do: schema
@impl Backpex.Field
def association?(_field), do: false
@impl Backpex.Field
def assign_uploads(_field, socket), do: socket
@impl Backpex.Field
def before_changeset(changeset, _attrs, _metadata, _repo, _field, _assigns), do: changeset
@impl Backpex.Field
def search_condition(schema_name, field_name, search_string) do
import Ecto.Query
dynamic(
[{^schema_name, schema_name}],
ilike(fragment("CAST(? AS TEXT)", field(schema_name, ^field_name)), ^search_string)
)
end


@impl Phoenix.LiveComponent
def handle_event("toggle-option", %{"id" => id}, socket) do
%{assigns: %{selected: selected, options: options, name: name}} = socket

selected_item = Enum.find(selected, fn {_label, value} -> value == id end)

new_selected =
if selected_item do
Enum.reject(selected, fn {_label, value} -> value == id end)
else
selected
|> Enum.reverse()
|> Kernel.then(&[Enum.find(options, fn {_label, value} -> value == id end) | &1])
|> Enum.reverse()
end

new_selected_values = Enum.map(new_selected, fn {_label, value} -> value end)
target_value = %{"_target" => ["change", "#{name}"], "change" => %{to_string(name) => new_selected_values}}
send(self(), {:validate_change, target_value})

socket
|> Phoenix.Component.assign(:selected, new_selected)
|> push_event("checklist:#{name}:changed", %{values: new_selected_values})
|> noreply()
end
end
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont't really like how much code is redundant to Backpex.Fields.MultiSelect. I think we should implement a way to reuse code in both modules.

Loading