feat: adds keyboard accessibility to tabs
This commit is contained in:
parent
615b4b866b
commit
2922a4d1ee
11 changed files with 680 additions and 613 deletions
|
|
@ -2775,6 +2775,10 @@ Building accessible applications ensures that all users, including those with di
|
||||||
<div phx-click="action">Click me</div>
|
<div phx-click="action">Click me</div>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Tables (Core Component `<.table>` with `row_click`):**
|
||||||
|
|
||||||
|
- When `row_click` is set, the first column that does not use `col_click` gets `tabindex="0"` and `role="button"` so each row is reachable via Tab. The `TableRowKeydown` hook triggers the row action on Enter and Space (WCAG 2.1.1). Use `row_id` and `row_tooltip` for all clickable tables (e.g. Groups, Users, Roles, Members, Custom Fields, Member Fields) so the table is fully keyboard accessible.
|
||||||
|
|
||||||
**Tab Order:**
|
**Tab Order:**
|
||||||
|
|
||||||
- Ensure logical tab order matches visual order
|
- Ensure logical tab order matches visual order
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,27 @@ Hooks.ComboBox = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TableRowKeydown hook: WCAG 2.1.1 — when a table row cell has data-row-clickable,
|
||||||
|
// Enter and Space trigger a click so row_click tables are keyboard activatable
|
||||||
|
Hooks.TableRowKeydown = {
|
||||||
|
mounted() {
|
||||||
|
this.handleKeydown = (e) => {
|
||||||
|
if (
|
||||||
|
e.target.getAttribute("data-row-clickable") === "true" &&
|
||||||
|
(e.key === "Enter" || e.key === " ")
|
||||||
|
) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.target.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.el.addEventListener("keydown", this.handleKeydown)
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("keydown", this.handleKeydown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SidebarState hook: Manages sidebar expanded/collapsed state
|
// SidebarState hook: Manages sidebar expanded/collapsed state
|
||||||
Hooks.SidebarState = {
|
Hooks.SidebarState = {
|
||||||
mounted() {
|
mounted() {
|
||||||
|
|
|
||||||
|
|
@ -765,6 +765,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
When `row_click` is set, clicking a row (or a data cell) triggers the handler.
|
||||||
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
Rows with `row_click` get a subtle hover and focus-within outline (theme-friendly ring).
|
||||||
|
For keyboard accessibility (WCAG 2.1.1), the first column without `col_click` gets
|
||||||
|
`tabindex="0"` and `role="button"`; the TableRowKeydown hook triggers the row action on Enter/Space.
|
||||||
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
When `selected_row_id` is set and matches a row's id (via `row_value_id` or `row_item.(row).id`),
|
||||||
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
that row gets a stronger selected outline (ring-primary) for accessibility (not color-only).
|
||||||
|
|
||||||
|
|
@ -847,8 +849,22 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
assigns = assign(assigns, :row_value_id_fn, row_value_id_fn)
|
||||||
|
|
||||||
|
# WCAG 2.1.1: when row_click is set, first column without col_click gets tabindex="0"
|
||||||
|
# so rows are reachable via Tab; TableRowKeydown hook triggers click on Enter/Space
|
||||||
|
first_row_click_col_idx =
|
||||||
|
if assigns[:row_click] do
|
||||||
|
Enum.find_index(assigns[:col] || [], fn c -> !c[:col_click] end)
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assign(assigns, :first_row_click_col_idx, first_row_click_col_idx)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="overflow-auto">
|
<div
|
||||||
|
id={@row_click && "#{@id}-keyboard"}
|
||||||
|
class="overflow-auto"
|
||||||
|
phx-hook={@row_click && "TableRowKeydown"}
|
||||||
|
>
|
||||||
<table class="table table-zebra">
|
<table class="table table-zebra">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -884,6 +900,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
:for={{col, col_idx} <- Enum.with_index(@col)}
|
:for={{col, col_idx} <- Enum.with_index(@col)}
|
||||||
|
tabindex={if @row_click && @first_row_click_col_idx == col_idx, do: 0, else: nil}
|
||||||
|
role={if @row_click && @first_row_click_col_idx == col_idx, do: "button", else: nil}
|
||||||
|
data-row-clickable={
|
||||||
|
if @row_click && @first_row_click_col_idx == col_idx, do: "true", else: nil
|
||||||
|
}
|
||||||
phx-click={
|
phx-click={
|
||||||
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||||
(@row_click && @row_click.(row))
|
(@row_click && @row_click.(row))
|
||||||
|
|
@ -907,6 +928,19 @@ defmodule MvWeb.CoreComponents do
|
||||||
classes
|
classes
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# WCAG: no focus ring on the cell itself; row shows focus via focus-within
|
||||||
|
classes =
|
||||||
|
if @row_click && @first_row_click_col_idx == col_idx do
|
||||||
|
[
|
||||||
|
"focus:outline-none",
|
||||||
|
"focus-visible:outline-none",
|
||||||
|
"focus:ring-0",
|
||||||
|
"focus-visible:ring-0" | classes
|
||||||
|
]
|
||||||
|
else
|
||||||
|
classes
|
||||||
|
end
|
||||||
|
|
||||||
classes =
|
classes =
|
||||||
if col_class do
|
if col_class do
|
||||||
[col_class | classes]
|
[col_class | classes]
|
||||||
|
|
|
||||||
|
|
@ -130,149 +130,153 @@ defmodule MvWeb.GroupLive.Show do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
<p class="mb-4" data-testid="group-show-member-count">
|
<p class="mb-4" data-testid="group-show-member-count">
|
||||||
{ngettext(
|
{ngettext(
|
||||||
"Total: %{count} member",
|
"Total: %{count} member",
|
||||||
"Total: %{count} members",
|
"Total: %{count} members",
|
||||||
@group.member_count || 0,
|
@group.member_count || 0,
|
||||||
count: @group.member_count || 0
|
count: @group.member_count || 0
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<%= if can?(@current_user, :update, @group) do %>
|
<%= if can?(@current_user, :update, @group) do %>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<%= if assigns[:show_add_member_input] do %>
|
<%= if assigns[:show_add_member_input] do %>
|
||||||
<div class="join w-full">
|
<div class="join w-full">
|
||||||
<form phx-change="search_members" class="flex-1">
|
<form phx-change="search_members" class="flex-1">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
|
||||||
<%= for member <- @selected_members do %>
|
<%= for member <- @selected_members do %>
|
||||||
<.badge variant="primary" style="outline" class="flex items-center gap-1">
|
<.badge
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
variant="primary"
|
||||||
<.tooltip content={gettext("Remove")} position="top">
|
style="outline"
|
||||||
<.button
|
class="flex items-center gap-1"
|
||||||
type="button"
|
|
||||||
variant="icon"
|
|
||||||
size="sm"
|
|
||||||
phx-click="remove_selected_member"
|
|
||||||
phx-value-member_id={member.id}
|
|
||||||
aria-label={
|
|
||||||
gettext("Remove %{name}",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
class="p-0 h-4 w-4 min-h-0"
|
|
||||||
>
|
|
||||||
<.icon name="hero-x-mark" class="size-3" />
|
|
||||||
</.button>
|
|
||||||
</.tooltip>
|
|
||||||
</.badge>
|
|
||||||
<% end %>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="member-search-input"
|
|
||||||
data-testid="group-show-member-search-input"
|
|
||||||
role="combobox"
|
|
||||||
phx-hook="ComboBox"
|
|
||||||
phx-focus="show_member_dropdown"
|
|
||||||
phx-debounce="300"
|
|
||||||
phx-keydown="member_dropdown_keydown"
|
|
||||||
phx-mounted={JS.focus()}
|
|
||||||
value={@member_search_query}
|
|
||||||
placeholder={
|
|
||||||
if Enum.empty?(@selected_members),
|
|
||||||
do: gettext("Search for a member..."),
|
|
||||||
else: ""
|
|
||||||
}
|
|
||||||
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
|
|
||||||
name="member_search"
|
|
||||||
aria-label={gettext("Search for a member")}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
aria-controls="member-dropdown"
|
|
||||||
aria-expanded={to_string(@show_member_dropdown)}
|
|
||||||
aria-activedescendant={
|
|
||||||
if @focused_member_index,
|
|
||||||
do: "member-option-#{@focused_member_index}",
|
|
||||||
else: nil
|
|
||||||
}
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if length(@available_members) > 0 do %>
|
|
||||||
<div
|
|
||||||
id="member-dropdown"
|
|
||||||
role="listbox"
|
|
||||||
aria-label={gettext("Available members")}
|
|
||||||
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
|
|
||||||
phx-click-away="hide_member_dropdown"
|
|
||||||
>
|
|
||||||
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
|
||||||
<div
|
|
||||||
id={"member-option-#{index}"}
|
|
||||||
role="option"
|
|
||||||
tabindex="0"
|
|
||||||
aria-selected={to_string(@focused_member_index == index)}
|
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
data-member-id={member.id}
|
|
||||||
class={[
|
|
||||||
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
|
||||||
if(@focused_member_index == index,
|
|
||||||
do: "bg-base-300",
|
|
||||||
else: "hover:bg-base-200"
|
|
||||||
)
|
|
||||||
]}
|
|
||||||
>
|
>
|
||||||
<p class="font-medium">
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
<.tooltip content={gettext("Remove")} position="top">
|
||||||
</p>
|
<.button
|
||||||
<p class="text-sm text-base-content/70">
|
type="button"
|
||||||
{member.email || gettext("No email")}
|
variant="icon"
|
||||||
</p>
|
size="sm"
|
||||||
</div>
|
phx-click="remove_selected_member"
|
||||||
|
phx-value-member_id={member.id}
|
||||||
|
aria-label={
|
||||||
|
gettext("Remove %{name}",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
class="p-0 h-4 w-4 min-h-0"
|
||||||
|
>
|
||||||
|
<.icon name="hero-x-mark" class="size-3" />
|
||||||
|
</.button>
|
||||||
|
</.tooltip>
|
||||||
|
</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="member-search-input"
|
||||||
|
data-testid="group-show-member-search-input"
|
||||||
|
role="combobox"
|
||||||
|
phx-hook="ComboBox"
|
||||||
|
phx-focus="show_member_dropdown"
|
||||||
|
phx-debounce="300"
|
||||||
|
phx-keydown="member_dropdown_keydown"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
value={@member_search_query}
|
||||||
|
placeholder={
|
||||||
|
if Enum.empty?(@selected_members),
|
||||||
|
do: gettext("Search for a member..."),
|
||||||
|
else: ""
|
||||||
|
}
|
||||||
|
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
|
||||||
|
name="member_search"
|
||||||
|
aria-label={gettext("Search for a member")}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-controls="member-dropdown"
|
||||||
|
aria-expanded={to_string(@show_member_dropdown)}
|
||||||
|
aria-activedescendant={
|
||||||
|
if @focused_member_index,
|
||||||
|
do: "member-option-#{@focused_member_index}",
|
||||||
|
else: nil
|
||||||
|
}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<.button
|
|
||||||
type="button"
|
|
||||||
variant="primary"
|
|
||||||
phx-click="add_selected_members"
|
|
||||||
data-testid="group-show-add-selected-members-btn"
|
|
||||||
disabled={Enum.empty?(@selected_member_ids)}
|
|
||||||
aria-label={gettext("Add members")}
|
|
||||||
class="join-item"
|
|
||||||
>
|
|
||||||
<.icon name="hero-plus" class="size-5" />
|
|
||||||
</.button>
|
|
||||||
<.button
|
|
||||||
type="button"
|
|
||||||
variant="neutral"
|
|
||||||
phx-click="hide_add_member_input"
|
|
||||||
aria-label={gettext("Cancel")}
|
|
||||||
class="join-item"
|
|
||||||
>
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<.button
|
|
||||||
variant="primary"
|
|
||||||
phx-click="show_add_member_input"
|
|
||||||
aria-label={gettext("Add Member")}
|
|
||||||
>
|
|
||||||
{gettext("Add Member")}
|
|
||||||
</.button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if Enum.empty?(@group.members || []) do %>
|
<%= if length(@available_members) > 0 do %>
|
||||||
|
<div
|
||||||
|
id="member-dropdown"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={gettext("Available members")}
|
||||||
|
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto top-full #{if !@show_member_dropdown, do: "hidden"}"}
|
||||||
|
phx-click-away="hide_member_dropdown"
|
||||||
|
>
|
||||||
|
<%= for {member, index} <- Enum.with_index(@available_members) do %>
|
||||||
|
<div
|
||||||
|
id={"member-option-#{index}"}
|
||||||
|
role="option"
|
||||||
|
tabindex="0"
|
||||||
|
aria-selected={to_string(@focused_member_index == index)}
|
||||||
|
phx-click="select_member"
|
||||||
|
phx-value-id={member.id}
|
||||||
|
data-member-id={member.id}
|
||||||
|
class={[
|
||||||
|
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
|
||||||
|
if(@focused_member_index == index,
|
||||||
|
do: "bg-base-300",
|
||||||
|
else: "hover:bg-base-200"
|
||||||
|
)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p class="font-medium">
|
||||||
|
{MvWeb.Helpers.MemberHelpers.display_name(member)}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{member.email || gettext("No email")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
phx-click="add_selected_members"
|
||||||
|
data-testid="group-show-add-selected-members-btn"
|
||||||
|
disabled={Enum.empty?(@selected_member_ids)}
|
||||||
|
aria-label={gettext("Add members")}
|
||||||
|
class="join-item"
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus" class="size-5" />
|
||||||
|
</.button>
|
||||||
|
<.button
|
||||||
|
type="button"
|
||||||
|
variant="neutral"
|
||||||
|
phx-click="hide_add_member_input"
|
||||||
|
aria-label={gettext("Cancel")}
|
||||||
|
class="join-item"
|
||||||
|
>
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<.button
|
||||||
|
variant="primary"
|
||||||
|
phx-click="show_add_member_input"
|
||||||
|
aria-label={gettext("Add Member")}
|
||||||
|
>
|
||||||
|
{gettext("Add Member")}
|
||||||
|
</.button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= if Enum.empty?(@group.members || []) do %>
|
||||||
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
|
<p class="text-base-content/50 italic" data-testid="group-show-no-members">
|
||||||
{gettext("No members in this group")}
|
{gettext("No members in this group")}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
|
||||||
:if={!@show_form}
|
:if={!@show_form}
|
||||||
id="member_fields"
|
id="member_fields"
|
||||||
rows={@member_fields}
|
rows={@member_fields}
|
||||||
|
row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end}
|
||||||
row_click={
|
row_click={
|
||||||
fn {field_name, _field_data} ->
|
fn {field_name, _field_data} ->
|
||||||
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself)
|
||||||
|
|
|
||||||
|
|
@ -85,218 +85,219 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
>
|
>
|
||||||
<%!-- Personal Data and Custom Fields Row --%>
|
<%!-- Personal Data and Custom Fields Row --%>
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||||
<%!-- Personal Data Section --%>
|
<%!-- Personal Data Section --%>
|
||||||
<div>
|
<div>
|
||||||
<.form_section title={gettext("Personal Data")}>
|
<.form_section title={gettext("Personal Data")}>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<%!-- Name Row --%>
|
<%!-- Name Row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:first_name]}
|
field={@form[:first_name]}
|
||||||
label={gettext("First Name")}
|
label={gettext("First Name")}
|
||||||
required={@member_field_required_map[:first_name]}
|
required={@member_field_required_map[:first_name]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-48">
|
||||||
|
<.input
|
||||||
|
field={@form[:last_name]}
|
||||||
|
label={gettext("Last Name")}
|
||||||
|
required={@member_field_required_map[:last_name]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-48">
|
|
||||||
<.input
|
|
||||||
field={@form[:last_name]}
|
|
||||||
label={gettext("Last Name")}
|
|
||||||
required={@member_field_required_map[:last_name]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Address: Country, Postal Code, City in one row --%>
|
<%!-- Address: Country, Postal Code, City in one row --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div class="w-48">
|
<div class="w-48">
|
||||||
<.input field={@form[:country]} label={gettext("Country")} />
|
<.input field={@form[:country]} label={gettext("Country")} />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<.input
|
||||||
|
field={@form[:postal_code]}
|
||||||
|
label={gettext("Postal Code")}
|
||||||
|
required={@member_field_required_map[:postal_code]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-48">
|
||||||
|
<.input field={@form[:city]} label={gettext("City")} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
|
||||||
<.input
|
|
||||||
field={@form[:postal_code]}
|
|
||||||
label={gettext("Postal Code")}
|
|
||||||
required={@member_field_required_map[:postal_code]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="w-48">
|
|
||||||
<.input field={@form[:city]} label={gettext("City")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Street and Nr. below --%>
|
<%!-- Street and Nr. below --%>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
|
<div class="w-64">
|
||||||
|
<.input field={@form[:street]} label={gettext("Street")} />
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Email --%>
|
||||||
<div class="w-64">
|
<div class="w-64">
|
||||||
<.input field={@form[:street]} label={gettext("Street")} />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
|
||||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Email --%>
|
<%!-- Membership Dates Row --%>
|
||||||
<div class="w-64">
|
<div class="flex gap-4">
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<div class="w-36">
|
||||||
</div>
|
<.input
|
||||||
|
field={@form[:join_date]}
|
||||||
<%!-- Membership Dates Row --%>
|
label={gettext("Join Date")}
|
||||||
<div class="flex gap-4">
|
type="date"
|
||||||
<div class="w-36">
|
required={@member_field_required_map[:join_date]}
|
||||||
<.input
|
/>
|
||||||
field={@form[:join_date]}
|
</div>
|
||||||
label={gettext("Join Date")}
|
<div class="w-36">
|
||||||
type="date"
|
<.input
|
||||||
required={@member_field_required_map[:join_date]}
|
field={@form[:exit_date]}
|
||||||
/>
|
label={gettext("Exit Date")}
|
||||||
|
type="date"
|
||||||
|
required={@member_field_required_map[:exit_date]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-36">
|
|
||||||
|
<%!-- Notes --%>
|
||||||
|
<div>
|
||||||
<.input
|
<.input
|
||||||
field={@form[:exit_date]}
|
field={@form[:notes]}
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Notes")}
|
||||||
type="date"
|
type="textarea"
|
||||||
required={@member_field_required_map[:exit_date]}
|
required={@member_field_required_map[:notes]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</.form_section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Notes --%>
|
<%!-- 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)}
|
||||||
|
required={cf.required}
|
||||||
|
/>
|
||||||
|
</.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>
|
||||||
|
|
||||||
|
<%!-- Membership Fee Section --%>
|
||||||
|
<div class="max-w-xl">
|
||||||
|
<.form_section title={gettext("Membership Fee")}>
|
||||||
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<.input
|
<label class="label">
|
||||||
field={@form[:notes]}
|
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
||||||
label={gettext("Notes")}
|
</label>
|
||||||
type="textarea"
|
<select
|
||||||
required={@member_field_required_map[:notes]}
|
class="select select-bordered w-full"
|
||||||
/>
|
name={@form[:membership_fee_type_id].name}
|
||||||
|
phx-change="validate"
|
||||||
|
value={@form[:membership_fee_type_id].value || ""}
|
||||||
|
>
|
||||||
|
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
||||||
|
<option value="">{gettext("Select a membership fee type")}</option>
|
||||||
|
<%= for fee_type <- @available_fee_types do %>
|
||||||
|
<option
|
||||||
|
value={fee_type.id}
|
||||||
|
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
||||||
|
>
|
||||||
|
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
||||||
|
fee_type.interval
|
||||||
|
)})
|
||||||
|
</option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
||||||
|
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
||||||
|
<p class="text-error text-sm mt-1">{msg}</p>
|
||||||
|
<% end %>
|
||||||
|
<%= if @interval_warning do %>
|
||||||
|
<div class="alert alert-warning mt-2">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="size-5" />
|
||||||
|
<span>{@interval_warning}</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<p class="text-sm text-base-content/60 mt-2">
|
||||||
|
{gettext(
|
||||||
|
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form_section>
|
</.form_section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Custom Fields Section --%>
|
<%!-- Bottom Action Buttons --%>
|
||||||
<%= if Enum.any?(@custom_fields) do %>
|
<div class="flex justify-end gap-4 mt-6">
|
||||||
<div>
|
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
||||||
<.form_section title={gettext("Custom Fields")}>
|
{gettext("Cancel")}
|
||||||
<div class="grid grid-cols-2 gap-4">
|
</.button>
|
||||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
<%= for cf <- @sorted_custom_fields do %>
|
{gettext("Save Member")}
|
||||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
</.button>
|
||||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
</div>
|
||||||
<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)}
|
|
||||||
required={cf.required}
|
|
||||||
/>
|
|
||||||
</.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>
|
|
||||||
|
|
||||||
<%!-- Membership Fee Section --%>
|
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
||||||
<div class="max-w-xl">
|
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
||||||
<.form_section title={gettext("Membership Fee")}>
|
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
||||||
<div class="space-y-4">
|
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
||||||
<div>
|
{gettext("Danger zone")}
|
||||||
<label class="label">
|
</h2>
|
||||||
<span class="label-text font-semibold">{gettext("Membership Fee Type")}</span>
|
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||||
</label>
|
<p class="text-base-content/70 mb-4">
|
||||||
<select
|
|
||||||
class="select select-bordered w-full"
|
|
||||||
name={@form[:membership_fee_type_id].name}
|
|
||||||
phx-change="validate"
|
|
||||||
value={@form[:membership_fee_type_id].value || ""}
|
|
||||||
>
|
|
||||||
<%!-- No "None" option: a membership fee type is required (validated in Member resource). --%>
|
|
||||||
<option value="">{gettext("Select a membership fee type")}</option>
|
|
||||||
<%= for fee_type <- @available_fee_types do %>
|
|
||||||
<option
|
|
||||||
value={fee_type.id}
|
|
||||||
selected={fee_type.id == @form[:membership_fee_type_id].value}
|
|
||||||
>
|
|
||||||
{fee_type.name} ({MembershipFeeHelpers.format_currency(fee_type.amount)}, {MembershipFeeHelpers.format_interval(
|
|
||||||
fee_type.interval
|
|
||||||
)})
|
|
||||||
</option>
|
|
||||||
<% end %>
|
|
||||||
</select>
|
|
||||||
<%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %>
|
|
||||||
<% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %>
|
|
||||||
<p class="text-error text-sm mt-1">{msg}</p>
|
|
||||||
<% end %>
|
|
||||||
<%= if @interval_warning do %>
|
|
||||||
<div class="alert alert-warning mt-2">
|
|
||||||
<.icon name="hero-exclamation-triangle" class="size-5" />
|
|
||||||
<span>{@interval_warning}</span>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<p class="text-sm text-base-content/60 mt-2">
|
|
||||||
{gettext(
|
{gettext(
|
||||||
"Select a membership fee type for this member. Members can only switch between types with the same interval."
|
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<.button
|
||||||
|
variant="danger"
|
||||||
|
type="button"
|
||||||
|
phx-click="delete"
|
||||||
|
phx-value-id={@member.id}
|
||||||
|
data-confirm={
|
||||||
|
gettext(
|
||||||
|
"Are you sure you want to delete %{name}? This action cannot be undone.",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
data-testid="member-delete"
|
||||||
|
aria-label={
|
||||||
|
gettext("Delete member %{name}",
|
||||||
|
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="size-4" />
|
||||||
|
{gettext("Delete member")}
|
||||||
|
</.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</.form_section>
|
<% end %>
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Bottom Action Buttons --%>
|
|
||||||
<div class="flex justify-end gap-4 mt-6">
|
|
||||||
<.button navigate={return_path(@return_to, @member)} variant="neutral" type="button">
|
|
||||||
{gettext("Cancel")}
|
|
||||||
</.button>
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
|
||||||
{gettext("Save Member")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%>
|
|
||||||
<%= if @member && can?(@current_user, :destroy, @member) do %>
|
|
||||||
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
|
|
||||||
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
|
|
||||||
{gettext("Danger zone")}
|
|
||||||
</h2>
|
|
||||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
|
||||||
<p class="text-base-content/70 mb-4">
|
|
||||||
{gettext(
|
|
||||||
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<.button
|
|
||||||
variant="danger"
|
|
||||||
type="button"
|
|
||||||
phx-click="delete"
|
|
||||||
phx-value-id={@member.id}
|
|
||||||
data-confirm={
|
|
||||||
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
data-testid="member-delete"
|
|
||||||
aria-label={
|
|
||||||
gettext("Delete member %{name}",
|
|
||||||
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.icon name="hero-trash" class="size-4" />
|
|
||||||
{gettext("Delete member")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
|
||||||
|
|
@ -134,286 +134,286 @@
|
||||||
aria-label={gettext("Select member")}
|
aria-label={gettext("Select member")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:first_name in @member_fields_visible}
|
:if={:first_name in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_first_name}
|
id={:sort_first_name}
|
||||||
field={:first_name}
|
field={:first_name}
|
||||||
label={gettext("First name")}
|
label={gettext("First name")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.first_name}
|
{member.first_name}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:last_name in @member_fields_visible}
|
:if={:last_name in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_last_name}
|
id={:sort_last_name}
|
||||||
field={:last_name}
|
field={:last_name}
|
||||||
label={gettext("Last name")}
|
label={gettext("Last name")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.last_name}
|
{member.last_name}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:email in @member_fields_visible}
|
:if={:email in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_email}
|
id={:sort_email}
|
||||||
field={:email}
|
field={:email}
|
||||||
label={gettext("Email")}
|
label={gettext("Email")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.email}
|
{member.email}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:join_date in @member_fields_visible}
|
:if={:join_date in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_join_date}
|
id={:sort_join_date}
|
||||||
field={:join_date}
|
field={:join_date}
|
||||||
label={gettext("Join Date")}
|
label={gettext("Join Date")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:exit_date in @member_fields_visible}
|
:if={:exit_date in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_exit_date}
|
id={:sort_exit_date}
|
||||||
field={:exit_date}
|
field={:exit_date}
|
||||||
label={gettext("Exit Date")}
|
label={gettext("Exit Date")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
|
{MvWeb.MemberLive.Index.format_date(member.exit_date)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:notes in @member_fields_visible}
|
:if={:notes in @member_fields_visible}
|
||||||
label={gettext("Notes")}
|
label={gettext("Notes")}
|
||||||
>
|
>
|
||||||
{member.notes}
|
{member.notes}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:country in @member_fields_visible}
|
:if={:country in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_country}
|
id={:sort_country}
|
||||||
field={:country}
|
field={:country}
|
||||||
label={gettext("Country")}
|
label={gettext("Country")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.country}
|
{member.country}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:city in @member_fields_visible}
|
:if={:city in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_city}
|
id={:sort_city}
|
||||||
field={:city}
|
field={:city}
|
||||||
label={gettext("City")}
|
label={gettext("City")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.city}
|
{member.city}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:street in @member_fields_visible}
|
:if={:street in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_street}
|
id={:sort_street}
|
||||||
field={:street}
|
field={:street}
|
||||||
label={gettext("Street")}
|
label={gettext("Street")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.street}
|
{member.street}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:house_number in @member_fields_visible}
|
:if={:house_number in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_house_number}
|
id={:sort_house_number}
|
||||||
field={:house_number}
|
field={:house_number}
|
||||||
label={gettext("House Number")}
|
label={gettext("House Number")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.house_number}
|
{member.house_number}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:postal_code in @member_fields_visible}
|
:if={:postal_code in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_postal_code}
|
id={:sort_postal_code}
|
||||||
field={:postal_code}
|
field={:postal_code}
|
||||||
label={gettext("Postal Code")}
|
label={gettext("Postal Code")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.postal_code}
|
{member.postal_code}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_start_date in @member_fields_visible}
|
:if={:membership_fee_start_date in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_membership_fee_start_date}
|
id={:sort_membership_fee_start_date}
|
||||||
field={:membership_fee_start_date}
|
field={:membership_fee_start_date}
|
||||||
label={gettext("Membership Fee Start Date")}
|
label={gettext("Membership Fee Start Date")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
|
{MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_type in @member_fields_visible}
|
:if={:membership_fee_type in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_membership_fee_type}
|
id={:sort_membership_fee_type}
|
||||||
field={:membership_fee_type}
|
field={:membership_fee_type}
|
||||||
label={gettext("Fee Type")}
|
label={gettext("Fee Type")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<%= if member.membership_fee_type do %>
|
<%= if member.membership_fee_type do %>
|
||||||
{member.membership_fee_type.name}
|
{member.membership_fee_type.name}
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-base-content/50">—</span>
|
<span class="text-base-content/50">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_status in @member_fields_visible}
|
:if={:membership_fee_status in @member_fields_visible}
|
||||||
label={gettext("Membership Fee Status")}
|
label={gettext("Membership Fee Status")}
|
||||||
>
|
>
|
||||||
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
|
<%= if badge = MembershipFeeStatus.format_cycle_status_badge(
|
||||||
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle)
|
||||||
) do %>
|
) do %>
|
||||||
<.badge variant={badge.variant}>
|
<.badge variant={badge.variant}>
|
||||||
<.icon name={badge.icon} class="size-4" />
|
<.icon name={badge.icon} class="size-4" />
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</.badge>
|
</.badge>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.badge variant="neutral">{gettext("No cycle")}</.badge>
|
<.badge variant="neutral">{gettext("No cycle")}</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:groups in @member_fields_visible}
|
:if={:groups in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
id={:sort_groups}
|
id={:sort_groups}
|
||||||
field={:groups}
|
field={:groups}
|
||||||
label={gettext("Groups")}
|
label={gettext("Groups")}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
/>
|
/>
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<%= for group <- (member.groups || []) do %>
|
<%= for group <- (member.groups || []) do %>
|
||||||
<.badge
|
<.badge
|
||||||
variant="primary"
|
variant="primary"
|
||||||
style="outline"
|
style="outline"
|
||||||
aria-label={gettext("Member of group %{name}", name: group.name)}
|
aria-label={gettext("Member of group %{name}", name: group.name)}
|
||||||
>
|
>
|
||||||
{group.name}
|
{group.name}
|
||||||
</.badge>
|
</.badge>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if (member.groups || []) == [] do %>
|
<%= if (member.groups || []) == [] do %>
|
||||||
<span class="text-base-content/50">—</span>
|
<span class="text-base-content/50">—</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:col>
|
</:col>
|
||||||
<:action :let={member}>
|
<:action :let={member}>
|
||||||
<div class="sr-only">
|
<div class="sr-only">
|
||||||
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
|
<.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
|
||||||
{gettext("Show")}
|
{gettext("Show")}
|
||||||
</.link>
|
</.link>
|
||||||
</div>
|
</div>
|
||||||
</:action>
|
</:action>
|
||||||
</.table>
|
</.table>
|
||||||
</div>
|
</div>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
|
|
|
||||||
|
|
@ -254,7 +254,9 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
/>
|
/>
|
||||||
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
<.data_field label={gettext("Last Cycle")} class="min-w-32">
|
||||||
<%= if @member.last_cycle_status do %>
|
<%= if @member.last_cycle_status do %>
|
||||||
<.badge variant={MembershipFeeHelpers.status_variant(@member.last_cycle_status)}>
|
<.badge variant={
|
||||||
|
MembershipFeeHelpers.status_variant(@member.last_cycle_status)
|
||||||
|
}>
|
||||||
{format_status_label(@member.last_cycle_status)}
|
{format_status_label(@member.last_cycle_status)}
|
||||||
</.badge>
|
</.badge>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
import MvWeb.RoleLive.Helpers,
|
import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1]
|
||||||
only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3]
|
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
<.table
|
<.table
|
||||||
id="roles"
|
id="roles"
|
||||||
rows={@roles}
|
rows={@roles}
|
||||||
|
row_id={fn role -> "role-#{role.id}" end}
|
||||||
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end}
|
||||||
row_tooltip={gettext("Click for role details")}
|
row_tooltip={gettext("Click for role details")}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue