Redesign member view/edit UI with improved accessibility

- Group fields into Personal Data, Custom Fields, and Payment Data sections
- Fix WCAG AA contrast issues and semantic HTML (dt/dd in dl)
- Format mailto links with member name in href attribute
This commit is contained in:
Moritz 2025-12-03 13:34:44 +01:00
parent 82f1a65b85
commit ed961f7585
Signed by: moritz
GPG key ID: 1020A035E5DD0824
5 changed files with 922 additions and 1642 deletions

View file

@ -5,80 +5,212 @@ defmodule MvWeb.MemberLive.Form do
## Features
- Create new members with personal information
- Edit existing member details
- Manage custom properties (dynamic fields)
- Grouped sections for better organization
- Tab navigation (Payments tab disabled, coming soon)
- Manage custom properties (dynamic fields, displayed sorted by name)
- Real-time validation with visual feedback
- Link/unlink user accounts
## Form Fields
**Required:**
- first_name, last_name, email
**Optional:**
- phone_number, address fields (city, street, house_number, postal_code)
- join_date, exit_date
- paid status
- notes
## Custom Field Values
Members can have dynamic custom field values defined by CustomFields.
The form dynamically renders inputs based on available CustomFields.
## Form Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
- Payment Data: Mockup section (not editable)
## Events
- `validate` - Real-time form validation
- `save` - Submit form (create or update member)
- Custom field value management events for adding/removing custom fields
"""
use MvWeb, :live_view
@impl true
def render(assigns) do
# Sort custom fields by name for display only
sorted_custom_fields = Enum.sort_by(assigns.custom_fields, & &1.name)
assigns = assign(assigns, :sorted_custom_fields, sorted_custom_fields)
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@page_title}
<:subtitle>
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
</:subtitle>
</.header>
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
<.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
<.input field={@form[:notes]} label={gettext("Notes")} />
<.input field={@form[:city]} label={gettext("City")} />
<.input field={@form[:street]} label={gettext("Street")} />
<.input field={@form[:house_number]} label={gettext("House Number")} />
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
<%!-- Header with Back button, Name display, and Save button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={return_path(@return_to, @member)} type="button">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
<% type =
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
<% input_type =
cond do
type && type.value_type == :boolean -> "checkbox"
type && type.value_type == :date -> :date
true -> :text
end %>
<.input field={value_form[:value]} label={type && type.name} type={input_type} />
</.inputs_for>
<input
type="hidden"
name={f_custom_field_value[:custom_field_id].name}
value={f_custom_field_value[:custom_field_id].value}
/>
</.inputs_for>
<h1 class="text-2xl font-bold text-center flex-1">
<%= if @member do %>
{@member.first_name} {@member.last_name}
<% else %>
{gettext("New Member")}
<% end %>
</h1>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Member")}
</.button>
<.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</div>
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button
type="button"
role="tab"
class="tab"
disabled
aria-disabled="true"
title={gettext("Coming soon")}
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</button>
</div>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.form_section title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-4">
<div class="w-48">
<.input field={@form[:first_name]} label={gettext("First Name")} required />
</div>
<div class="w-48">
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
</div>
</div>
<%!-- Address Row --%>
<div class="flex gap-4">
<div class="flex-1">
<.input field={@form[:street]} label={gettext("Street")} />
</div>
<div class="w-16">
<.input field={@form[:house_number]} label={gettext("Nr.")} />
</div>
<div class="w-24">
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
</div>
<div class="w-32">
<.input field={@form[:city]} label={gettext("City")} />
</div>
</div>
<%!-- Email --%>
<div>
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
</div>
<%!-- Phone --%>
<div>
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-4">
<div class="w-36">
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
</div>
<div class="w-36">
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
</div>
</div>
<%!-- Notes --%>
<div>
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
</div>
</div>
</.form_section>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.form_section title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
<%= for cf <- @sorted_custom_fields do %>
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
<%= if f_cfv[:custom_field_id].value == cf.id do %>
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
<.inputs_for :let={value_form} field={f_cfv[:value]}>
<.input
field={value_form[:value]}
label={cf.name}
type={custom_field_input_type(cf.value_type)}
/>
</.inputs_for>
<input
type="hidden"
name={f_cfv[:custom_field_id].name}
value={f_cfv[:custom_field_id].value}
/>
</div>
<% end %>
</.inputs_for>
<% end %>
</div>
</.form_section>
</div>
<% end %>
</div>
<%!-- Payment Data Section (Mockup) --%>
<div class="max-w-xl">
<.form_section title={gettext("Payment Data")}>
<div role="alert" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" />
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
</div>
<div class="flex gap-8">
<div class="w-24">
<label for="mock-contribution" class="label text-sm font-medium">
{gettext("Contribution")}
</label>
<input
type="text"
id="mock-contribution"
value="72 €"
disabled
class="input input-bordered w-full bg-base-200"
/>
</div>
<div class="w-40">
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label>
<div class="flex gap-3 mt-2">
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" />
<span class="text-sm">{gettext("monthly")}</span>
</label>
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" />
<span class="text-sm">{gettext("yearly")}</span>
</label>
</div>
</div>
<div class="w-24 flex items-end">
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
</div>
</div>
</.form_section>
</div>
<%!-- Bottom Action Buttons --%>
<div class="flex justify-end gap-4 mt-6">
<.button navigate={return_path(@return_to, @member)} type="button">
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save Member")}
</.button>
</div>
</.form>
</Layouts.app>
"""
@ -106,8 +238,8 @@ defmodule MvWeb.MemberLive.Form do
id -> Ash.get!(Mv.Membership.Member, id)
end
action = if is_nil(member), do: "New", else: "Edit"
page_title = action <> " " <> "Member"
action = if is_nil(member), do: gettext("New"), else: gettext("Edit")
page_title = "#{action} #{gettext("Member")}"
{:ok,
socket
@ -213,5 +345,37 @@ defmodule MvWeb.MemberLive.Form do
end
defp return_path("index", _member), do: ~p"/members"
defp return_path("show", nil), do: ~p"/members"
defp return_path("show", member), do: ~p"/members/#{member.id}"
# -----------------------------------------------------------------
# Helper Components
# -----------------------------------------------------------------
# Renders a form section box with border and title.
attr :title, :string, required: true
slot :inner_block, required: true
defp form_section(assigns) do
~H"""
<section class="mb-6">
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
{render_slot(@inner_block)}
</div>
</section>
"""
end
# -----------------------------------------------------------------
# Helper Functions for Custom Fields
# -----------------------------------------------------------------
# Returns input type for custom field based on value type
defp custom_field_input_type(:string), do: "text"
defp custom_field_input_type(:integer), do: "number"
defp custom_field_input_type(:boolean), do: "checkbox"
defp custom_field_input_type(:date), do: "date"
defp custom_field_input_type(:email), do: "email"
defp custom_field_input_type(_), do: "text"
end

View file

@ -3,19 +3,16 @@ defmodule MvWeb.MemberLive.Show do
LiveView for displaying a single member's details.
## Features
- Display all member information (personal, contact, address)
- Show linked user account (if exists)
- Display custom field values
- Display all member information in grouped sections
- Tab navigation for future features (Payments)
- Show custom field values with type-based formatting
- Navigate to edit form
- Return to member list
## Displayed Information
- Basic: name, email, dates (join, exit)
- Contact: phone number
- Address: street, house number, postal code, city
- Status: paid flag
- Relationships: linked user account
- Custom: dynamic custom field values from CustomFields
## Sections
- Personal Data: Name, address, contact information, membership dates, notes
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
- Payment Data: Mockup section with placeholder data
## Navigation
- Back to member list
@ -23,69 +20,155 @@ defmodule MvWeb.MemberLive.Show do
"""
use MvWeb, :live_view
import Ash.Query
alias MvWeb.Helpers.DateFormatter
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.header>
{@member.first_name} {@member.last_name}
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
<%!-- Header with Back button, Name, and Edit button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<:actions>
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
<.icon name="hero-arrow-left" />
<span class="sr-only">{gettext("Back to members list")}</span>
</.button>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1">
{@member.first_name} {@member.last_name}
</h1>
<.list>
<:item title={gettext("Id")}>{@member.id}</:item>
<:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item>
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
<:item title={gettext("Join Date")}>{DateFormatter.format_date(@member.join_date)}</:item>
<:item title={gettext("Exit Date")}>{DateFormatter.format_date(@member.exit_date)}</:item>
<:item title={gettext("Notes")}>{@member.notes}</:item>
<:item title={gettext("City")}>{@member.city}</:item>
<:item title={gettext("Street")}>{@member.street}</:item>
<:item title={gettext("House Number")}>{@member.house_number}</:item>
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
<:item title={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-600 hover:text-blue-800 underline"
>
<.icon name="hero-user" class="h-4 w-4 inline mr-1" />
{@member.user.email}
</.link>
<% else %>
<span class="text-gray-500 italic">{gettext("No user linked")}</span>
<% end %>
</:item>
</.list>
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
{gettext("Edit Member")}
</.button>
</div>
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
<.generic_list items={
Enum.map(@member.custom_field_values, fn cfv ->
{
# name
cfv.custom_field && cfv.custom_field.name,
# value
format_custom_field_value(cfv)
}
end)
} />
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered mb-6">
<button role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</button>
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</button>
</div>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.section_box title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-6">
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
</div>
<%!-- Address --%>
<div>
<.data_field label={gettext("Address")} value={format_address(@member)} />
</div>
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{format_email_mailto(@member.first_name, @member.last_name, @member.email)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
</.data_field>
</div>
<%!-- Phone --%>
<div>
<.data_field label={gettext("Phone")} value={@member.phone_number} />
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("Join Date")}
value={format_date(@member.join_date)}
class="w-28"
/>
<.data_field
label={gettext("Exit Date")}
value={format_date(@member.exit_date)}
class="w-28"
/>
</div>
<%!-- Linked User --%>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@member.custom_field_values) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %>
<% custom_field = cfv.custom_field %>
<% value_type = custom_field && custom_field.value_type %>
<.data_field label={custom_field && custom_field.name}>
{format_custom_field_value(cfv.value, value_type)}
</.data_field>
<% end %>
</div>
</.section_box>
</div>
<% end %>
</div>
<%!-- Payment Data Section (Mockup) --%>
<div class="max-w-xl">
<.section_box title={gettext("Payment Data")}>
<div role="alert" class="alert alert-info mb-4">
<.icon name="hero-information-circle" class="size-5" />
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
</div>
<div class="flex gap-6">
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" />
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" />
<.data_field label={gettext("Paid")} class="w-24">
<%= if @member.paid do %>
<span class="badge badge-success">{gettext("Paid")}</span>
<% else %>
<span class="badge badge-warning">{gettext("Pending")}</span>
<% end %>
</.data_field>
</div>
</.section_box>
</div>
</Layouts.app>
"""
end
@ -113,16 +196,132 @@ defmodule MvWeb.MemberLive.Show do
defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member")
defp format_custom_field_value(cfv) do
value =
case cfv.value do
%{value: v} -> v
v -> v
end
# -----------------------------------------------------------------
# Helper Components
# -----------------------------------------------------------------
case value do
%Date{} = date -> DateFormatter.format_date(date)
other -> other
# Renders a section box with border and title.
attr :title, :string, required: true
slot :inner_block, required: true
defp section_box(assigns) do
~H"""
<section class="mb-6">
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
{render_slot(@inner_block)}
</div>
</section>
"""
end
# Renders a labeled data field.
attr :label, :string, required: true
attr :value, :string, default: nil
attr :class, :string, default: ""
slot :inner_block
defp data_field(assigns) do
~H"""
<dl class={@class}>
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
<dd class="mt-1 text-base-content">
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
{display_value(@value)}
<% end %>
</dd>
</dl>
"""
end
# -----------------------------------------------------------------
# Helper Functions
# -----------------------------------------------------------------
defp display_value(nil), do: ""
defp display_value(""), do: ""
defp display_value(value), do: value
defp format_email_mailto(first_name, last_name, email) do
name =
[first_name, last_name]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
if name != "" do
"#{name} <#{email}>"
else
email
end
end
defp format_address(member) do
street_part =
[member.street, member.house_number]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
city_part =
[member.postal_code, member.city]
|> Enum.filter(&(&1 && &1 != ""))
|> Enum.join(" ")
[street_part, city_part]
|> Enum.filter(&(&1 != ""))
|> Enum.join(", ")
|> case do
"" -> nil
address -> address
end
end
defp format_date(nil), do: nil
defp format_date(%Date{} = date) do
Calendar.strftime(date, "%d.%m.%Y")
end
defp format_date(date), do: to_string(date)
# Sorts custom field values by custom field name
defp sort_custom_field_values(custom_field_values) do
Enum.sort_by(custom_field_values, fn cfv ->
(cfv.custom_field && cfv.custom_field.name) || ""
end)
end
# Formats custom field value based on type
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
format_custom_field_value(value, type)
end
defp format_custom_field_value(nil, _type), do: ""
defp format_custom_field_value(value, :boolean) when is_boolean(value) do
if value, do: gettext("Yes"), else: gettext("No")
end
defp format_custom_field_value(%Date{} = date, :date) do
Calendar.strftime(date, "%d.%m.%Y")
end
defp format_custom_field_value(value, :email) when is_binary(value) do
assigns = %{email: value}
~H"""
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
"""
end
defp format_custom_field_value(value, :integer) when is_integer(value) do
Integer.to_string(value)
end
defp format_custom_field_value(value, _type) when is_binary(value) do
if String.trim(value) == "", do: "", else: value
end
defp format_custom_field_value(value, _type), do: to_string(value)
end