diff --git a/guides/fields/readonly.md b/guides/fields/readonly.md index 68aabc6a2..1f550a743 100644 --- a/guides/fields/readonly.md +++ b/guides/fields/readonly.md @@ -1,6 +1,6 @@ # Readonly -Fields can be configured to be readonly. In edit view, these fields are rendered with the additional HTML attributes `readonly` and `disabled`, ensuring that users cannot interact with the field or change its value. +Fields can be configured to be readonly. In edit view, readonly fields prevent users from interacting with the field or changing its value, while still displaying the current value. In index view, if readonly and index editable are both set to true, forms will be rendered with the `readonly` HTML attribute. @@ -8,16 +8,57 @@ In index view, if readonly and index editable are both set to true, forms will b On index view, readonly is supported for all fields with the index editable option (see [Index Edit](index-edit.md)). -On edit view, readonly is supported for: -- `Backpex.Fields.Date` -- `Backpex.Fields.DateTime` -- `Backpex.Fields.Number` +On edit view, `readonly` is a global field option defined on `Backpex.Field`, so every built-in field type inherits it. It accepts either a `boolean` or a function `(assigns -> boolean)`. + +Built-in fields render readonly using one of three strategies: + +**Native `readonly` (text-like inputs)** + +These fields render the browser's native `readonly` attribute on their input, so the value is still focusable and selectable but cannot be changed: + - `Backpex.Fields.Text` - `Backpex.Fields.Textarea` +- `Backpex.Fields.Number` +- `Backpex.Fields.Date` +- `Backpex.Fields.DateTime` +- `Backpex.Fields.Time` +- `Backpex.Fields.Email` +- `Backpex.Fields.URL` +- `Backpex.Fields.Currency` + +**`disabled` (control-style inputs)** + +Native `readonly` does not apply to these control types, so they render as `disabled` instead: + +- `Backpex.Fields.Select` +- `Backpex.Fields.Boolean` — renders as a disabled toggle +- `Backpex.Fields.BelongsTo` + +**Custom readonly rendering** + +A few fields need tailored behavior beyond a single attribute: + +- `Backpex.Fields.MultiSelect` — dropdown is rendered as a labelled, non-interactive group; selected badges lose the remove control +- `Backpex.Fields.HasMany` — dropdown is rendered as a labelled, non-interactive group; selected badges lose the remove control +- `Backpex.Fields.Upload` — the drop target and "Upload a file" link are disabled, the cancel/remove buttons on pending and existing entries are hidden, and the existing-file list is still displayed so users can see what is attached. +- `Backpex.Fields.InlineCRUD` — nested row fields become readonly, and the per-row delete checkbox and the add-row control are hidden entirely. +- `Backpex.Fields.HasManyThrough` — the Actions column (edit/remove buttons) is hidden, the "new relational" button is disabled, and pivot and select inputs inside the modal are rendered as disabled. + +## Appearance and accessibility + +Readonly inputs use the active theme's `base-200` background, `base-300` border, and +`base-content` text color. Native selects and custom dropdowns use the same treatment, +so non-editable values remain readable in light and dark themes. Validation errors +retain their error colors. + +Text-like inputs keep native `readonly` behavior: they remain keyboard-focusable, +retain a visible focus outline, and allow text selection and copying. Controls that +cannot use native `readonly` remain disabled or render as non-interactive groups. +The visual treatment does not change which values are submitted with the form. ## Configuration -To enable readonly for a field, you need to set the `readonly` option to true in the field configuration. This key must contain either a boolean value or a function that returns a boolean value. +To enable readonly for a field, you need to set the `readonly` option in the field configuration. This key must contain either a boolean value or a function that returns a boolean value. ```elixir # in your resource configuration file @@ -67,7 +108,6 @@ def render_form(assigns) do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} /> diff --git a/guides/upgrading/v0.21.md b/guides/upgrading/v0.21.md new file mode 100644 index 000000000..a97888641 --- /dev/null +++ b/guides/upgrading/v0.21.md @@ -0,0 +1,30 @@ +# Upgrading to v0.21 + +## Bump Your Deps + +Update Backpex to the latest version: + +```elixir +defp deps do + [ + {:backpex, "~> 0.21.0"} + ] +end +``` + +## Readonly fields + +`readonly` is now a global field option. Existing configurations keep working, +but check these changes when upgrading: + +- Disabled checkboxes and toggles no longer submit a hidden `false` value. + Custom form handlers must preserve existing values when these parameters are absent. +- Nested fields inherit a readonly parent; a child's `readonly: false` cannot override it. +- Review custom CSS and DOM selectors for the updated readonly styling and + non-interactive dropdown markup. +- Code inspecting field schemas must include `Backpex.Field.default_config_schema/0` + to find the global `readonly` option. + +See the [Readonly guide](../fields/readonly.md) for supported fields and behavior, +and [Readonly for custom fields](../fields/readonly.md#readonly-for-custom-fields) +for custom implementations. diff --git a/lib/backpex/field.ex b/lib/backpex/field.ex index b73072605..19695b672 100644 --- a/lib/backpex/field.ex +++ b/lib/backpex/field.ex @@ -12,6 +12,11 @@ defmodule Backpex.Field do type: :string, required: true ], + readonly: [ + doc: "Sets the field to readonly. Also see the [readonly](/guides/fields/readonly.md) guide.", + type: {:or, [:boolean, {:fun, 1}]}, + default: false + ], class: [ type: {:or, [:string, {:fun, 1}]}, doc: """ diff --git a/lib/backpex/fields/belongs_to.ex b/lib/backpex/fields/belongs_to.ex index 2dba75eb1..019927fc1 100644 --- a/lib/backpex/fields/belongs_to.ex +++ b/lib/backpex/fields/belongs_to.ex @@ -144,6 +144,8 @@ defmodule Backpex.Fields.BelongsTo do field={@form[@owner_key]} options={@options} prompt={@prompt} + readonly={@readonly} + disabled={@readonly} translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)} help_text={Backpex.Field.help_text(@field_options, assigns)} phx-debounce={Backpex.Field.debounce(@field_options, assigns)} diff --git a/lib/backpex/fields/boolean.ex b/lib/backpex/fields/boolean.ex index a0eea450d..41b945387 100644 --- a/lib/backpex/fields/boolean.ex +++ b/lib/backpex/fields/boolean.ex @@ -43,6 +43,7 @@ defmodule Backpex.Fields.Boolean do diff --git a/lib/backpex/fields/date_time.ex b/lib/backpex/fields/date_time.ex index df60eb00d..19947233f 100644 --- a/lib/backpex/fields/date_time.ex +++ b/lib/backpex/fields/date_time.ex @@ -18,10 +18,6 @@ defmodule Backpex.Fields.DateTime do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -114,7 +110,6 @@ defmodule Backpex.Fields.DateTime do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/email.ex b/lib/backpex/fields/email.ex index d8abffc17..f83f95ff7 100644 --- a/lib/backpex/fields/email.ex +++ b/lib/backpex/fields/email.ex @@ -12,10 +12,6 @@ defmodule Backpex.Fields.Email do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -56,7 +52,6 @@ defmodule Backpex.Fields.Email do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/has_many.ex b/lib/backpex/fields/has_many.ex index f4b815ad8..babbb41a7 100644 --- a/lib/backpex/fields/has_many.ex +++ b/lib/backpex/fields/has_many.ex @@ -142,21 +142,31 @@ defmodule Backpex.Fields.HasMany do
<:label :if={not @hide_label} align={Backpex.Field.align_label(@field_options, assigns)}> - + - + <:trigger class={[ - "input block h-fit w-full p-2", - @errors == [] && "bg-transparent", - @errors != [] && "input-error bg-error/10" + "block h-fit w-full p-2", + not @readonly && "input", + not @readonly && @errors == [] && "bg-transparent", + not @readonly && @errors != [] && "input-error bg-error/10", + @readonly && "rounded-field border-(length:--border) border min-h-10", + Backpex.HTML.Form.readonly_input_class(@readonly), + @readonly && @errors != [] && "border-error bg-error/10" ]} - aria_labelledby={Map.get(assigns, :aria_labelledby)} + aria_labelledby={Map.get(assigns, :aria_labelledby) || "#{@form[@name].id}-label"} >

