diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 2f2516b..d68d0b5 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -2775,6 +2775,10 @@ Building accessible applications ensures that all users, including those with di
Click me
``` +**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:** - Ensure logical tab order matches visual order diff --git a/assets/js/app.js b/assets/js/app.js index de3f154..c17e7b5 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 Hooks.SidebarState = { mounted() { diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 24fe879..1e90fd1 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -765,6 +765,8 @@ defmodule MvWeb.CoreComponents do 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). + 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`), 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) + # 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""" -
+
@@ -884,6 +900,11 @@ defmodule MvWeb.CoreComponents do >
- +
0} class="mb-4">
@@ -249,7 +249,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
- +
0} class="mb-2">
@@ -316,7 +316,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
- +
<.button diff --git a/lib/mv_web/live/group_live/show.ex b/lib/mv_web/live/group_live/show.ex index 7e802b8..4ecc6f3 100644 --- a/lib/mv_web/live/group_live/show.ex +++ b/lib/mv_web/live/group_live/show.ex @@ -130,149 +130,153 @@ defmodule MvWeb.GroupLive.Show do
-
-

{gettext("Members")}

-
-

- {ngettext( - "Total: %{count} member", - "Total: %{count} members", - @group.member_count || 0, - count: @group.member_count || 0 - )} -

+
+

{gettext("Members")}

+
+

+ {ngettext( + "Total: %{count} member", + "Total: %{count} members", + @group.member_count || 0, + count: @group.member_count || 0 + )} +

- <%= if can?(@current_user, :update, @group) do %> -
- <%= if assigns[:show_add_member_input] do %> -
-
-
-
+ <%= if can?(@current_user, :update, @group) do %> +
+ <%= if assigns[:show_add_member_input] do %> +
+ +
+
<%= for member <- @selected_members do %> - <.badge variant="primary" style="outline" class="flex items-center gap-1"> - {MvWeb.Helpers.MemberHelpers.display_name(member)} - <.tooltip content={gettext("Remove")} position="top"> - <.button - 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" /> - - - - <% end %> - -
- - <%= if length(@available_members) > 0 do %> -
- <%= for {member, index} <- Enum.with_index(@available_members) do %> -
-

- {MvWeb.Helpers.MemberHelpers.display_name(member)} -

-

- {member.email || gettext("No email")} -

-
+ {MvWeb.Helpers.MemberHelpers.display_name(member)} + <.tooltip content={gettext("Remove")} position="top"> + <.button + 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" /> + + + <% end %> +
- <% end %> -
- - <.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 - type="button" - variant="neutral" - phx-click="hide_add_member_input" - aria-label={gettext("Cancel")} - class="join-item" - > - {gettext("Cancel")} - -
- <% else %> - <.button - variant="primary" - phx-click="show_add_member_input" - aria-label={gettext("Add Member")} - > - {gettext("Add Member")} - - <% end %> -
- <% end %> - <%= if Enum.empty?(@group.members || []) do %> + <%= if length(@available_members) > 0 do %> +
+ <%= for {member, index} <- Enum.with_index(@available_members) do %> +
+

+ {MvWeb.Helpers.MemberHelpers.display_name(member)} +

+

+ {member.email || gettext("No email")} +

+
+ <% end %> +
+ <% end %> +
+ + <.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 + type="button" + variant="neutral" + phx-click="hide_add_member_input" + aria-label={gettext("Cancel")} + class="join-item" + > + {gettext("Cancel")} + +
+ <% else %> + <.button + variant="primary" + phx-click="show_add_member_input" + aria-label={gettext("Add Member")} + > + {gettext("Add Member")} + + <% end %> +
+ <% end %> + + <%= if Enum.empty?(@group.members || []) do %>

{gettext("No members in this group")}

diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index d6f87b1..d8b2616 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -52,6 +52,7 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do :if={!@show_form} id="member_fields" rows={@member_fields} + row_id={fn {field_name, _field_data} -> "member_field-#{field_name}" end} row_click={ fn {field_name, _field_data} -> JS.push("edit_member_field", value: %{"field" => field_name}, target: @myself) diff --git a/lib/mv_web/live/member_live/form.ex b/lib/mv_web/live/member_live/form.ex index 45da418..e4a627b 100644 --- a/lib/mv_web/live/member_live/form.ex +++ b/lib/mv_web/live/member_live/form.ex @@ -85,218 +85,219 @@ defmodule MvWeb.MemberLive.Form do > <%!-- Personal Data and Custom Fields Row --%>
- <%!-- Personal Data Section --%> -
- <.form_section title={gettext("Personal Data")}> -
- <%!-- Name Row --%> -
-
- <.input - field={@form[:first_name]} - label={gettext("First Name")} - required={@member_field_required_map[:first_name]} - /> + <%!-- Personal Data Section --%> +
+ <.form_section title={gettext("Personal Data")}> +
+ <%!-- Name Row --%> +
+
+ <.input + field={@form[:first_name]} + label={gettext("First Name")} + required={@member_field_required_map[:first_name]} + /> +
+
+ <.input + field={@form[:last_name]} + label={gettext("Last Name")} + required={@member_field_required_map[:last_name]} + /> +
-
- <.input - field={@form[:last_name]} - label={gettext("Last Name")} - required={@member_field_required_map[:last_name]} - /> -
-
- <%!-- Address: Country, Postal Code, City in one row --%> -
-
- <.input field={@form[:country]} label={gettext("Country")} /> + <%!-- Address: Country, Postal Code, City in one row --%> +
+
+ <.input field={@form[:country]} label={gettext("Country")} /> +
+
+ <.input + field={@form[:postal_code]} + label={gettext("Postal Code")} + required={@member_field_required_map[:postal_code]} + /> +
+
+ <.input field={@form[:city]} label={gettext("City")} /> +
-
- <.input - field={@form[:postal_code]} - label={gettext("Postal Code")} - required={@member_field_required_map[:postal_code]} - /> -
-
- <.input field={@form[:city]} label={gettext("City")} /> -
-
- <%!-- Street and Nr. below --%> -
+ <%!-- Street and Nr. below --%> +
+
+ <.input field={@form[:street]} label={gettext("Street")} /> +
+
+ <.input field={@form[:house_number]} label={gettext("Nr.")} /> +
+
+ + <%!-- Email --%>
- <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" />
-
- <.input field={@form[:house_number]} label={gettext("Nr.")} /> -
-
- <%!-- Email --%> -
- <.input field={@form[:email]} label={gettext("Email")} required type="email" /> -
- - <%!-- Membership Dates Row --%> -
-
- <.input - field={@form[:join_date]} - label={gettext("Join Date")} - type="date" - required={@member_field_required_map[:join_date]} - /> + <%!-- Membership Dates Row --%> +
+
+ <.input + field={@form[:join_date]} + label={gettext("Join Date")} + type="date" + required={@member_field_required_map[:join_date]} + /> +
+
+ <.input + field={@form[:exit_date]} + label={gettext("Exit Date")} + type="date" + required={@member_field_required_map[:exit_date]} + /> +
-
+ + <%!-- Notes --%> +
<.input - field={@form[:exit_date]} - label={gettext("Exit Date")} - type="date" - required={@member_field_required_map[:exit_date]} + field={@form[:notes]} + label={gettext("Notes")} + type="textarea" + required={@member_field_required_map[:notes]} />
+ +
- <%!-- Notes --%> + <%!-- Custom Fields Section --%> + <%= if Enum.any?(@custom_fields) do %> +
+ <.form_section title={gettext("Custom Fields")}> +
+ <%!-- 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 %> +
+ <.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} + /> + + +
+ <% end %> + + <% end %> +
+ +
+ <% end %> +
+ + <%!-- Membership Fee Section --%> +
+ <.form_section title={gettext("Membership Fee")}> +
- <.input - field={@form[:notes]} - label={gettext("Notes")} - type="textarea" - required={@member_field_required_map[:notes]} - /> + + + <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> + <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> +

{msg}

+ <% end %> + <%= if @interval_warning do %> +
+ <.icon name="hero-exclamation-triangle" class="size-5" /> + {@interval_warning} +
+ <% end %> +

+ {gettext( + "Select a membership fee type for this member. Members can only switch between types with the same interval." + )} +

