Redesign member view/edit UI with improved accessibility
All checks were successful
continuous-integration/drone/push Build is passing

- 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 0cafdbafcd
commit d24096ceaf
Signed by: moritz
GPG key ID: 1020A035E5DD0824
5 changed files with 906 additions and 314 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