{@prompt}

- <.badge :for={{label, value} <- @selected} label={label} value={value} name={@name} /> + <.badge + :for={{label, value} <- @selected} + label={label} + value={value} + readonly={@readonly} + name={@name} + />
<:menu class="w-full overflow-y-auto"> @@ -296,10 +306,17 @@ defmodule Backpex.Fields.HasMany do """ end + attr :readonly, :boolean, default: false attr :name, :string, required: true attr :label, :string, required: true attr :value, :string, required: true + defp badge(%{readonly: true} = assigns) do + ~H""" + {@label} + """ + end + defp badge(assigns) do ~H"""
diff --git a/lib/backpex/fields/has_many_through.ex b/lib/backpex/fields/has_many_through.ex index 1a0e7d446..be01d0e6f 100644 --- a/lib/backpex/fields/has_many_through.ex +++ b/lib/backpex/fields/has_many_through.ex @@ -76,6 +76,12 @@ defmodule Backpex.Fields.HasManyThrough do end The field requires a [`Ecto.Schema.has_many/3`](https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3) relation with a mandatory `through` option in the main schema. Any extra column in the pivot table besides the relational id's must be mapped in the `pivot_fields` option or given a default value. + + ## Readonly + + When the field is readonly, the Actions column (edit/remove) is hidden, the "new relational" + button is disabled, and any pivot and select inputs inside the edit-relation modal are disabled. + See the [readonly](/guides/fields/readonly.md) guide for details. """ use Backpex.Field, config_schema: @config_schema import Ecto.Query @@ -260,7 +266,7 @@ defmodule Backpex.Fields.HasManyThrough do > {label} - + {Backpex.__("Actions", @live_resource)} @@ -290,7 +296,7 @@ defmodule Backpex.Fields.HasManyThrough do {assigns} /> - +
@@ -449,6 +468,10 @@ defmodule Backpex.Fields.HasManyThrough do @impl Backpex.Field def association?(_field), do: true + attr :name, :atom, required: true + attr :form, :any, required: true + attr :readonly, :boolean, default: false + defp pivot_field(assigns) do name = assigns.name @@ -538,6 +561,14 @@ defmodule Backpex.Fields.HasManyThrough do items end + attr :form, :any, required: true + attr :hide_label, :boolean, required: true + attr :label, :string, required: true + attr :field_options, :any, required: true + attr :owner_key, :atom, required: true + attr :options, :list, required: true + attr :readonly, :boolean, default: false + defp select_relational_field(assigns) do ~H""" @@ -548,6 +579,8 @@ defmodule Backpex.Fields.HasManyThrough do type="select" field={@form[@owner_key]} options={@options} + disabled={@readonly} + aria-disabled={@readonly} translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)} phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} diff --git a/lib/backpex/fields/inline_crud.ex b/lib/backpex/fields/inline_crud.ex index 9f9918df5..c72d59b63 100644 --- a/lib/backpex/fields/inline_crud.ex +++ b/lib/backpex/fields/inline_crud.ex @@ -89,6 +89,12 @@ defmodule Backpex.Fields.InlineCRUD do } ] end + + ## Readonly + + When the field is readonly, each nested row's child fields render as readonly, the per-row delete + checkbox is hidden entirely, and the add-row control is hidden entirely. See the + [readonly](/guides/fields/readonly.md) guide for details. """ use Backpex.Field, config_schema: @config_schema @@ -185,7 +191,14 @@ defmodule Backpex.Fields.InlineCRUD do
<.inputs_for :let={f_nested} field={@form[@name]}> - +
-
+
<:label :if={not @hide_label} align={Backpex.Field.align_label(@field_options, assigns)}> - +
diff --git a/lib/backpex/fields/number.ex b/lib/backpex/fields/number.ex index 41e6fd1c0..e47ab1456 100644 --- a/lib/backpex/fields/number.ex +++ b/lib/backpex/fields/number.ex @@ -12,10 +12,6 @@ defmodule Backpex.Fields.Number do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -56,7 +52,6 @@ defmodule Backpex.Fields.Number do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/select.ex b/lib/backpex/fields/select.ex index a5618008b..4084f769d 100644 --- a/lib/backpex/fields/select.ex +++ b/lib/backpex/fields/select.ex @@ -78,6 +78,8 @@ defmodule Backpex.Fields.Select do field={@form[@name]} options={@options} prompt={@prompt} + readonly={@readonly} + disabled={@readonly} translate_error_fun={Backpex.Field.translate_error_fun(@field_options, assigns)} help_text={Backpex.Field.help_text(@field_options, assigns)} phx-debounce={Backpex.Field.debounce(@field_options, assigns)} diff --git a/lib/backpex/fields/text.ex b/lib/backpex/fields/text.ex index 602e93d79..ffc45dee8 100644 --- a/lib/backpex/fields/text.ex +++ b/lib/backpex/fields/text.ex @@ -12,10 +12,6 @@ defmodule Backpex.Fields.Text do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -56,7 +52,6 @@ defmodule Backpex.Fields.Text do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/textarea.ex b/lib/backpex/fields/textarea.ex index 30d7b6fe2..41224680e 100644 --- a/lib/backpex/fields/textarea.ex +++ b/lib/backpex/fields/textarea.ex @@ -17,10 +17,6 @@ defmodule Backpex.Fields.Textarea do doc: "Number of visible text lines for the control.", type: :non_neg_integer, default: 2 - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -66,7 +62,6 @@ defmodule Backpex.Fields.Textarea do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/time.ex b/lib/backpex/fields/time.ex index 07883172f..5be4447bb 100644 --- a/lib/backpex/fields/time.ex +++ b/lib/backpex/fields/time.ex @@ -18,10 +18,6 @@ defmodule Backpex.Fields.Time do throttle: [ doc: "Timeout value (in milliseconds) or function that receives the assigns.", type: {:or, [:pos_integer, {:fun, 1}]} - ], - readonly: [ - doc: "Sets the field to readonly. Also see the [panels](/guides/fields/readonly.md) guide.", - type: {:or, [:boolean, {:fun, 1}]} ] ] @@ -87,7 +83,6 @@ defmodule Backpex.Fields.Time do phx-debounce={Backpex.Field.debounce(@field_options, assigns)} phx-throttle={Backpex.Field.throttle(@field_options, assigns)} readonly={@readonly} - disabled={@readonly} aria-labelledby={Map.get(assigns, :aria_labelledby)} /> diff --git a/lib/backpex/fields/upload.ex b/lib/backpex/fields/upload.ex index b299adc90..de2cb0014 100644 --- a/lib/backpex/fields/upload.ex +++ b/lib/backpex/fields/upload.ex @@ -601,6 +601,12 @@ defmodule Backpex.Fields.Upload do ... }) + ## Readonly + + When the field is readonly, the drop target and the "Upload a file" link are disabled, and the + cancel/remove buttons on both pending and existing entries are hidden. The list of existing files + is still displayed so users can see the current value. See the + [readonly](/guides/fields/readonly.md) guide for details. """ use Backpex.Field, config_schema: @config_schema alias Backpex.HTML.Form, as: BackpexForm @@ -662,22 +668,31 @@ defmodule Backpex.Fields.Upload do