- <%!-- Custom Fields Section --%> - <%= if Enum.any?(@custom_fields) do %> -
- <.form_section title={gettext("Custom Fields")}> -
- <%!-- 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 %> -
- <.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} - /> - - -
- <% end %> - - <% end %> -
- -
- <% end %> -
+ <%!-- Bottom Action Buttons --%> +
+ <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> + {gettext("Cancel")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> + {gettext("Save Member")} + +
- <%!-- Membership Fee Section --%> -
- <.form_section title={gettext("Membership Fee")}> -
-
- - - <%= for error <- List.wrap(@form.errors[:membership_fee_type_id] || []) do %> - <% {msg, _opts} = if is_tuple(error), do: error, else: {error, []} %> -

{msg}

- <% end %> - <%= if @interval_warning do %> -
- <.icon name="hero-exclamation-triangle" class="size-5" /> - {@interval_warning} -
- <% end %> -

+ <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> + <%= if @member && can?(@current_user, :destroy, @member) do %> +

+

+ {gettext("Danger zone")} +

+
+

{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." )}

+ <.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")} +
-
- -
- - <%!-- Bottom Action Buttons --%> -
- <.button navigate={return_path(@return_to, @member)} variant="neutral" type="button"> - {gettext("Cancel")} - - <.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit"> - {gettext("Save Member")} - -
- - <%!-- Danger zone: same section pattern as MemberLive.Show (canonical) --%> - <%= if @member && can?(@current_user, :destroy, @member) do %> -
-

- {gettext("Danger zone")} -

-
-

- {gettext( - "Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed." - )} -

- <.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")} - -
-
- <% end %> + + <% end %>
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 412a5c4..c49e343 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -109,7 +109,7 @@ sort_field={@sort_field} sort_order={@sort_order} > - + <:col :let={member} @@ -134,286 +134,286 @@ aria-label={gettext("Select member")} role="checkbox" /> - - <:col - :let={member} - :if={:first_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_first_name} - field={:first_name} - label={gettext("First name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.first_name} - - <:col - :let={member} - :if={:last_name in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_last_name} - field={:last_name} - label={gettext("Last name")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.last_name} - - <:col - :let={member} - :if={:email in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_email} - field={:email} - label={gettext("Email")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.email} - - <:col - :let={member} - :if={:join_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_join_date} - field={:join_date} - label={gettext("Join Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.join_date)} - - <:col - :let={member} - :if={:exit_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_exit_date} - field={:exit_date} - label={gettext("Exit Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.exit_date)} - - <:col - :let={member} - :if={:notes in @member_fields_visible} - label={gettext("Notes")} - > - {member.notes} - - <:col - :let={member} - :if={:country in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_country} - field={:country} - label={gettext("Country")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.country} - - <:col - :let={member} - :if={:city in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_city} - field={:city} - label={gettext("City")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.city} - - <:col - :let={member} - :if={:street in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_street} - field={:street} - label={gettext("Street")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.street} - - <:col - :let={member} - :if={:house_number in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_house_number} - field={:house_number} - label={gettext("House Number")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.house_number} - - <:col - :let={member} - :if={:postal_code in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_postal_code} - field={:postal_code} - label={gettext("Postal Code")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {member.postal_code} - - <:col - :let={member} - :if={:membership_fee_start_date in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_start_date} - field={:membership_fee_start_date} - label={gettext("Membership Fee Start Date")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} - - <:col - :let={member} - :if={:membership_fee_type in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_membership_fee_type} - field={:membership_fee_type} - label={gettext("Fee Type")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= if member.membership_fee_type do %> - {member.membership_fee_type.name} - <% else %> - - <% end %> - - <:col - :let={member} - :if={:membership_fee_status in @member_fields_visible} - label={gettext("Membership Fee Status")} - > - <%= if badge = MembershipFeeStatus.format_cycle_status_badge( + + <:col + :let={member} + :if={:first_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_first_name} + field={:first_name} + label={gettext("First name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.first_name} + + <:col + :let={member} + :if={:last_name in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_last_name} + field={:last_name} + label={gettext("Last name")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.last_name} + + <:col + :let={member} + :if={:email in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_email} + field={:email} + label={gettext("Email")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.email} + + <:col + :let={member} + :if={:join_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_join_date} + field={:join_date} + label={gettext("Join Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.join_date)} + + <:col + :let={member} + :if={:exit_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_exit_date} + field={:exit_date} + label={gettext("Exit Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.exit_date)} + + <:col + :let={member} + :if={:notes in @member_fields_visible} + label={gettext("Notes")} + > + {member.notes} + + <:col + :let={member} + :if={:country in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_country} + field={:country} + label={gettext("Country")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.country} + + <:col + :let={member} + :if={:city in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_city} + field={:city} + label={gettext("City")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.city} + + <:col + :let={member} + :if={:street in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_street} + field={:street} + label={gettext("Street")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.street} + + <:col + :let={member} + :if={:house_number in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_house_number} + field={:house_number} + label={gettext("House Number")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.house_number} + + <:col + :let={member} + :if={:postal_code in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_postal_code} + field={:postal_code} + label={gettext("Postal Code")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {member.postal_code} + + <:col + :let={member} + :if={:membership_fee_start_date in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_start_date} + field={:membership_fee_start_date} + label={gettext("Membership Fee Start Date")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + {MvWeb.MemberLive.Index.format_date(member.membership_fee_start_date)} + + <:col + :let={member} + :if={:membership_fee_type in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_membership_fee_type} + field={:membership_fee_type} + label={gettext("Fee Type")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= if member.membership_fee_type do %> + {member.membership_fee_type.name} + <% else %> + + <% end %> + + <:col + :let={member} + :if={:membership_fee_status in @member_fields_visible} + label={gettext("Membership Fee Status")} + > + <%= if badge = MembershipFeeStatus.format_cycle_status_badge( MembershipFeeStatus.get_cycle_status_for_member(member, @show_current_cycle) ) do %> - <.badge variant={badge.variant}> - <.icon name={badge.icon} class="size-4" /> - {badge.label} - - <% else %> - <.badge variant="neutral">{gettext("No cycle")} - <% end %> - - <:col - :let={member} - :if={:groups in @member_fields_visible} - label={ - ~H""" - <.live_component - module={MvWeb.Components.SortHeaderComponent} - id={:sort_groups} - field={:groups} - label={gettext("Groups")} - sort_field={@sort_field} - sort_order={@sort_order} - /> - """ - } - > - <%= for group <- (member.groups || []) do %> - <.badge - variant="primary" - style="outline" - aria-label={gettext("Member of group %{name}", name: group.name)} - > - {group.name} - - <% end %> - <%= if (member.groups || []) == [] do %> - - <% end %> - - <:action :let={member}> -
- <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> - {gettext("Show")} - -
- - + <.badge variant={badge.variant}> + <.icon name={badge.icon} class="size-4" /> + {badge.label} + + <% else %> + <.badge variant="neutral">{gettext("No cycle")} + <% end %> + + <:col + :let={member} + :if={:groups in @member_fields_visible} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_groups} + field={:groups} + label={gettext("Groups")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= for group <- (member.groups || []) do %> + <.badge + variant="primary" + style="outline" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + + <% end %> + + <:action :let={member}> +
+ <.link navigate={~p"/members/#{member}"} data-testid="member-show-link"> + {gettext("Show")} + +
+ +
diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index c63ced5..a957b61 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -254,7 +254,9 @@ defmodule MvWeb.MemberLive.Show do /> <.data_field label={gettext("Last Cycle")} class="min-w-32"> <%= 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)} <% else %> diff --git a/lib/mv_web/live/role_live/index.ex b/lib/mv_web/live/role_live/index.ex index 0bdc226..58f98d4 100644 --- a/lib/mv_web/live/role_live/index.ex +++ b/lib/mv_web/live/role_live/index.ex @@ -18,8 +18,7 @@ defmodule MvWeb.RoleLive.Index do require Ash.Query - import MvWeb.RoleLive.Helpers, - only: [format_error: 1, permission_set_badge_variant: 1, opts_with_actor: 3] + import MvWeb.RoleLive.Helpers, only: [permission_set_badge_variant: 1] @impl true def mount(_params, _session, socket) do diff --git a/lib/mv_web/live/role_live/index.html.heex b/lib/mv_web/live/role_live/index.html.heex index 1dc41c8..94d1fc6 100644 --- a/lib/mv_web/live/role_live/index.html.heex +++ b/lib/mv_web/live/role_live/index.html.heex @@ -16,6 +16,7 @@ <.table id="roles" rows={@roles} + row_id={fn role -> "role-#{role.id}" end} row_click={fn role -> JS.navigate(~p"/admin/roles/#{role}") end} row_tooltip={gettext("Click for role details")} >