diff --git a/demo/lib/demo/user.ex b/demo/lib/demo/user.ex index accdd1bd5..ac321195b 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 3dea7f29c..baffa9e28 100644 --- a/demo/lib/demo_web/live/user_live.ex +++ b/demo/lib/demo_web/live/user_live.ex @@ -122,6 +122,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", @@ -195,15 +205,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/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` diff --git a/lib/backpex/fields/checkgroup.ex b/lib/backpex/fields/checkgroup.ex new file mode 100644 index 000000000..a17b97d50 --- /dev/null +++ b/lib/backpex/fields/checkgroup.ex @@ -0,0 +1,109 @@ +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 + ], + readonly: [ + doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", + type: {:or, [:boolean, {:fun, 1}]} + ] + ] + + @moduledoc """ + A field for handling multiple checkboxes with predefined options. + + 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. + + #{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 + + @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 d1a1b592e..a1a921c2a 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]" @@ -144,6 +144,35 @@ 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} + +
+ +
+ <.error :for={msg <- @errors} :if={not @hide_errors}>{msg} + <.help_text :if={@help_text} id={"#{@id}-help"}>{@help_text} +
+ """ + end + def input(assigns) do ~H"""
@@ -270,13 +299,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)}

"""