docs: update group concept
This commit is contained in:
parent
af62dbac03
commit
1d1f3b16b1
1 changed files with 513 additions and 41 deletions
|
|
@ -354,6 +354,106 @@ end
|
|||
- Edit group button
|
||||
- Delete group button (with confirmation)
|
||||
|
||||
### Accessibility (A11y) Considerations
|
||||
|
||||
**Requirements:**
|
||||
- All UI elements must be keyboard accessible
|
||||
- Screen readers must be able to navigate and understand the interface
|
||||
- ARIA labels and roles must be properly set
|
||||
|
||||
**Group Badges in Member Overview:**
|
||||
|
||||
```heex
|
||||
<span
|
||||
class="badge badge-primary"
|
||||
role="status"
|
||||
aria-label={"Member of group: #{group.name}"}
|
||||
title={"Member of group: #{group.name}"}
|
||||
>
|
||||
<%= group.name %>
|
||||
</span>
|
||||
```
|
||||
|
||||
**Clickable Group Badge (for filtering):**
|
||||
|
||||
```heex
|
||||
<button
|
||||
phx-click="filter_by_group"
|
||||
phx-value-group-id={group.id}
|
||||
aria-label={"Filter members by group: #{group.name}"}
|
||||
class="badge badge-primary badge-clickable"
|
||||
type="button"
|
||||
>
|
||||
<%= group.name %>
|
||||
<.icon name="hero-x-mark" class="w-3 h-3 ml-1" aria-hidden="true" />
|
||||
</button>
|
||||
```
|
||||
|
||||
**Group Filter Dropdown:**
|
||||
|
||||
```heex
|
||||
<select
|
||||
id="group-filter"
|
||||
name="group_filter"
|
||||
phx-change="group_filter_changed"
|
||||
aria-label="Filter members by group"
|
||||
class="select select-bordered"
|
||||
>
|
||||
<option value="">All Groups</option>
|
||||
<%= for group <- @groups do %>
|
||||
<option value={group.id} selected={@selected_group_id == group.id}>
|
||||
<%= group.name %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
```
|
||||
|
||||
**Screen Reader Announcements:**
|
||||
|
||||
```heex
|
||||
<div role="status" aria-live="polite" class="sr-only">
|
||||
<%= if @filtered_by_group do %>
|
||||
Showing <%= @member_count %> members in group <%= @filtered_group.name %>
|
||||
<% else %>
|
||||
Showing <%= @member_count %> members
|
||||
<% end %>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Delete Confirmation Modal:**
|
||||
|
||||
```heex
|
||||
<dialog
|
||||
id="delete-group-modal"
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-labelledby="delete-modal-title"
|
||||
aria-describedby="delete-modal-description"
|
||||
>
|
||||
<div class="modal-box">
|
||||
<h3 id="delete-modal-title" class="text-lg font-bold">
|
||||
Delete Group
|
||||
</h3>
|
||||
|
||||
<div id="delete-modal-description" class="py-4">
|
||||
<p class="font-bold">Group "<%= @group.name %>" will be permanently deleted.</p>
|
||||
<p class="text-warning">
|
||||
<%= @group.member_count %> members are in this group.
|
||||
All member-group associations will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Form with name confirmation -->
|
||||
</div>
|
||||
</dialog>
|
||||
```
|
||||
|
||||
**Keyboard Navigation:**
|
||||
- All interactive elements (buttons, links, form inputs) must be focusable via Tab key
|
||||
- Modal dialogs must trap focus (Tab key cycles within modal)
|
||||
- Escape key closes modals
|
||||
- Enter/Space activates buttons when focused
|
||||
|
||||
---
|
||||
|
||||
## Integration Points
|
||||
|
|
@ -403,8 +503,7 @@ end
|
|||
- `destroy` - Delete groups (admin only)
|
||||
|
||||
**Scopes:**
|
||||
- `:all` - All groups (for admins)
|
||||
- `:all` - All groups (for read-only users, read permission only)
|
||||
- `:all` - All groups (for all permission sets that have read access)
|
||||
|
||||
### Permission Sets Update
|
||||
|
||||
|
|
@ -413,10 +512,10 @@ end
|
|||
%{
|
||||
resources: [
|
||||
# ... existing resources ...
|
||||
%{resource: "groups", action: :read, scope: :all, granted: true},
|
||||
%{resource: "groups", action: :create, scope: :all, granted: true},
|
||||
%{resource: "groups", action: :update, scope: :all, granted: true},
|
||||
%{resource: "groups", action: :destroy, scope: :all, granted: true}
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Group", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Group", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Group", action: :destroy, scope: :all, granted: true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
|
@ -426,11 +525,33 @@ end
|
|||
%{
|
||||
resources: [
|
||||
# ... existing resources ...
|
||||
%{resource: "groups", action: :read, scope: :all, granted: true}
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Normal User Permission Set:**
|
||||
```elixir
|
||||
%{
|
||||
resources: [
|
||||
# ... existing resources ...
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Own Data Permission Set:**
|
||||
```elixir
|
||||
%{
|
||||
resources: [
|
||||
# ... existing resources ...
|
||||
%{resource: "Group", action: :read, scope: :all, granted: true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** All permission sets use `:all` scope for groups. Groups are considered public information that all users with member read permission can view. Only admins can manage (create/update/destroy) groups.
|
||||
|
||||
### Member-Group Association Permissions
|
||||
|
||||
**Current (MVP):**
|
||||
|
|
@ -456,13 +577,60 @@ end
|
|||
### Query Optimization
|
||||
|
||||
**Member Overview:**
|
||||
- Load groups with members in single query (using `load`)
|
||||
- Load groups with members in single query using `Ash.Query.load`
|
||||
- Use `Ash.Query.load(groups: [:id, :name])` to minimize data transfer
|
||||
- Filter groups at database level when filtering by group
|
||||
|
||||
**Example:**
|
||||
```elixir
|
||||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.load(groups: [:id, :name])
|
||||
|> Ash.Query.filter(expr(groups.id == ^selected_group_id))
|
||||
|
||||
members = Ash.read!(query, actor: actor)
|
||||
```
|
||||
|
||||
**N+1 Query Prevention:**
|
||||
- Always use `Ash.Query.load` to preload groups relationship
|
||||
- Never access `member.groups` without preloading (would trigger N+1 queries)
|
||||
|
||||
**Performance Threshold:**
|
||||
- With proper `load` usage: Works efficiently up to **100 members** (MVP scope)
|
||||
- For larger datasets (>100 members), consider:
|
||||
- Pagination (limit number of members loaded)
|
||||
- Lazy loading of groups (only load when groups column is visible)
|
||||
- Database-level aggregation for group counts
|
||||
|
||||
**Example of N+1 Problem (DO NOT DO THIS):**
|
||||
```elixir
|
||||
# BAD: This causes N+1 queries
|
||||
members = Ash.read!(Mv.Membership.Member)
|
||||
Enum.each(members, fn member ->
|
||||
# Each iteration triggers a separate query!
|
||||
groups = member.groups # N+1 query!
|
||||
end)
|
||||
```
|
||||
|
||||
**Correct Approach:**
|
||||
```elixir
|
||||
# GOOD: Preload in single query
|
||||
members =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.load(groups: [:id, :name])
|
||||
|> Ash.read!()
|
||||
|
||||
# No additional queries needed
|
||||
Enum.each(members, fn member ->
|
||||
groups = member.groups # Already loaded!
|
||||
end)
|
||||
```
|
||||
|
||||
**Group Detail:**
|
||||
- Paginate member list for large groups
|
||||
- Paginate member list for large groups (>50 members)
|
||||
- Load member count via calculation (not separate query)
|
||||
- Use `Ash.Query.load` for member details when displaying
|
||||
|
||||
### Search Performance
|
||||
|
||||
|
|
@ -927,48 +1095,352 @@ Jede fachliche Einheit kann als **separates Issue** umgesetzt werden:
|
|||
|
||||
### Unit Tests
|
||||
|
||||
**Group Resource:**
|
||||
- Name uniqueness validation
|
||||
- Name/description length constraints
|
||||
- Member count calculation
|
||||
#### Group Resource Tests
|
||||
|
||||
**MemberGroup Resource:**
|
||||
- Unique constraint on (member_id, group_id)
|
||||
- Cascade delete behavior
|
||||
**File:** `test/membership/group_test.exs`
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Membership.GroupTest do
|
||||
use Mv.DataCase
|
||||
alias Mv.Membership.Group
|
||||
|
||||
describe "create_group/1" do
|
||||
test "creates group with valid attributes" do
|
||||
attrs = %{name: "Vorstand", description: "Board of directors"}
|
||||
assert {:ok, group} = Group.create(attrs)
|
||||
assert group.name == "Vorstand"
|
||||
assert group.description == "Board of directors"
|
||||
end
|
||||
|
||||
test "returns error when name is missing" do
|
||||
attrs = %{description: "Some description"}
|
||||
assert {:error, changeset} = Group.create(attrs)
|
||||
assert %{name: ["is required"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "returns error when name exceeds 100 characters" do
|
||||
long_name = String.duplicate("a", 101)
|
||||
attrs = %{name: long_name}
|
||||
assert {:error, changeset} = Group.create(attrs)
|
||||
assert %{name: ["must be at most 100 character(s)"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "returns error when name is not unique" do
|
||||
Group.create!(%{name: "Vorstand"})
|
||||
attrs = %{name: "Vorstand"}
|
||||
assert {:error, changeset} = Group.create(attrs)
|
||||
assert %{name: ["has already been taken"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "name uniqueness is case-sensitive" do
|
||||
Group.create!(%{name: "Vorstand"})
|
||||
attrs = %{name: "VORSTAND"}
|
||||
# For MVP: case-sensitive uniqueness
|
||||
assert {:ok, _} = Group.create(attrs)
|
||||
end
|
||||
|
||||
test "allows description to be nil" do
|
||||
attrs = %{name: "Test Group"}
|
||||
assert {:ok, group} = Group.create(attrs)
|
||||
assert is_nil(group.description)
|
||||
end
|
||||
|
||||
test "trims whitespace from name" do
|
||||
attrs = %{name: " Vorstand "}
|
||||
assert {:ok, group} = Group.create(attrs)
|
||||
assert group.name == "Vorstand"
|
||||
end
|
||||
|
||||
test "description max length is 500 characters" do
|
||||
long_desc = String.duplicate("a", 501)
|
||||
attrs = %{name: "Test", description: long_desc}
|
||||
assert {:error, changeset} = Group.create(attrs)
|
||||
assert %{description: ["must be at most 500 character(s)"]} = errors_on(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_group/2" do
|
||||
test "updates group name and description" do
|
||||
group = Group.create!(%{name: "Old Name", description: "Old Desc"})
|
||||
attrs = %{name: "New Name", description: "New Desc"}
|
||||
assert {:ok, updated} = Group.update(group, attrs)
|
||||
assert updated.name == "New Name"
|
||||
assert updated.description == "New Desc"
|
||||
end
|
||||
|
||||
test "prevents duplicate name on update" do
|
||||
Group.create!(%{name: "Existing"})
|
||||
group = Group.create!(%{name: "Other"})
|
||||
attrs = %{name: "Existing"}
|
||||
assert {:error, changeset} = Group.update(group, attrs)
|
||||
assert %{name: ["has already been taken"]} = errors_on(changeset)
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_group/1" do
|
||||
test "deletes group and all member associations" do
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
assert :ok = Group.destroy(group)
|
||||
|
||||
# Group should be deleted
|
||||
assert {:error, _} = Group.get(group.id)
|
||||
|
||||
# MemberGroup association should be deleted (CASCADE)
|
||||
assert [] = MemberGroup.read!(filter: [group_id: group.id])
|
||||
|
||||
# Member should still exist
|
||||
assert {:ok, _} = Member.get(member.id)
|
||||
end
|
||||
|
||||
test "does not delete members themselves" do
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
Group.destroy!(group)
|
||||
|
||||
# Member should still exist
|
||||
assert {:ok, _} = Member.get(member.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe "member_count calculation" do
|
||||
test "returns 0 for empty group" do
|
||||
group = Group.create!(%{name: "Empty Group"})
|
||||
assert group.member_count == 0
|
||||
end
|
||||
|
||||
test "returns correct count when members added" do
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
member1 = Member.create!(%{email: "test1@example.com"})
|
||||
member2 = Member.create!(%{email: "test2@example.com"})
|
||||
|
||||
MemberGroup.create!(%{member_id: member1.id, group_id: group.id})
|
||||
MemberGroup.create!(%{member_id: member2.id, group_id: group.id})
|
||||
|
||||
# Reload group to get updated count
|
||||
group = Group.get!(group.id, load: [:member_count])
|
||||
assert group.member_count == 2
|
||||
end
|
||||
|
||||
test "updates correctly when members removed" do
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
# Remove member
|
||||
MemberGroup.destroy!(mg)
|
||||
|
||||
# Reload group
|
||||
group = Group.get!(group.id, load: [:member_count])
|
||||
assert group.member_count == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### MemberGroup Resource Tests
|
||||
|
||||
**File:** `test/membership/member_group_test.exs`
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Membership.MemberGroupTest do
|
||||
use Mv.DataCase
|
||||
alias Mv.Membership.{MemberGroup, Member, Group}
|
||||
|
||||
describe "create_member_group/1" do
|
||||
test "creates association between member and group" do
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
|
||||
attrs = %{member_id: member.id, group_id: group.id}
|
||||
assert {:ok, mg} = MemberGroup.create(attrs)
|
||||
assert mg.member_id == member.id
|
||||
assert mg.group_id == group.id
|
||||
end
|
||||
|
||||
test "prevents duplicate associations" do
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
attrs = %{member_id: member.id, group_id: group.id}
|
||||
assert {:error, changeset} = MemberGroup.create(attrs)
|
||||
assert %{member_id: ["has already been taken"]} = errors_on(changeset)
|
||||
end
|
||||
|
||||
test "cascade deletes when member deleted" do
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
Member.destroy!(member)
|
||||
|
||||
# Association should be deleted
|
||||
assert {:error, _} = MemberGroup.get(mg.id)
|
||||
end
|
||||
|
||||
test "cascade deletes when group deleted" do
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
mg = MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
Group.destroy!(group)
|
||||
|
||||
# Association should be deleted
|
||||
assert {:error, _} = MemberGroup.get(mg.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Group Management:**
|
||||
- Create group
|
||||
- Update group
|
||||
- Delete group with confirmation
|
||||
- Add member to group
|
||||
- Remove member from group
|
||||
#### Member-Group Relationships
|
||||
|
||||
**Member-Group Relationships:**
|
||||
- Member can belong to multiple groups
|
||||
- Group can contain multiple members
|
||||
- Cascade delete when member deleted
|
||||
- Cascade delete when group deleted
|
||||
**File:** `test/membership/group_integration_test.exs`
|
||||
|
||||
```elixir
|
||||
defmodule Mv.Membership.GroupIntegrationTest do
|
||||
use Mv.DataCase
|
||||
alias Mv.Membership.{Group, Member, MemberGroup}
|
||||
|
||||
describe "member-group relationships" do
|
||||
test "member can belong to multiple groups" do
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
group1 = Group.create!(%{name: "Group 1"})
|
||||
group2 = Group.create!(%{name: "Group 2"})
|
||||
|
||||
MemberGroup.create!(%{member_id: member.id, group_id: group1.id})
|
||||
MemberGroup.create!(%{member_id: member.id, group_id: group2.id})
|
||||
|
||||
member = Member.get!(member.id, load: [:groups])
|
||||
assert length(member.groups) == 2
|
||||
assert Enum.any?(member.groups, &(&1.id == group1.id))
|
||||
assert Enum.any?(member.groups, &(&1.id == group2.id))
|
||||
end
|
||||
|
||||
test "group can contain multiple members" do
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
member1 = Member.create!(%{email: "test1@example.com"})
|
||||
member2 = Member.create!(%{email: "test2@example.com"})
|
||||
|
||||
MemberGroup.create!(%{member_id: member1.id, group_id: group.id})
|
||||
MemberGroup.create!(%{member_id: member2.id, group_id: group.id})
|
||||
|
||||
group = Group.get!(group.id, load: [:members])
|
||||
assert length(group.members) == 2
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### UI Tests
|
||||
|
||||
**Groups Management:**
|
||||
- Groups index page loads
|
||||
- Create group form works
|
||||
- Edit group form works
|
||||
- Delete confirmation modal works
|
||||
- Name confirmation required for delete
|
||||
#### Groups Management
|
||||
|
||||
**Member Overview:**
|
||||
- Groups column displays correctly
|
||||
- Group filter works
|
||||
- Group sorting works
|
||||
- URL params persist
|
||||
**File:** `test/mv_web/live/group_live/index_test.exs`
|
||||
|
||||
**Member Detail:**
|
||||
- Groups section displays
|
||||
- Links to group pages work
|
||||
```elixir
|
||||
defmodule MvWeb.GroupLive.IndexTest do
|
||||
use MvWeb.ConnCase
|
||||
alias Mv.Membership.Group
|
||||
|
||||
describe "groups index page" do
|
||||
test "lists all groups", %{conn: conn} do
|
||||
group1 = Group.create!(%{name: "Group 1", description: "First group"})
|
||||
group2 = Group.create!(%{name: "Group 2", description: "Second group"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/groups")
|
||||
|
||||
assert render(view) =~ "Group 1"
|
||||
assert render(view) =~ "Group 2"
|
||||
end
|
||||
|
||||
test "creates new group", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/groups")
|
||||
|
||||
view
|
||||
|> element("button", "New Group")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> form("#group-form", group: %{name: "New Group", description: "Description"})
|
||||
|> render_submit()
|
||||
|
||||
assert render(view) =~ "New Group"
|
||||
end
|
||||
|
||||
test "deletes group with confirmation", %{conn: conn} do
|
||||
group = Group.create!(%{name: "To Delete"})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/groups")
|
||||
|
||||
# Click delete
|
||||
view
|
||||
|> element("button[phx-click='delete']", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Enter group name to confirm
|
||||
view
|
||||
|> form("#delete-group-modal form", name: "To Delete")
|
||||
|> render_change()
|
||||
|
||||
# Confirm deletion
|
||||
view
|
||||
|> element("#delete-group-modal button", "Delete Group")
|
||||
|> render_click()
|
||||
|
||||
assert render(view) =~ "Group deleted successfully"
|
||||
assert {:error, _} = Group.get(group.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### Member Overview Integration
|
||||
|
||||
**File:** `test/mv_web/live/member_live/index_groups_test.exs`
|
||||
|
||||
```elixir
|
||||
defmodule MvWeb.MemberLive.IndexGroupsTest do
|
||||
use MvWeb.ConnCase
|
||||
alias Mv.Membership.{Member, Group, MemberGroup}
|
||||
|
||||
describe "groups in member overview" do
|
||||
test "displays group badges", %{conn: conn} do
|
||||
member = Member.create!(%{email: "test@example.com"})
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
MemberGroup.create!(%{member_id: member.id, group_id: group.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
assert render(view) =~ "Test Group"
|
||||
end
|
||||
|
||||
test "filters members by group", %{conn: conn} do
|
||||
member1 = Member.create!(%{email: "test1@example.com"})
|
||||
member2 = Member.create!(%{email: "test2@example.com"})
|
||||
group = Group.create!(%{name: "Test Group"})
|
||||
MemberGroup.create!(%{member_id: member1.id, group_id: group.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
# Select group filter
|
||||
view
|
||||
|> element("#group-filter")
|
||||
|> render_change(%{"group_filter" => group.id})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "test1@example.com"
|
||||
refute html =~ "test2@example.com"
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue