From 95c2d201bdc7ffcfbc0f44da9b7d8f565e1fe237 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Sat, 6 Dec 2025 00:31:45 +0100 Subject: [PATCH 01/10] Add checkgroup field --- demo/lib/demo/user.ex | 2 +- demo/lib/demo_web/live/user_live.ex | 19 ++-- ...1205230743_set_default_for_permissions.exs | 15 +++ lib/backpex/fields/checkgroup.ex | 91 +++++++++++++++++++ lib/backpex/html/form.ex | 27 +++++- 5 files changed, 143 insertions(+), 11 deletions(-) create mode 100644 demo/priv/repo/migrations/20251205230743_set_default_for_permissions.exs create mode 100644 lib/backpex/fields/checkgroup.ex diff --git a/demo/lib/demo/user.ex b/demo/lib/demo/user.ex index d8b9bd860..afb0daeea 100644 --- a/demo/lib/demo/user.ex +++ b/demo/lib/demo/user.ex @@ -15,7 +15,7 @@ defmodule Demo.User do field :full_name, :string, virtual: true field :age, :integer field :role, Ecto.Enum, values: [:user, :admin] - field :permissions, {:array, :string} + field :permissions, {:array, :string}, default: [] field :avatar, :string field :deleted_at, :utc_datetime diff --git a/demo/lib/demo_web/live/user_live.ex b/demo/lib/demo_web/live/user_live.ex index 711abe0f6..4f30facd9 100644 --- a/demo/lib/demo_web/live/user_live.ex +++ b/demo/lib/demo_web/live/user_live.ex @@ -120,6 +120,16 @@ defmodule DemoWeb.UserLive do options: [Admin: "admin", User: "user"], prompt: "Choose role..." }, + permissions: %{ + module: Backpex.Fields.Checkgroup, + label: "Permissions", + options: [ + {"Create Posts", "create_posts"}, + {"Edit Posts", "edit_posts"}, + {"Delete Posts", "delete_posts"}, + {"Manage Users", "manage_users"} + ] + }, posts: %{ module: Backpex.Fields.HasMany, label: "Posts", @@ -193,15 +203,6 @@ defmodule DemoWeb.UserLive do label: "Notes" } ] - }, - permissions: %{ - module: Backpex.Fields.MultiSelect, - label: "Permissions", - options: [ - {"Can access admin panel", "can_access_admin_panel"}, - {"Item actions", [{"Delete", "delete"}, {"Edit", "edit"}, {"Show", "show"}]}, - {"Other actions", [{"Can send email", "can_send_email"}]} - ] } ] end diff --git a/demo/priv/repo/migrations/20251205230743_set_default_for_permissions.exs b/demo/priv/repo/migrations/20251205230743_set_default_for_permissions.exs new file mode 100644 index 000000000..401a9023f --- /dev/null +++ b/demo/priv/repo/migrations/20251205230743_set_default_for_permissions.exs @@ -0,0 +1,15 @@ +defmodule Demo.Repo.Migrations.SetDefaultForPermissions do + use Ecto.Migration + + def up do + # Set default for new records + execute "ALTER TABLE users ALTER COLUMN permissions SET DEFAULT '{}'::text[]" + + # Update existing NULL values to empty array + execute "UPDATE users SET permissions = '{}' WHERE permissions IS NULL" + end + + def down do + execute "ALTER TABLE users ALTER COLUMN permissions DROP DEFAULT" + end +end diff --git a/lib/backpex/fields/checkgroup.ex b/lib/backpex/fields/checkgroup.ex new file mode 100644 index 000000000..1e959b115 --- /dev/null +++ b/lib/backpex/fields/checkgroup.ex @@ -0,0 +1,91 @@ +defmodule Backpex.Fields.Checkgroup do + @config_schema [ + options: [ + doc: "List of options or function that receives the assigns.", + type: {:or, [{:list, :any}, {:fun, 1}]}, + required: true + ] + ] + + @moduledoc """ + A field for handling multiple checkboxes with predefined options. + + This field stores selected values as an array. + + ## Field-specific options + + See `Backpex.Field` for general field options. + + #{NimbleOptions.docs(@config_schema)} + + ## Example + + @impl Backpex.LiveResource + def fields do + [ + roles: %{ + module: Backpex.Fields.Checkgroup, + label: "Roles", + options: [{"Admin", "admin"}, {"User", "user"}, {"Editor", "editor"}] + } + ] + end + """ + use Backpex.Field, config_schema: @config_schema + + alias Backpex.HTML + + @impl Backpex.Field + def render_value(assigns) do + options = get_options(assigns) + labels = get_labels(assigns.value, options) + + assigns = assign(assigns, :labels, labels) + + ~H""" +