{Map.get(entry, :client_name)}

diff --git a/lib/backpex/html/core_components.ex b/lib/backpex/html/core_components.ex index 9e77510e5..2e7f5c8da 100644 --- a/lib/backpex/html/core_components.ex +++ b/lib/backpex/html/core_components.ex @@ -36,6 +36,7 @@ defmodule Backpex.HTML.CoreComponents do """ attr :id, :string, required: true, doc: "unique identifier for the dropdown" + attr :readonly, :boolean, default: false, doc: "whether the dropdown is readonly" attr :class, :any, default: nil, doc: "additional classes for the outer container element" slot :trigger, doc: "the trigger element to be used to toggle the dropdown menu" do @@ -62,32 +63,48 @@ defmodule Backpex.HTML.CoreComponents do _trigger -> nil end) - ~H""" -
-
- {render_slot(@trigger)} + if assigns.readonly do + ~H""" +
+
+ {render_slot(@trigger)} +
+ """ + else + ~H""" +
+
+ {render_slot(@trigger)} +
-
- {render_slot(@menu)} +
+ {render_slot(@menu)} +
-
- """ + """ + end end end diff --git a/lib/backpex/html/form.ex b/lib/backpex/html/form.ex index 7edc4e1ae..945e07b5f 100644 --- a/lib/backpex/html/form.ex +++ b/lib/backpex/html/form.ex @@ -60,14 +60,25 @@ defmodule Backpex.HTML.Form do ~H"""
@@ -83,14 +94,25 @@ defmodule Backpex.HTML.Form do ~H"""
@@ -110,9 +132,10 @@ defmodule Backpex.HTML.Form do name={@name} class={[ @input_class || "select w-full", + readonly_input_class(@rest[:disabled]), @errors != [] && (@error_class || - "select-error text-error-content bg-error/10 [&.select::picker(select)]:bg-base-100 [&.select::picker(select)]:text-base-content") + "select-error border-error text-error-content bg-error/10 [&.select::picker(select)]:bg-base-100 [&.select::picker(select)]:text-base-content") ]} multiple={@multiple} {@rest} @@ -138,7 +161,11 @@ defmodule Backpex.HTML.Form do @@ -158,7 +185,11 @@ defmodule Backpex.HTML.Form do name={@name} id={@id} value={Phoenix.HTML.Form.normalize_value(@type, @value)} - class={[@input_class || "input w-full", @errors != [] && (@error_class || "input-error bg-error/10")]} + class={[ + @input_class || "input w-full", + readonly_input_class(@rest[:readonly] || @rest[:disabled]), + @errors != [] && (@error_class || "input-error border-error bg-error/10") + ]} {@rest} /> @@ -235,7 +266,8 @@ defmodule Backpex.HTML.Form do <%!-- As the input ignores updates, we need to wrap it in a span to apply the styles correctly --%> _input]:input [&_>_input]:w-full", - @errors != [] && (@error_class || "[&_>_input]:input-error [&_>_input]:bg-error/10") + readonly_input_class(@rest[:readonly] || @rest[:disabled], :currency), + @errors != [] && (@error_class || "[&_>_input]:input-error [&_>_input]:border-error [&_>_input]:bg-error/10") ]}> @@ -247,6 +279,20 @@ defmodule Backpex.HTML.Form do """ end + @doc false + def readonly_input_class(readonly, target \\ :input) + + def readonly_input_class(readonly, _target) when readonly in [false, nil], do: nil + + def readonly_input_class(_readonly, :input) do + "border-base-300 bg-base-200 text-base-content shadow-none placeholder:text-base-content" + end + + # Currency styles live on the wrapper because LiveView ignores the masked input. + def readonly_input_class(_readonly, :currency) do + "[&_>_input]:border-base-300 [&_>_input]:bg-base-200 [&_>_input]:text-base-content [&_>_input]:shadow-none" + end + defp build_mask_pattern(:before, true, unit), do: "#{unit} num" defp build_mask_pattern(:before, false, unit), do: "#{unit}num" defp build_mask_pattern(:after, true, unit), do: "num #{unit}" @@ -292,6 +338,7 @@ defmodule Backpex.HTML.Form do @doc type: :component attr :prompt, :string, required: true, doc: "string that will be shown when no option is selected" + attr :readonly, :boolean, default: false, doc: "whether the dropdown is readonly" attr :help_text, :string, default: nil, doc: "help text to be displayed below input" attr :not_found_text, :string, required: true, doc: "string that will be shown when there are no options" attr :options, :list, required: true, doc: "a list of options for the select" @@ -315,14 +362,18 @@ defmodule Backpex.HTML.Form do ~H"""
- <.dropdown id={"multi-select-#{@field.id}"} class="w-full"> + <.dropdown id={"multi-select-#{@field.id}"} class="w-full" readonly={@readonly}> <:trigger aria_label={@prompt} aria_labelledby={Map.get(assigns, :aria_labelledby)} class={[ - "input block h-fit w-full p-2", - @errors == [] && "bg-transparent", - @errors != [] && "input-error bg-error/10" + "block h-fit w-full p-2", + not @readonly && "input", + not @readonly && @errors == [] && "bg-transparent", + not @readonly && @errors != [] && "input-error border-error bg-error/10", + @readonly && "rounded-field border-(length:--border) border min-h-10", + readonly_input_class(@readonly), + @readonly && @errors != [] && "border-error bg-error/10" ]} >
@@ -333,6 +384,7 @@ defmodule Backpex.HTML.Form do label={label} value={value} event_target={@event_target} + readonly={@readonly} />
@@ -390,10 +442,17 @@ defmodule Backpex.HTML.Form do end attr :live_resource, :atom, required: true + attr :readonly, :boolean, default: false attr :label, :string, required: true attr :value, :any, required: true attr :event_target, :any, required: true + defp multi_select_badge(%{readonly: true} = assigns) do + ~H""" + {@label} + """ + end + defp multi_select_badge(assigns) do ~H"""
diff --git a/lib/backpex/html/resource.ex b/lib/backpex/html/resource.ex index 85cff7093..b65f332f6 100644 --- a/lib/backpex/html/resource.ex +++ b/lib/backpex/html/resource.ex @@ -185,7 +185,14 @@ defmodule Backpex.HTML.Resource do |> assign(:field, field) |> assign(:field_options, field_options) |> assign(:type, :form) - |> assign(:readonly, Backpex.Field.readonly?(field_options, assigns)) + + # this is needed to apply `:readonly` to individual fields in `Backpex.Fields.InlineCRUD` + assigns = + if assigns[:readonly] do + assigns + else + assign(assigns, :readonly, Backpex.Field.readonly?(field_options, assigns)) + end ~H""" <.live_component diff --git a/mix.exs b/mix.exs index 8996e0144..a24736c0d 100644 --- a/mix.exs +++ b/mix.exs @@ -198,6 +198,7 @@ defmodule Backpex.MixProject do "guides/translations/translations.md", # Upgrade Guides + "guides/upgrading/v0.21.md", "guides/upgrading/v0.20.md", "guides/upgrading/v0.19.md", "guides/upgrading/v0.18.md", diff --git a/test/html/core_components_test.exs b/test/html/core_components_test.exs new file mode 100644 index 000000000..73c771d83 --- /dev/null +++ b/test/html/core_components_test.exs @@ -0,0 +1,86 @@ +defmodule Backpex.HTML.CoreComponentsTest do + use ExUnit.Case, async: true + + import Backpex.HTML.CoreComponents + import Phoenix.Component + import Phoenix.LiveViewTest + + # A thin wrapper so we can exercise the `dropdown/1` slots via render_component/2. + defmodule TestComponent do + use Phoenix.Component + + import Backpex.HTML.CoreComponents + + attr :readonly, :boolean, default: false + attr :class, :any, default: nil + + def test_dropdown(assigns) do + ~H""" + <.dropdown id="test-dd" class={@class} readonly={@readonly}> + <:trigger aria_label="open" class="trigger-class">Trigger + <:menu>Menu content + + """ + end + end + + describe "dropdown/1" do + test "renders dropdown class and trigger role in non-readonly mode" do + html = render_component(&TestComponent.test_dropdown/1, readonly: false, class: "w-full") + + doc = LazyHTML.from_fragment(html) + outer = LazyHTML.query(doc, "#test-dd") + trigger = LazyHTML.query(doc, "#test-dd-trigger") + menu = LazyHTML.query(doc, "#test-dd-menu") + + assert LazyHTML.attribute(outer, "class") == ["dropdown w-full"] + assert LazyHTML.attribute(outer, "phx-hook") == ["BackpexDropdown"] + + assert LazyHTML.attribute(trigger, "role") == ["button"] + assert LazyHTML.attribute(trigger, "tabindex") == ["0"] + assert LazyHTML.attribute(trigger, "aria-haspopup") == ["true"] + assert LazyHTML.attribute(trigger, "aria-label") == ["open"] + + # menu div is present + assert Enum.count(menu) == 1 + end + + test "renders inert div without interactive attrs in readonly mode" do + html = render_component(&TestComponent.test_dropdown/1, readonly: true, class: "w-full") + + doc = LazyHTML.from_fragment(html) + outer = LazyHTML.query(doc, "#test-dd") + trigger = LazyHTML.query(doc, "#test-dd-trigger") + menu = LazyHTML.query(doc, "#test-dd-menu") + + # User-supplied class is still passed through, but the dropdown class is not. + [outer_class] = LazyHTML.attribute(outer, "class") + refute outer_class =~ "dropdown" + assert LazyHTML.attribute(outer, "phx-hook") == [] + assert outer_class =~ "w-full" + + # No interactive attributes on the trigger in readonly mode. + assert LazyHTML.attribute(trigger, "role") == ["group"] + assert LazyHTML.attribute(trigger, "tabindex") == [] + assert LazyHTML.attribute(trigger, "aria-haspopup") == [] + assert LazyHTML.attribute(trigger, "aria-label") == ["open"] + assert LazyHTML.attribute(trigger, "aria-labelledby") == [] + + # Menu div is not rendered in readonly mode. + assert Enum.empty?(menu) + end + + test "passes through the user-supplied class on the outer wrapper in both modes" do + for readonly <- [false, true] do + html = render_component(&TestComponent.test_dropdown/1, readonly: readonly, class: "w-full") + + doc = LazyHTML.from_fragment(html) + outer = LazyHTML.query(doc, "#test-dd") + [outer_class] = LazyHTML.attribute(outer, "class") + + assert outer_class =~ "w-full", + "expected w-full in outer class for readonly=#{readonly}, got #{inspect(outer_class)}" + end + end + end +end diff --git a/test/html/form_test.exs b/test/html/form_test.exs new file mode 100644 index 000000000..5d1f54673 --- /dev/null +++ b/test/html/form_test.exs @@ -0,0 +1,177 @@ +defmodule Backpex.HTML.FormTest do + use ExUnit.Case, async: true + + import Phoenix.Component + import Phoenix.LiveViewTest + + alias Backpex.HTML.Form, as: BackpexForm + + # Build a bare `Phoenix.HTML.FormField` with the minimum needed for multi_select/1: + # `field.id` (for the dropdown wrapper id), `field.name` (for the search/hidden inputs) + # and `field.errors`. + defp build_field do + form = to_form(%{"tags" => ""}, as: nil) + + %Phoenix.HTML.FormField{ + id: "tags", + name: "tags", + errors: [], + field: :tags, + form: form, + value: "" + } + end + + defp base_assigns(overrides) do + defaults = [ + prompt: "Select an option", + not_found_text: "No options found", + options: [], + search_input: "", + event_target: nil, + field_options: %{}, + field: build_field(), + selected: [], + show_select_all: true, + show_more: false + ] + + Keyword.merge(defaults, overrides) + end + + describe "Boolean inputs" do + test "disabled checkboxes and toggles submit no values" do + for type <- ["checkbox", "toggle"], checked <- [true, false] do + html = + render_component(&BackpexForm.input/1, + type: type, + name: "preferred", + value: checked, + disabled: true + ) + + assert submitted_params(html) == %{} + end + end + + test "editable checkboxes and toggles still submit checked and unchecked values" do + for type <- ["checkbox", "toggle"], checked <- [true, false] do + html = + render_component(&BackpexForm.input/1, + type: type, + name: "preferred", + value: checked + ) + + assert submitted_params(html) == %{"preferred" => to_string(checked)} + end + end + + test "an unrelated nested edit preserves a true readonly Boolean" do + data = %{preferred: true, name: "Old supplier"} + types = %{preferred: :boolean, name: :string} + form = to_form(Ecto.Changeset.change({data, types}), as: "change[suppliers][0]") + child_fields = [preferred: %{module: Backpex.Fields.Boolean, label: "Preferred", readonly: true}] + fields = [suppliers: %{module: Backpex.Fields.InlineCRUD, child_fields: child_fields}] + + boolean_html = + render_component(&Backpex.Fields.Boolean.render_form/1, + form: form, + name: :preferred, + field_options: child_fields[:preferred], + readonly: true, + hide_label: true + ) + + name_html = + render_component(&BackpexForm.input/1, + type: "text", + name: form[:name].name, + value: "Updated supplier" + ) + + params = submitted_params(boolean_html <> name_html) + change = Backpex.Field.drop_readonly_changes(params["change"], fields, %{}) + + updated = + {data, types} + |> Ecto.Changeset.cast(change["suppliers"]["0"], [:preferred, :name]) + |> Ecto.Changeset.apply_changes() + + assert updated == %{preferred: true, name: "Updated supplier"} + end + end + + # Serialize successful controls in DOM order, including the hidden Boolean fallback. + defp submitted_params(html) do + html + |> LazyHTML.from_fragment() + |> LazyHTML.query( + "input[name]:not([disabled]):not([type=checkbox]), input[name][type=checkbox][checked]:not([disabled])" + ) + |> Enum.map(fn input -> + [name] = LazyHTML.attribute(input, "name") + [value] = LazyHTML.attribute(input, "value") + {name, value} + end) + |> URI.encode_query() + |> Plug.Conn.Query.decode() + end + + describe "multi_select/1" do + test "readonly prompt inherits readable text color" do + assigns = base_assigns(readonly: true, selected: []) + + html = render_component(&BackpexForm.multi_select/1, assigns) + + doc = LazyHTML.from_fragment(html) + # The prompt is the

