refactor: use core components

This commit is contained in:
carla 2026-02-25 09:12:33 +01:00
parent f0be98316c
commit b7c93f19cb
26 changed files with 1080 additions and 954 deletions

View file

@ -78,30 +78,29 @@ defmodule MvWeb.GroupLive.Form do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<.form for={@form} id="group-form" phx-change="validate" phx-submit="save">
<%!-- Header with Back button, Title, and Save button --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={return_path(@return_to, @group)} type="button">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<.header>
{@page_title}
<:actions>
<.button navigate={return_path(@return_to, @group)} variant="neutral">
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</:actions>
</.header>
<h1 class="text-2xl font-bold text-center flex-1">
{@page_title}
</h1>
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
{gettext("Save")}
</.button>
</div>
<div class="max-w-2xl space-y-4">
<.input field={@form[:name]} label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="4"
/>
<div class="mt-6 space-y-6">
<div class="max-w-2xl space-y-4">
<.input field={@form[:name]} label={gettext("Name")} required />
<.input
field={@form[:description]}
type="textarea"
label={gettext("Description")}
rows="4"
/>
</div>
</div>
</.form>
</Layouts.app>

View file

@ -39,72 +39,64 @@ defmodule MvWeb.GroupLive.Index do
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">{gettext("Groups")}</h1>
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.button navigate={~p"/groups/new"} variant="primary">
<.icon name="hero-plus" class="size-4 mr-2" />
{gettext("Create Group")}
</.button>
<.header>
{gettext("Groups")}
<:actions>
<%= if can?(@current_user, :create, Mv.Membership.Group) do %>
<.button navigate={~p"/groups/new"} variant="primary">
<.icon name="hero-plus" class="size-4 mr-2" />
{gettext("Create Group")}
</.button>
<% end %>
</:actions>
</.header>
<div class="mt-6 space-y-6">
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/60 italic">{gettext("No groups")}</p>
</div>
<% else %>
<.table
id="groups-table"
rows={@groups}
row_id={fn group -> "group-#{group.id}" end}
row_click={fn group -> JS.navigate(~p"/groups/#{group.slug}") end}
>
<:col :let={group} label={gettext("Name")}>
{group.name}
</:col>
<:col :let={group} label={gettext("Description")}>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</:col>
<:col :let={group} label={gettext("Members")} class="text-right">
{group.member_count || 0}
</:col>
<:action :let={group}>
<.button
variant="ghost"
size="sm"
navigate={~p"/groups/#{group.slug}"}
>
{gettext("View")}
</.button>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.button
variant="ghost"
size="sm"
navigate={~p"/groups/#{group.slug}/edit"}
>
{gettext("Edit group")}
</.button>
<% end %>
</:action>
</.table>
<% end %>
</div>
<%= if Enum.empty?(@groups) do %>
<div class="text-center py-12">
<p class="text-base-content/70">{gettext("No groups")}</p>
</div>
<% else %>
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Description")}</th>
<th>{gettext("Members")}</th>
<th>{gettext("Actions")}</th>
</tr>
</thead>
<tbody>
<%= for group <- @groups do %>
<tr>
<td>
{group.name}
</td>
<td>
<%= if group.description do %>
{group.description}
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<td>
<%= if group.member_count do %>
{group.member_count}
<% else %>
0
<% end %>
</td>
<td>
<div class="flex gap-2">
<.link navigate={~p"/groups/#{group.slug}"} class="btn btn-sm btn-ghost">
{gettext("View")}
</.link>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.link
navigate={~p"/groups/#{group.slug}/edit"}
class="btn btn-sm btn-ghost"
>
{gettext("Edit")}
</.link>
<% end %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.app>
"""
end

View file

@ -85,318 +85,327 @@ defmodule MvWeb.GroupLive.Show do
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<%!-- Header with Back button, Name, and Edit/Delete buttons --%>
<div class="flex items-center justify-between gap-4 pb-4">
<.button navigate={~p"/groups"} aria-label={gettext("Back to groups list")}>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<h1 class="text-2xl font-bold text-center flex-1">
{@group.name}
</h1>
<div class="flex gap-2">
<.header>
{@group.name}
<:actions>
<.button
navigate={~p"/groups"}
variant="neutral"
aria-label={gettext("Back to groups list")}
>
<.icon name="hero-arrow-left" class="size-4" />
{gettext("Back")}
</.button>
<%= if can?(@current_user, :update, @group) do %>
<.button
variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn"
>
{gettext("Edit")}
{gettext("Edit group")}
</.button>
<% end %>
<%= if can?(@current_user, :destroy, @group) do %>
<.button
class="btn-error"
variant="danger"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")}
</.button>
<% end %>
</div>
</div>
</:actions>
</.header>
<%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6">
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<%= if @group.description && String.trim(@group.description) != "" do %>
<p class="whitespace-pre-wrap">{@group.description}</p>
<% else %>
<p class="text-base-content/50 italic">{gettext("No description")}</p>
<% end %>
<div class="mt-6 space-y-6">
<%!-- Group Information --%>
<div class="max-w-2xl space-y-6 mb-6">
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Description")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<%= if @group.description && String.trim(@group.description) != "" do %>
<p class="whitespace-pre-wrap">{@group.description}</p>
<% else %>
<p class="text-base-content/50 italic">{gettext("No description")}</p>
<% end %>
</div>
</div>
</div>
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4" data-testid="group-show-member-count">
{ngettext(
"Total: %{count} member",
"Total: %{count} members",
@group.member_count || 0,
count: @group.member_count || 0
)}
</p>
<%= if can?(@current_user, :update, @group) do %>
<div class="mb-4">
<%= if assigns[:show_add_member_input] do %>
<div class="join w-full">
<form phx-change="search_members" class="flex-1">
<div class="relative">
<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 %>
<span class="badge badge-outline badge flex items-center gap-1">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<button
type="button"
class="btn btn-ghost btn-xs p-0 h-4 w-4 min-h-0"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
>
<.icon name="hero-x-mark" class="size-3" />
</button>
</span>
<% 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)}
</p>
<p class="text-sm text-base-content/70">
{member.email || gettext("No email")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</form>
<button
type="button"
class="btn btn-primary join-item"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
>
<.icon name="hero-plus" class="size-5" />
</button>
<button
type="button"
class="btn join-item"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
>
{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">
{gettext("No members in this group")}
<div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4" data-testid="group-show-member-count">
{ngettext(
"Total: %{count} member",
"Total: %{count} members",
@group.member_count || 0,
count: @group.member_count || 0
)}
</p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %>
<th class="w-0">{gettext("Actions")}</th>
<% end %>
</tr>
</thead>
<tbody>
<%= for member <- @group.members do %>
<tr>
<td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<%= if member.email do %>
<a
href={"mailto:#{member.email}"}
class="text-blue-700 hover:text-blue-800 underline"
<%= if can?(@current_user, :update, @group) do %>
<div class="mb-4">
<%= if assigns[:show_add_member_input] do %>
<div class="join w-full">
<form phx-change="search_members" class="flex-1">
<div class="relative">
<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 %>
<span class="badge badge-outline badge 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" />
</.button>
</.tooltip>
</span>
<% 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"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<%= 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 %>
</td>
</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">
{gettext("No members in this group")}
</p>
<% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table">
<table class="table table-zebra">
<thead>
<tr>
<th>{gettext("Name")}</th>
<th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %>
<td>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</td>
<th class="w-0">{gettext("Actions")}</th>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</thead>
<tbody>
<%= for member <- @group.members do %>
<tr>
<td>
<.link
navigate={~p"/members/#{member.id}"}
class="link link-primary"
>
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</.link>
</td>
<td>
<%= if member.email do %>
<a
href={"mailto:#{member.email}"}
class="link link-primary"
>
{member.email}
</a>
<% else %>
<span class="text-base-content/50 italic"></span>
<% end %>
</td>
<%= if can?(@current_user, :update, @group) do %>
<td>
<.tooltip content={gettext("Remove")} position="left">
<.button
type="button"
variant="danger"
size="sm"
phx-click="remove_member"
phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</.tooltip>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
</div>
</div>
</div>
<%!-- Delete Confirmation Modal --%>
<%= if assigns[:show_delete_modal] do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
<p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p>
<%= if @group.member_count && @group.member_count > 0 do %>
<div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>
{ngettext(
"This group has %{count} member. All member-group associations will be permanently deleted.",
"This group has %{count} members. All member-group associations will be permanently deleted.",
@group.member_count,
count: @group.member_count
)}
</span>
<%!-- Delete Confirmation Modal --%>
<%= if assigns[:show_delete_modal] do %>
<dialog id="delete-group-modal" class="modal modal-open" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold mb-4">{gettext("Delete Group")}</h3>
<p class="mb-4">
{gettext("Are you sure you want to delete this group? This action cannot be undone.")}
</p>
<%= if @group.member_count && @group.member_count > 0 do %>
<div class="alert alert-warning mb-4">
<.icon name="hero-exclamation-triangle" class="size-5" />
<span>
{ngettext(
"This group has %{count} member. All member-group associations will be permanently deleted.",
"This group has %{count} members. All member-group associations will be permanently deleted.",
@group.member_count,
count: @group.member_count
)}
</span>
</div>
<% end %>
<div>
<label for="group-name-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter the group name:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@group.name}
</div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div>
<% end %>
<div>
<label for="group-name-confirmation" class="label">
<span class="label-text">
{gettext("To confirm deletion, please enter the group name:")}
</span>
</label>
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
{@group.name}
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_delete"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</.button>
<.button
type="button"
variant="danger"
phx-click="confirm_delete"
phx-value-slug={@group.slug}
disabled={(@name_confirmation || "") != @group.name}
aria-label={gettext("Delete group")}
>
{gettext("Delete")}
</.button>
</div>
<form phx-change="update_name_confirmation">
<input
id="group-name-confirmation"
name="name"
type="text"
value={@name_confirmation || ""}
placeholder={gettext("Enter the group name to confirm")}
autocomplete="off"
phx-debounce="200"
class="w-full input input-bordered"
/>
</form>
</div>
<div class="modal-action">
<button
type="button"
class="btn"
phx-click="cancel_delete"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</button>
<button
type="button"
class="btn btn-error"
phx-click="confirm_delete"
phx-value-slug={@group.slug}
disabled={(@name_confirmation || "") != @group.name}
aria-label={gettext("Delete group")}
>
{gettext("Delete")}
</button>
</div>
</div>
</dialog>
<% end %>
</dialog>
<% end %>
</div>
</Layouts.app>
"""
end