+ {if @labels == [], do: raw("—"), else: @labels |> Enum.map(&HTML.pretty_value/1) |> Enum.join(", ")} +

+ """ + end + + @impl Backpex.Field + def render_form(assigns) do + options = get_options(assigns) + + assigns = assign(assigns, :options, options) + + ~H""" +
+ + <:label :if={not @hide_label} align={Backpex.Field.align_label(@field_options, assigns)}> + + + + +
+ """ + end + + defp get_labels(value, options) do + values = List.wrap(value) |> Enum.map(&to_string/1) + + options + |> Enum.filter(fn {_label, option_value} -> to_string(option_value) in values end) + |> Enum.map(fn {label, _value} -> label end) + end + + defp get_options(assigns) do + case Map.get(assigns.field_options, :options) do + options when is_function(options) -> options.(assigns) + options -> options + end + end +end diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index dd21ce354..ee2575d1f 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -23,7 +23,7 @@ defmodule Backpex.HTML.Form do attr :type, :string, default: "text", - values: ~w(checkbox color date datetime-local email file hidden month number password + values: ~w(checkbox checkgroup color date datetime-local email file hidden month number password range radio search select tel text textarea time toggle url week) attr :field, Phoenix.HTML.FormField, doc: "a form field struct retrieved from the form, for example: @form[:email]" @@ -142,6 +142,31 @@ defmodule Backpex.HTML.Form do """ end + def input(%{type: "checkgroup"} = assigns) do + ~H""" +
+ {@label} +
+ + +
+ <.error :for={msg <- @errors} :if={not @hide_errors}>{msg} + <.help_text :if={@help_text}>{@help_text} +
+ """ + end + def input(assigns) do ~H"""
From ac76ebea160b818752b9df0464445956d3a18adc Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:18:48 +0200 Subject: [PATCH 02/10] Remove redundant Backpex.HTML alias in Checkgroup The `alias Backpex.HTML` is already injected by `use Backpex.Field` via BackpexWeb's field helpers. Drop the duplicate alias; sibling fields like Select and Text rely on the injected alias as well. --- lib/backpex/fields/checkgroup.ex | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/backpex/fields/checkgroup.ex b/lib/backpex/fields/checkgroup.ex index 1e959b115..a50462548 100644 --- a/lib/backpex/fields/checkgroup.ex +++ b/lib/backpex/fields/checkgroup.ex @@ -33,8 +33,6 @@ defmodule Backpex.Fields.Checkgroup do """ use Backpex.Field, config_schema: @config_schema - alias Backpex.HTML - @impl Backpex.Field def render_value(assigns) do options = get_options(assigns) From e494a60fda83ed07885eab24541c0c20de542aa5 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:18:57 +0200 Subject: [PATCH 03/10] Fix type coercion in Checkgroup checked= comparison Compare option values and the current selection as strings, matching the coercion already performed by Checkgroup.get_labels/2. Without this, atom or integer option values never register as checked when the wrapped value is stored as strings (or vice versa). --- lib/backpex/html/form.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index ebd709c0e..062f0c5c1 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -156,7 +156,7 @@ defmodule Backpex.HTML.Form do id={"#{@id}-#{value}"} name={@name <> "[]"} value={value} - checked={value in List.wrap(@value)} + checked={to_string(value) in Enum.map(List.wrap(@value), &to_string/1)} class={["checkbox checkbox-sm checkbox-primary", @errors != [] && "checkbox-error"]} {@rest} /> From 7ed8c658c32c3f6c59843f875b31a98256632dcb Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:19:22 +0200 Subject: [PATCH 04/10] Use semantic fieldset/legend for Checkgroup and fix group ARIA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the checkgroup inputs in a
with a so the group has a native accessible name. Remove the `{@rest}` spread on individual checkboxes: group-level attributes such as aria-labelledby or required must not land on every box. Nest the checkbox input inside its
diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index 062f0c5c1..2a40948c9 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -146,11 +146,11 @@ defmodule Backpex.HTML.Form do def input(%{type: "checkgroup"} = assigns) do ~H""" -
- {@label} +
+ {@label} +
- -
<.error :for={msg <- @errors} :if={not @hide_errors}>{msg} <.help_text :if={@help_text}>{@help_text} -
+ """ end From 245c588910b71c2fa59e594aec5fa0fe30eaafa5 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:19:43 +0200 Subject: [PATCH 05/10] Wire aria-invalid and aria-describedby for Checkgroup errors Each checkbox now advertises its invalid state via aria-invalid when the field has errors, and points at the help text via aria-describedby when one is provided. Give the help_text component an optional `id` attribute so we can target it stably as `-help`. --- lib/backpex/html/form.ex | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index 2a40948c9..805be5bec 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -158,12 +158,14 @@ defmodule Backpex.HTML.Form do value={value} checked={to_string(value) in Enum.map(List.wrap(@value), &to_string/1)} class={["checkbox checkbox-sm checkbox-primary", @errors != [] && "checkbox-error"]} + aria-invalid={@errors != [] && "true"} + aria-describedby={@help_text && "#{@id}-help"} /> {label} <.error :for={msg <- @errors} :if={not @hide_errors}>{msg} - <.help_text :if={@help_text}>{@help_text} + <.help_text :if={@help_text} id={"#{@id}-help"}>{@help_text} """ end @@ -294,13 +296,14 @@ defmodule Backpex.HTML.Form do """ @doc type: :component + attr :id, :string, default: nil attr :class, :string, default: nil slot :inner_block, required: true def help_text(assigns) do ~H""" -

+

{render_slot(@inner_block)}

""" From 13404ee0d7b7367402d84c18a013ff6b5294e605 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:20:57 +0200 Subject: [PATCH 06/10] Support readonly on Checkgroup field Add a `readonly` option to Checkgroup's config schema, pass it through Checkgroup.render_form/1 to BackpexForm.input, and honor it on each checkbox via `disabled={@readonly}`. Matches the convention already used by the Text and Select fields. Because `{@rest}` is no longer spread onto individual checkboxes, the checkgroup clause pulls `readonly` out of `@rest` explicitly. --- lib/backpex/fields/checkgroup.ex | 5 +++++ lib/backpex/html/form.ex | 3 +++ 2 files changed, 8 insertions(+) diff --git a/lib/backpex/fields/checkgroup.ex b/lib/backpex/fields/checkgroup.ex index 89180ea89..b5f7da19a 100644 --- a/lib/backpex/fields/checkgroup.ex +++ b/lib/backpex/fields/checkgroup.ex @@ -4,6 +4,10 @@ defmodule Backpex.Fields.Checkgroup do doc: "List of options or function that receives the assigns.", type: {:or, [{:list, :any}, {:fun, 1}]}, required: true + ], + readonly: [ + doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", + type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -65,6 +69,7 @@ defmodule Backpex.Fields.Checkgroup do options={@options} translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)} help_text={Backpex.Field.help_text(@field_options, assigns)} + readonly={@readonly} /> diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index 805be5bec..28a0b662f 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -145,6 +145,8 @@ defmodule Backpex.HTML.Form do end def input(%{type: "checkgroup"} = assigns) do + assigns = assign_new(assigns, :readonly, fn -> Map.get(assigns.rest, :readonly, false) end) + ~H"""
{@label} @@ -157,6 +159,7 @@ defmodule Backpex.HTML.Form do name={@name <> "[]"} value={value} checked={to_string(value) in Enum.map(List.wrap(@value), &to_string/1)} + disabled={@readonly} class={["checkbox checkbox-sm checkbox-primary", @errors != [] && "checkbox-error"]} aria-invalid={@errors != [] && "true"} aria-describedby={@help_text && "#{@id}-help"} From 5eec3b924b012cc5c603f6df4908f3cfcf2203a1 Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:21:14 +0200 Subject: [PATCH 07/10] Document default: [] requirement in Checkgroup moduledoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Call out in the moduledoc that users must set `default: []` on both the Ecto schema field and the SQL column, otherwise unchecking all boxes will persist NULL instead of an empty array — a consequence of Ecto's `filter_empty_values/3` treating the hidden sentinel input's scalar "" as empty. --- lib/backpex/fields/checkgroup.ex | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/backpex/fields/checkgroup.ex b/lib/backpex/fields/checkgroup.ex index b5f7da19a..a17b97d50 100644 --- a/lib/backpex/fields/checkgroup.ex +++ b/lib/backpex/fields/checkgroup.ex @@ -16,6 +16,22 @@ defmodule Backpex.Fields.Checkgroup do This field stores selected values as an array. + > #### Schema and migration defaults {: .warning} + > + > You **must** declare `default: []` on the `{:array, _}` schema field *and* set a matching + > SQL default (`DEFAULT '{}'::text[]` in PostgreSQL). Otherwise unchecking every box will + > persist `NULL` instead of an empty array. The reason is Ecto's `filter_empty_values/3`: + > the hidden sentinel input submits the scalar `""`, which Ecto treats as empty and replaces + > with the schema default. + > + > # Ecto schema + > field :roles, {:array, :string}, default: [] + > + > # Migration + > alter table(:users) do + > modify :roles, {:array, :string}, default: [] + > end + ## Field-specific options See `Backpex.Field` for general field options. From c6bf8d291275b3a38eab98bfdeea392c48c3817e Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:21:23 +0200 Subject: [PATCH 08/10] Add Checkgroup to fields guide List Backpex.Fields.Checkgroup alongside the other built-in field types so it's discoverable from the fields guide. --- guides/fields/what-is-a-field.md | 1 + 1 file changed, 1 insertion(+) diff --git a/guides/fields/what-is-a-field.md b/guides/fields/what-is-a-field.md index 1d9ee1e6b..a866e6315 100644 --- a/guides/fields/what-is-a-field.md +++ b/guides/fields/what-is-a-field.md @@ -10,6 +10,7 @@ Backpex provides the following built-in field types: - `Backpex.Fields.BelongsTo` - `Backpex.Fields.Boolean` +- `Backpex.Fields.Checkgroup` - `Backpex.Fields.Currency` - `Backpex.Fields.DateTime` - `Backpex.Fields.Date` From 91b73ed05c121bab1d08c22b822b6a7e80eafeed Mon Sep 17 00:00:00 2001 From: Florian Arens <60519307+Flo0807@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:27:50 +0200 Subject: [PATCH 09/10] Tighten vertical rhythm between Checkgroup options The `min-h-11` touch target already enforces 44px rows, so the additional `space-y-2` on the options wrapper produced ~52px rows which felt too airy. Drop the extra gap and let the min-height carry the rhythm. --- lib/backpex/html/form.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index 28a0b662f..804a7fd76 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -151,7 +151,7 @@ defmodule Backpex.HTML.Form do
{@label} -
+