rendered when `@selected == []`. + prompt = LazyHTML.query(doc, "p") + [prompt_class] = LazyHTML.attribute(prompt, "class") + + refute prompt_class =~ "text-base-content/" + trigger = LazyHTML.query(doc, "#multi-select-tags-trigger") + assert LazyHTML.attribute(trigger, "role") == ["group"] + end + + test "readonly badge has no remove button or badge-primary class" do + assigns = base_assigns(readonly: true, selected: [{"Elixir", "elixir"}]) + + html = render_component(&BackpexForm.multi_select/1, assigns) + + doc = LazyHTML.from_fragment(html) + # In readonly mode the badge is a . + badge = LazyHTML.query(doc, "span.badge") + [badge_class] = LazyHTML.attribute(badge, "class") + + assert badge_class =~ "badge" + refute badge_class =~ "badge-primary" + + # No interactive remove control inside the badge. + refute html =~ ~s(phx-click="toggle-option") + # And no buttons rendered as part of the badge markup. (The dropdown itself does + # not render a menu in readonly mode, so there should be no phx-click remove.) + remove_buttons = LazyHTML.query(doc, "span.badge [phx-click]") + assert Enum.empty?(remove_buttons) + end + + test "non-readonly badge includes badge-primary and remove affordance" do + assigns = base_assigns(readonly: false, selected: [{"Elixir", "elixir"}]) + + html = render_component(&BackpexForm.multi_select/1, assigns) + + doc = LazyHTML.from_fragment(html) + # In non-readonly mode the badge is a

. + badge = LazyHTML.query(doc, "div.badge") + [badge_class] = LazyHTML.attribute(badge, "class") + + assert badge_class =~ "badge-primary" + + # The remove affordance is a div with role="button" and phx-click="toggle-option" + # inside the badge. + assert html =~ ~s(phx-click="toggle-option") + remove = LazyHTML.query(doc, ~s(div.badge [phx-click="toggle-option"])) + refute Enum.empty?(remove) + end + end +end