feat: adds form for member fields

This commit is contained in:
carla 2025-12-16 17:12:26 +01:00
parent 18c082a893
commit 5fa0b48acc
2 changed files with 592 additions and 105 deletions

View file

@ -0,0 +1,445 @@
defmodule MvWeb.MemberFieldLive.FormComponent do
@moduledoc """
LiveComponent form for editing member field properties (embedded in settings).
## Features
- Edit member field properties (name, value type, description, immutable, required, show in overview)
- Display member field information from Member Resource
- Restrict editing for email field (only show_in_overview can be changed)
- Real-time validation
- Updates Settings.member_field_visibility
## Props
- `member_field` - The member field atom to edit (e.g., :first_name, :email)
- `settings` - The current Settings resource
- `on_save` - Callback function to call when form is saved
- `on_cancel` - Callback function to call when form is cancelled
"""
use MvWeb, :live_component
alias Mv.Membership
alias MvWeb.Translations.MemberFields
alias MvWeb.Translations.FieldTypes
@required_fields [:first_name, :last_name, :email]
@impl true
def render(assigns) do
assigns =
assigns
|> assign(:field_attributes, get_field_attributes(assigns.member_field))
|> assign(:is_email_field?, assigns.member_field == :email)
|> assign(:field_label, MemberFields.label(assigns.member_field))
~H"""
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
<div class="card-body">
<div class="flex items-center gap-4 mb-4">
<.button
type="button"
phx-click="cancel"
phx-target={@myself}
aria-label={gettext("Back to member field overview")}
>
<.icon name="hero-arrow-left" class="w-4 h-4" />
</.button>
<h3 class="card-title">
{gettext("Edit Field: %{field}", field: @field_label)}
</h3>
</div>
<.form
for={@form}
id={@id <> "-form"}
phx-change="validate"
phx-submit="save"
phx-target={@myself}
>
<div
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>
<span class="mb-1 label flex items-center gap-2">
{gettext("Name")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:name].name}
id={@form[:name].id}
value={@field_label}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<div
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>
<span class="mb-1 label flex items-center gap-2">
{gettext("Value type")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:value_type].name}
id={@form[:value_type].id}
value={format_value_type(@field_attributes.value_type)}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<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>
<span class="mb-1 label flex items-center gap-2">
{gettext("Description")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:description].name}
id={@form[:description].id}
value={@form[:description].value}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:description]}
type="text"
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[:immutable].name} value="false" disabled />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:immutable].name}
id={@form[:immutable].id}
value="true"
checked={@form[:immutable].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Immutable")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</span>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:immutable]}
type="checkbox"
label={gettext("Immutable")}
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>
</span>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:required]}
type="checkbox"
label={gettext("Required")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<.input
field={@form[:show_in_overview]}
type="checkbox"
label={gettext("Show in overview")}
/>
<div class="justify-end mt-4 card-actions">
<.button type="button" phx-click="cancel" phx-target={@myself}>
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Field")}
</.button>
</div>
</.form>
</div>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_form()}
end
@impl true
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
updated_params =
member_field_params
|> Map.put("show_in_overview", parse_boolean(member_field_params["show_in_overview"]))
|> Map.put("name", form.source["name"])
|> Map.put("value_type", form.source["value_type"])
|> Map.put("description", form.source["description"])
|> Map.put("immutable", form.source["immutable"])
|> Map.put("required", form.source["required"])
updated_form =
form
|> Map.put(:value, updated_params)
|> Map.put(:errors, [])
{:noreply, assign(socket, form: updated_form)}
end
@impl true
def handle_event("save", %{"member_field" => member_field_params}, socket) do
# Only show_in_overview can be changed for member fields
show_in_overview = parse_boolean(member_field_params["show_in_overview"])
# Get current visibility config and update only the current field
current_visibility = socket.assigns.settings.member_field_visibility || %{}
field_string = Atom.to_string(socket.assigns.member_field)
# Normalize keys to strings
normalized_visibility =
Enum.reduce(current_visibility, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, Atom.to_string(key), value)
{key, value}, acc when is_binary(key) ->
Map.put(acc, key, value)
end)
# Update the specific field
updated_visibility = Map.put(normalized_visibility, field_string, show_in_overview)
# Update settings with new visibility
case Membership.update_member_field_visibility(
socket.assigns.settings,
updated_visibility
) do
{:ok, _updated_settings} ->
socket.assigns.on_save.(socket.assigns.member_field, "update")
{:noreply, socket}
{:error, error} ->
# Add error to form
form =
socket.assigns.form
|> Map.put(:errors, [
%{field: :show_in_overview, message: format_error(error)}
])
{:noreply, assign(socket, form: form)}
end
end
@impl true
def handle_event("cancel", _params, socket) do
socket.assigns.on_cancel.()
{:noreply, socket}
end
# Helper functions
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
field_attributes = get_field_attributes(member_field)
visibility_config = settings.member_field_visibility || %{}
normalized_config = normalize_visibility_config(visibility_config)
show_in_overview = Map.get(normalized_config, member_field, true)
# Create a manual form structure with string keys
form_data = %{
"name" => MemberFields.label(member_field),
"value_type" => format_value_type(field_attributes.value_type),
"description" => field_attributes.description || "",
"immutable" => field_attributes.immutable,
"required" => field_attributes.required,
"show_in_overview" => show_in_overview
}
form = to_form(form_data, as: "member_field")
assign(socket, form: form)
end
defp get_field_attributes(field) when is_atom(field) do
# Get attribute info from Member Resource
case Ash.Resource.Info.attribute(Mv.Membership.Member, field) do
nil ->
# Fallback for fields not in resource (shouldn't happen with Constants)
%{
value_type: :string,
description: nil,
immutable: field == :email,
required: field in @required_fields
}
attribute ->
%{
value_type: attribute.type,
description: nil,
immutable: field == :email,
required: not attribute.allow_nil?
}
end
end
defp format_value_type(type) when is_atom(type) do
type_string = to_string(type)
# Check if it's an Ash type module (e.g., Ash.Type.String or Elixir.Ash.Type.String)
if String.contains?(type_string, "Ash.Type.") do
# Extract the base type name from Ash type modules
# e.g., "Elixir.Ash.Type.String" -> "String" -> :string
type_name =
type_string
|> String.split(".")
|> List.last()
|> String.downcase()
try do
type_atom = String.to_existing_atom(type_name)
FieldTypes.label(type_atom)
rescue
ArgumentError ->
# Fallback if atom doesn't exist
FieldTypes.label(:string)
end
else
# It's already an atom like :string, :boolean, :date
FieldTypes.label(type)
end
end
defp format_value_type(type) do
# Fallback for unknown types
to_string(type)
end
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
defp parse_boolean(value) when is_boolean(value), do: value
defp parse_boolean("true"), do: true
defp parse_boolean("false"), do: false
defp parse_boolean(1), do: true
defp parse_boolean(0), do: false
defp parse_boolean(_), do: false
defp format_error(%Ash.Error.Invalid{} = error) do
Ash.ErrorKind.message(error)
end
defp format_error(error) do
inspect(error)
end
end

View file

@ -6,12 +6,14 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
- List all member fields from Mv.Constants.member_fields() - List all member fields from Mv.Constants.member_fields()
- Display show_in_overview status as badge (Yes/No) - Display show_in_overview status as badge (Yes/No)
- Display required status for required fields (first_name, last_name, email) - Display required status for required fields (first_name, last_name, email)
- Toggle show_in_overview flag for each field - Edit member field properties (expandable form like custom fields)
- Updates Settings.member_field_visibility - Updates Settings.member_field_visibility
""" """
use MvWeb, :live_component use MvWeb, :live_component
alias Mv.Membership alias Mv.Membership
alias MvWeb.Translations.MemberFields
alias MvWeb.Translations.FieldTypes
@required_fields [:first_name, :last_name, :email] @required_fields [:first_name, :last_name, :email]
@ -24,16 +26,44 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
~H""" ~H"""
<div id={@id}> <div id={@id}>
<.form_section title={gettext("Memberdata")}>
<p class="text-sm text-base-content/70 mb-4"> <p class="text-sm text-base-content/70 mb-4">
{gettext( {gettext(
"These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview."
)} )}
</p> </p>
<.table id="member_fields" rows={@member_fields}> <%!-- Show form when editing --%>
<:col :let={{_field_name, field_data}} label={gettext("Field Name")}> <div :if={@show_form} class="mb-8">
{format_field_name(field_data.field)} <.live_component
module={MvWeb.MemberFieldLive.FormComponent}
id={@form_id}
member_field={@editing_member_field}
settings={@settings}
on_save={
fn member_field, action ->
send(self(), {:member_field_saved, member_field, action})
end
}
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
/>
</div>
<%!-- Hide table when form is visible --%>
<.table
:if={!@show_form}
id="member_fields"
rows={@member_fields}
>
<:col :let={{_field_name, field_data}} label={gettext("Name")}>
{MemberFields.label(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Value Type")}>
{format_value_type(field_data.field)}
</:col>
<:col :let={{_field_name, field_data}} label={gettext("Description")}>
{field_data.description || ""}
</:col> </:col>
<:col <:col
@ -66,81 +96,53 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
</:col> </:col>
<:action :let={{_field_name, field_data}}> <:action :let={{_field_name, field_data}}>
<button <.link
id={"member-field-#{field_data.field}-toggle"} phx-click="edit_member_field"
phx-click="toggle_field_visibility" phx-value-field={Atom.to_string(field_data.field)}
phx-keydown="toggle_field_visibility"
phx-key="Enter"
phx-target={@myself} phx-target={@myself}
phx-value-field={field_data.field}
class="btn btn-sm btn-secondary"
aria-label={
if field_data.show_in_overview,
do:
gettext("Hide %{field} in overview", field: format_field_name(field_data.field)),
else:
gettext("Show %{field} in overview", field: format_field_name(field_data.field))
}
aria-pressed={to_string(field_data.show_in_overview)}
> >
{if field_data.show_in_overview, do: gettext("Hide"), else: gettext("Show")} {gettext("Edit")}
</button> </.link>
</:action> </:action>
</.table> </.table>
</.form_section>
</div> </div>
""" """
end end
@impl true @impl true
def update(assigns, socket) do def update(assigns, socket) do
# If show_form is explicitly provided in assigns, reset editing state
socket =
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
socket
|> assign(:editing_member_field, nil)
|> assign(:form_id, "member-field-form-new")
else
socket
end
{:ok, {:ok,
socket socket
|> assign(assigns) |> assign(assigns)
|> assign_new(:settings, fn -> get_settings() end)} |> assign_new(:settings, fn -> get_settings() end)
|> assign_new(:show_form, fn -> false end)
|> assign_new(:form_id, fn -> "member-field-form-new" end)
|> assign_new(:editing_member_field, fn -> nil end)}
end end
@impl true @impl true
def handle_event("toggle_field_visibility", %{"field" => field_string}, socket) do def handle_event("edit_member_field", %{"field" => field_string}, socket) do
# Validate that the field is a valid member field before converting to atom # Validate that the field is a valid member field before converting to atom
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field_string in valid_fields do if field_string in valid_fields do
{:ok, settings} = Membership.get_settings() field_atom = String.to_existing_atom(field_string)
# Get current visibility config
current_visibility = settings.member_field_visibility || %{}
# Normalize keys to strings
normalized_visibility =
Enum.reduce(current_visibility, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, Atom.to_string(key), value)
{key, value}, acc when is_binary(key) ->
Map.put(acc, key, value)
end)
# Toggle the field visibility
current_value = Map.get(normalized_visibility, field_string, true)
new_value = !current_value
updated_visibility = Map.put(normalized_visibility, field_string, new_value)
# Update settings
case Membership.update_member_field_visibility(settings, updated_visibility) do
{:ok, updated_settings} ->
# Send message to parent LiveView
send(self(), {:member_field_visibility_updated})
{:noreply, {:noreply,
socket socket
|> assign(:settings, updated_settings)} |> assign(:show_form, true)
|> assign(:editing_member_field, field_atom)
{:error, error} -> |> assign(:form_id, "member-field-form-#{field_string}")}
# Send error message to parent LiveView for user feedback
send(self(), {:member_field_visibility_error, error})
{:noreply, socket}
end
else else
{:noreply, socket} {:noreply, socket}
end end
@ -169,9 +171,57 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
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_config, field, true)
attribute = Ash.Resource.Info.attribute(Mv.Membership.Member, field)
{Atom.to_string(field), %{field: field, show_in_overview: show_in_overview}} %{
field: field,
show_in_overview: show_in_overview,
value_type: (attribute && attribute.type) || :string,
description: nil
}
end) end)
|> Enum.map(fn field_data ->
{Atom.to_string(field_data.field), field_data}
end)
end
defp format_value_type(field) when is_atom(field) do
case Ash.Resource.Info.attribute(Mv.Membership.Member, field) do
nil -> FieldTypes.label(:string)
attribute -> format_value_type(attribute.type)
end
end
defp format_value_type(type) when is_atom(type) do
type_string = to_string(type)
# Check if it's an Ash type module (e.g., Ash.Type.String or Elixir.Ash.Type.String)
if String.contains?(type_string, "Ash.Type.") do
# Extract the base type name from Ash type modules
# e.g., "Elixir.Ash.Type.String" -> "String" -> :string
type_name =
type_string
|> String.split(".")
|> List.last()
|> String.downcase()
try do
type_atom = String.to_existing_atom(type_name)
FieldTypes.label(type_atom)
rescue
ArgumentError ->
# Fallback if atom doesn't exist
FieldTypes.label(:string)
end
else
# It's already an atom like :string, :boolean, :date
FieldTypes.label(type)
end
end
defp format_value_type(type) do
# Fallback for unknown types
to_string(type)
end end
defp normalize_visibility_config(config) when is_map(config) do defp normalize_visibility_config(config) when is_map(config) do
@ -197,12 +247,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
defp required?(field) when field in @required_fields, do: true defp required?(field) when field in @required_fields, do: true
defp required?(_), do: false defp required?(_), do: false
defp format_field_name(field) when is_atom(field) do
field
|> Atom.to_string()
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
end end