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()
- Display show_in_overview status as badge (Yes/No)
- 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
"""
use MvWeb, :live_component
alias Mv.Membership
alias MvWeb.Translations.MemberFields
alias MvWeb.Translations.FieldTypes
@required_fields [:first_name, :last_name, :email]
@ -24,16 +26,44 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
~H"""
<div id={@id}>
<.form_section title={gettext("Memberdata")}>
<p class="text-sm text-base-content/70 mb-4">
{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."
)}
</p>
<.table id="member_fields" rows={@member_fields}>
<:col :let={{_field_name, field_data}} label={gettext("Field Name")}>
{format_field_name(field_data.field)}
<%!-- Show form when editing --%>
<div :if={@show_form} class="mb-8">
<.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
@ -66,81 +96,53 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
</:col>
<:action :let={{_field_name, field_data}}>
<button
id={"member-field-#{field_data.field}-toggle"}
phx-click="toggle_field_visibility"
phx-keydown="toggle_field_visibility"
phx-key="Enter"
<.link
phx-click="edit_member_field"
phx-value-field={Atom.to_string(field_data.field)}
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")}
</button>
{gettext("Edit")}
</.link>
</:action>
</.table>
</.form_section>
</div>
"""
end
@impl true
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,
socket
|> 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
@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
valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
if field_string in valid_fields do
{:ok, settings} = Membership.get_settings()
# 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})
field_atom = String.to_existing_atom(field_string)
{:noreply,
socket
|> assign(:settings, updated_settings)}
{:error, error} ->
# Send error message to parent LiveView for user feedback
send(self(), {:member_field_visibility_error, error})
{:noreply, socket}
end
|> assign(:show_form, true)
|> assign(:editing_member_field, field_atom)
|> assign(:form_id, "member-field-form-#{field_string}")}
else
{:noreply, socket}
end
@ -169,9 +171,57 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
Enum.map(member_fields, fn field ->
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)
|> 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
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?(_), 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