Skip to content
Open
2 changes: 1 addition & 1 deletion demo/lib/demo/user.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 10 additions & 9 deletions demo/lib/demo_web/live/user_live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +12 to +14
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.

Are we using that?

end
1 change: 1 addition & 0 deletions guides/fields/what-is-a-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
109 changes: 109 additions & 0 deletions lib/backpex/fields/checkgroup.ex
Original file line number Diff line number Diff line change
@@ -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"""
<p class={@live_action in [:index, :resource_action] && "truncate"}>
{if @labels == [], do: raw("&mdash;"), else: @labels |> Enum.map(&HTML.pretty_value/1) |> Enum.join(", ")}
</p>
"""
end

@impl Backpex.Field
def render_form(assigns) do
options = get_options(assigns)

assigns = assign(assigns, :options, options)

~H"""
<div>
<Layout.field_container>
<:label :if={not @hide_label} align={Backpex.Field.align_label(@field_options, assigns)}>
<Layout.input_label as="span" text={@field_options[:label]} />
</:label>
<BackpexForm.input
type="checkgroup"
field={@form[@name]}
options={@options}
translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)}
help_text={Backpex.Field.help_text(@field_options, assigns)}
readonly={@readonly}
/>
</Layout.field_container>
</div>
"""
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
34 changes: 32 additions & 2 deletions lib/backpex/html/form.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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]"
Expand Down Expand Up @@ -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"""
<fieldset class={["fieldset py-0", @class]}>
<legend :if={@label} class="label mb-1">{@label}</legend>
<input type="hidden" name={@name} value="" tabindex="-1" aria-hidden="true" />
<div>
<label :for={{label, value} <- @options} class="flex min-h-11 cursor-pointer items-center space-x-2 md:min-h-8">
<input
type="checkbox"
id={"#{@id}-#{value}"}
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"}
/>
<span class="text-sm">{label}</span>
</label>
</div>
<.error :for={msg <- @errors} :if={not @hide_errors}>{msg}</.error>
<.help_text :if={@help_text} id={"#{@id}-help"}>{@help_text}</.help_text>
</fieldset>
"""
end

def input(assigns) do
~H"""
<div class={["fieldset py-0", @class]}>
Expand Down Expand Up @@ -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"""
<p class={["text-base-content/60", @class]}>
<p id={@id} class={["text-base-content/60", @class]}>
{render_slot(@inner_block)}
</p>
"""
Expand Down