Member field settings: required checkbox, line break, toggle fix

Index/Form use member_field_required; Required disabled for email and
Vereinfacht-required fields with tooltip. Rebuild form with to_form
on validate to fix checkbox toggle. Add mt-4 block before Required.
This commit is contained in:
Moritz 2026-02-23 22:11:02 +01:00
parent 17fd5e13d5
commit 8933ad9d14
Signed by: moritz
GPG key ID: 1020A035E5DD0824
5 changed files with 154 additions and 155 deletions

View file

@ -16,8 +16,8 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
- `on_cancel` - Callback function to call when form is cancelled - `on_cancel` - Callback function to call when form is cancelled
## Note ## Note
Member fields are technical fields that cannot be changed (name, value_type, description, required). Member fields are technical fields that cannot be changed (name, value_type).
Only the visibility (show_in_overview) can be modified. Visibility (show_in_overview) and required flag are stored in Settings and can be modified.
""" """
use MvWeb, :live_component use MvWeb, :live_component
@ -27,14 +27,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
alias MvWeb.Helpers.FieldTypeFormatter alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
@required_fields [:first_name, :last_name, :email]
@impl true @impl true
def render(assigns) do def render(assigns) do
assigns = assigns =
assigns assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field)) |> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email) |> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:vereinfacht_required_field?, vereinfacht_required_field?(assigns))
|> assign(:field_label, MemberFields.label(assigns.member_field)) |> assign(:field_label, MemberFields.label(assigns.member_field))
~H""" ~H"""
@ -117,89 +116,64 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
</fieldset> </fieldset>
</div> </div>
<div <%!-- Line break before Required / Show in overview block --%>
:if={@is_email_field?} <div class="mt-4">
class="tooltip tooltip-right" <%!-- Required: disabled for email (always required) or Vereinfacht-required fields when integration is active --%>
data-tip={gettext("This is a technical field and cannot be changed")} <div
aria-label={gettext("This is a technical field and cannot be changed")} :if={@is_email_field? or @vereinfacht_required_field?}
> class="tooltip tooltip-right"
<fieldset class="mb-2 fieldset"> data-tip={
<label> if(@is_email_field?,
<span class="mb-1 label flex items-center gap-2"> do: gettext("This is a technical field and cannot be changed"),
{gettext("Description")} else: gettext("Required for Vereinfacht integration and cannot be disabled.")
<.icon )
name="hero-information-circle" }
class="w-4 h-4 text-base-content/60 cursor-help" aria-label={
aria-hidden="true" if(@is_email_field?,
/> do: gettext("This is a technical field and cannot be changed"),
</span> else: gettext("Required for Vereinfacht integration and cannot be disabled.")
<input )
type="text" }
name={@form[:description].name} >
id={@form[:description].id} <fieldset class="mb-2 fieldset">
value={@form[:description].value} <label>
disabled <input type="hidden" name={@form[:required].name} value="true" />
readonly <span class="label flex items-center gap-2">
class="w-full input" <input
/> type="checkbox"
</label> name={@form[:required].name}
</fieldset> id={@form[:required].id}
</div> value="true"
<.input checked={@form[:required].value}
:if={not @is_email_field?} disabled
field={@form[:description]} readonly
type="text" class="checkbox checkbox-sm"
label={gettext("Description")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@form[:required].name} value="false" disabled />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:required].name}
id={@form[:required].id}
value="true"
checked={@form[:required].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/> />
<span class="flex items-center gap-2">
{gettext("Required")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</span> </span>
</span> </label>
</label> </fieldset>
</fieldset> </div>
</div> <.input
<.input :if={not @is_email_field? and not @vereinfacht_required_field?}
:if={not @is_email_field?} field={@form[:required]}
field={@form[:required]} type="checkbox"
type="checkbox" label={gettext("Required")}
label={gettext("Required")} />
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<.input <.input
field={@form[:show_in_overview]} field={@form[:show_in_overview]}
type="checkbox" type="checkbox"
label={gettext("Show in overview")} label={gettext("Show in overview")}
/> />
</div>
<div class="justify-end mt-4 card-actions"> <div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}> <.button type="button" phx-click="cancel" phx-target={@myself}>
@ -225,24 +199,35 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"member_field" => member_field_params}, socket) do def handle_event("validate", %{"member_field" => member_field_params}, socket) do
# For member fields, we only validate show_in_overview
# Other fields are read-only or derived from the Member Resource
form = socket.assigns.form form = socket.assigns.form
# Unchecked checkboxes are not in params; preserve current form value when key is missing
updated_params = show_in_overview =
member_field_params if Map.has_key?(member_field_params, "show_in_overview") do
|> Map.put(
"show_in_overview",
TypeParsers.parse_boolean(member_field_params["show_in_overview"]) TypeParsers.parse_boolean(member_field_params["show_in_overview"])
) else
|> Map.put("name", form.source["name"]) form.source["show_in_overview"]
|> Map.put("value_type", form.source["value_type"]) end
|> Map.put("description", form.source["description"])
|> Map.put("required", form.source["required"]) required =
socket.assigns[:vereinfacht_required_field?] ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
# Merge so we keep name/value_type and have current checkbox state; use as new form source
merged_source =
form.source
|> Map.merge(%{
"show_in_overview" => show_in_overview,
"required" => required,
"name" => form.source["name"],
"value_type" => form.source["value_type"]
})
updated_form = updated_form =
form to_form(merged_source, as: "member_field")
|> Map.put(:value, updated_params)
|> Map.put(:errors, []) |> Map.put(:errors, [])
{:noreply, assign(socket, form: updated_form)} {:noreply, assign(socket, form: updated_form)}
@ -250,23 +235,36 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
@impl true @impl true
def handle_event("save", %{"member_field" => member_field_params}, socket) do def handle_event("save", %{"member_field" => member_field_params}, socket) do
# Only show_in_overview can be changed for member fields form = socket.assigns.form
show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"]) # Unchecked checkboxes are not in submit params; use form source when key missing
show_in_overview =
if Map.has_key?(member_field_params, "show_in_overview") do
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
else
form.source["show_in_overview"]
end
required =
socket.assigns[:vereinfacht_required_field?] ||
if Map.has_key?(member_field_params, "required") do
TypeParsers.parse_boolean(member_field_params["required"])
else
form.source["required"]
end
field_string = Atom.to_string(socket.assigns.member_field) field_string = Atom.to_string(socket.assigns.member_field)
# Use atomic action to update only this single field case Membership.update_single_member_field(
# This prevents lost updates in concurrent scenarios
case Membership.update_single_member_field_visibility(
socket.assigns.settings, socket.assigns.settings,
field: field_string, field: field_string,
show_in_overview: show_in_overview show_in_overview: show_in_overview,
required: required
) do ) do
{:ok, _updated_settings} -> {:ok, _updated_settings} ->
socket.assigns.on_save.(socket.assigns.member_field, "update") socket.assigns.on_save.(socket.assigns.member_field, "update")
{:noreply, socket} {:noreply, socket}
{:error, error} -> {:error, error} ->
# Add error to form
form = form =
socket.assigns.form socket.assigns.form
|> Map.put(:errors, [ |> Map.put(:errors, [
@ -288,16 +286,22 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
field_attributes = get_field_attributes(member_field) field_attributes = get_field_attributes(member_field)
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
normalized_config = VisibilityConfig.normalize(visibility_config) required_config = settings.member_field_required || %{}
show_in_overview = Map.get(normalized_config, member_field, true) normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_required = VisibilityConfig.normalize(required_config)
show_in_overview = Map.get(normalized_visibility, member_field, true)
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Email always required; Vereinfacht-required fields when integration active; else from settings
required =
member_field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(member_field)) ||
Map.get(normalized_required, member_field, false)
# Create a manual form structure with string keys
# Note: immutable is not included as it's not editable for member fields
form_data = %{ form_data = %{
"name" => MemberFields.label(member_field), "name" => MemberFields.label(member_field),
"value_type" => FieldTypeFormatter.format(field_attributes.value_type), "value_type" => FieldTypeFormatter.format(field_attributes.value_type),
"description" => field_attributes.description || "", "required" => required,
"required" => field_attributes.required,
"show_in_overview" => show_in_overview "show_in_overview" => show_in_overview
} }
@ -307,24 +311,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
end end
defp get_field_attributes(field) when is_atom(field) do defp get_field_attributes(field) when is_atom(field) do
# Get attribute info from Member Resource
alias Ash.Resource.Info alias Ash.Resource.Info
case Info.attribute(Mv.Membership.Member, field) do case Info.attribute(Mv.Membership.Member, field) do
nil -> nil ->
# Fallback for fields not in resource (shouldn't happen with Constants) %{value_type: :string}
%{
value_type: :string,
description: nil,
required: field in @required_fields
}
attribute -> attribute ->
%{ %{value_type: attribute.type}
value_type: attribute.type,
description: nil,
required: not attribute.allow_nil?
}
end end
end end
@ -335,4 +329,9 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp format_error(error) do defp format_error(error) do
inspect(error) inspect(error)
end end
defp vereinfacht_required_field?(assigns) do
Mv.Config.vereinfacht_configured?() &&
Mv.Constants.vereinfacht_required_field?(assigns.member_field)
end
end end

View file

@ -22,7 +22,6 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
assigns = assigns =
assigns assigns
|> assign(:member_fields, get_member_fields_with_visibility(assigns.settings)) |> assign(:member_fields, get_member_fields_with_visibility(assigns.settings))
|> assign(:required?, &required?/1)
~H""" ~H"""
<div id={@id}> <div id={@id}>
@ -62,22 +61,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{format_value_type(field_data.field)} {format_value_type(field_data.field)}
</:col> </:col>
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
{field_data.description || ""}
</:col>
<:col <:col
:let={{_field_name, field_data}} :let={{_field_name, field_data}}
label={gettext("Required")} label={gettext("Required")}
class="max-w-[9.375rem] text-center" class="max-w-[9.375rem] text-center"
> >
<span <span :if={field_data.required} class="text-base-content font-semibold">
:if={@required?.(field_data.field)}
class="text-base-content font-semibold"
>
{gettext("Required")} {gettext("Required")}
</span> </span>
<span :if={!@required?.(field_data.field)} class="text-base-content/70"> <span :if={!field_data.required} class="text-base-content/70">
{gettext("Optional")} {gettext("Optional")}
</span> </span>
</:col> </:col>
@ -173,26 +165,35 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
{:error, _} -> {:error, _} ->
# Return a minimal struct-like map for fallback # Return a minimal struct-like map for fallback
# This is only used for initial rendering, actual settings will be loaded properly # This is only used for initial rendering, actual settings will be loaded properly
%{member_field_visibility: %{}} %{member_field_visibility: %{}, member_field_required: %{}}
end end
end end
defp get_member_fields_with_visibility(settings) do defp get_member_fields_with_visibility(settings) do
member_fields = Mv.Constants.member_fields() member_fields = Mv.Constants.member_fields()
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
required_config = settings.member_field_required || %{}
vereinfacht_required? = Mv.Config.vereinfacht_configured?()
# Normalize visibility config keys to atoms normalized_visibility = VisibilityConfig.normalize(visibility_config)
normalized_config = VisibilityConfig.normalize(visibility_config) normalized_required = VisibilityConfig.normalize(required_config)
Enum.map(member_fields, fn field -> Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_config, field, true) show_in_overview = Map.get(normalized_visibility, field, true)
# Email always required; Vereinfacht-required fields when integration active; else from settings
required =
field == :email ||
(vereinfacht_required? && Mv.Constants.vereinfacht_required_field?(field)) ||
Map.get(normalized_required, field, false)
attribute = Info.attribute(Mv.Membership.Member, field) attribute = Info.attribute(Mv.Membership.Member, field)
%{ %{
field: field, field: field,
show_in_overview: show_in_overview, show_in_overview: show_in_overview,
value_type: (attribute && attribute.type) || :string, required: required,
description: nil value_type: (attribute && attribute.type) || :string
} }
end) end)
|> Enum.map(fn field_data -> |> Enum.map(fn field_data ->
@ -206,14 +207,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
attribute -> FieldTypeFormatter.format(attribute.type) attribute -> FieldTypeFormatter.format(attribute.type)
end end
end end
# Check if a field is required by checking the actual attribute definition
defp required?(field) when is_atom(field) do
case Info.attribute(Mv.Membership.Member, field) do
nil -> false
attribute -> not attribute.allow_nil?
end
end
defp required?(_), do: false
end end

View file

@ -287,8 +287,6 @@ msgstr "Abbrechen"
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -2911,3 +2909,8 @@ msgstr "Okt."
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Sep." msgid "Sep."
msgstr "Sep." msgstr "Sep."
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Für die Vereinfacht-Integration erforderlich und kann nicht deaktiviert werden."

View file

@ -288,8 +288,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -2911,3 +2909,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Sep." msgid "Sep."
msgstr "" msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr ""

View file

@ -288,8 +288,6 @@ msgstr ""
#: lib/mv_web/live/group_live/form.ex #: lib/mv_web/live/group_live/form.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/member_field_live/form_component.ex
#: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/membership_fee_type_live/form.ex #: lib/mv_web/live/membership_fee_type_live/form.ex
#: lib/mv_web/live/role_live/form.ex #: lib/mv_web/live/role_live/form.ex
#: lib/mv_web/live/role_live/index.html.heex #: lib/mv_web/live/role_live/index.html.heex
@ -2911,3 +2909,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Sep." msgid "Sep."
msgstr "" msgstr ""
#: lib/mv_web/live/member_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Required for Vereinfacht integration and cannot be disabled."
msgstr "Required for Vereinfacht integration and cannot be disabled."