groups: a group's page lists its members as contacts
The members were tags in a row of the group's card, beside the
addresses and note it lends. They are what a group holds, so they
now list under the card the way /contacts lists everyone, avatar and
name, in the same order (Format.sort_key, lifted out of
ContactsIndex). The card keeps only what the group lends and drops
its grid when that is nothing; "edit members" moves to the list's
heading.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUeUZZ5774jTPhGrwGDcfN
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 8410f01..22b5762 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -106,14 +106,12 @@ module ProTacts
# belongs to frames the values under it, and several of those
# values are the group's rather than the contact's. Every tag opens
# the group it names (docs/DESIGN.md, "Relationships are
- # navigable"), the other half of the member tags on a group's own
- # card (Admin::GroupsShow). Rendered for a contact in no group
+ # navigable"), the other half of the member list on a group's own
+ # page (Admin::GroupsShow). Rendered for a contact in no group
# too when the dialog is on the page, even before any group
- # exists, because the row holds the way to join or start one —
- # the group card's members row, for the same reason, with its
- # "edit members" in the same place. Without the dialog the row
- # stands only when there is a member to name: an empty row with
- # nothing to press is nothing at all.
+ # exists, because the row holds the way to join or start one.
+ # Without the dialog the row stands only when there is a member
+ # to name: an empty row with nothing to press is nothing at all.
#: () -> void
def groups_row
dt(class: "type-label") { "groups" }
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 0663674..c401b35 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -10,18 +10,16 @@ module ProTacts
module Admin
# GET /contacts — every contact in one list, sorted by last name:
# the one screen that browses the whole contact set (docs/DESIGN.md,
- # "The core idea"). The sort key is N's family name (RFC 2426
- # section 3.1.2), falling back the way Format.initials does — FN
- # for a card with no N, the id for a card with no name at all.
- # Rows carry the label the dashboard's rows do (Format.name_label)
- # and the contact's groups as chips under it.
+ # "The core idea"), in Format.sort_key's order. Rows carry the
+ # label the dashboard's rows do (Format.name_label) and the
+ # contact's groups as chips under it.
class ContactsIndex < Phlex::HTML
# @rbs @rows: Array[Contact]
# @rbs @groups_of: Hash[String, Array[Store::Group]]
#: (contacts: Array[Contact], groups: Array[Store::Group]) -> void
def initialize(contacts:, groups:)
- @rows = contacts.sort_by { [sort_key(it), it.id] }
+ @rows = contacts.sort_by { [Format.sort_key(it), it.id] }
# Whole groups inverted into memberships, the way the dashboard
# inverts them for search: which groups a contact is in is the
# store's fact, read from that side once instead of per row.
@@ -52,14 +50,6 @@ module ProTacts
private
- # The order a contact lists under, case-insensitive so the card's
- # spelling of a family name does not move it.
- #: (Contact contact) -> String
- def sort_key(contact)
- family, = contact.name_components || []
- (family || contact.name || contact.id).to_s.downcase
- end
-
#: (Contact contact) -> void
def render_row(contact)
render ListItem.new(
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 3f0a7db..b216b33 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -22,6 +22,18 @@ module ProTacts
nickname || name || contact.id
end
+ # The order a contact lists under: N's family name (RFC 2426
+ # section 3.1.2), falling back the way #initials does — FN for a
+ # card with no N, the id for a card with no name at all — and
+ # case-insensitive, so the card's spelling of a family name does
+ # not move it. Every listing of contacts sorts by it, the id
+ # breaking a tie.
+ #: (Contact contact) -> String
+ def self.sort_key(contact)
+ family, = contact.name_components || []
+ (family || contact.name || contact.id).to_s.downcase
+ end
+
# A contact's initials. N's given and family names (RFC 2426
# section 3.1.2) are structured data and the real answer to "what
# are this contact's initials" — falling back, when the card
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index dd4504d..391a86a 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -1,17 +1,21 @@
require "pro_tacts/admin/phlex"
+require "pro_tacts/admin/avatar"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_label"
require "pro_tacts/admin/layout"
+require "pro_tacts/admin/list_item"
module ProTacts
module Admin
- # GET /groups/:id — a group's card. Groups are records, not labels
- # (docs/DESIGN.md, "Relationships are navigable"), so this is a
- # contact's card in every way that carries over: what the group
- # lends on the same type-and-value grid, and an attribute with no
- # data gets no row. Its members are tags that open their records,
- # the other half of the tags on a member's own card.
+ # GET /groups/:id — a group's card, and under it the contacts it
+ # holds. Groups are records, not labels (docs/DESIGN.md,
+ # "Relationships are navigable"), so the card is a contact's card
+ # in every way that carries over: what the group lends on the same
+ # type-and-value grid, and an attribute with no data gets no row.
+ # The members are what a group is, so they are a list rather than a
+ # row: /contacts' rows, narrowed to this group and in its order
+ # (Format.sort_key).
class GroupsShow < Phlex::HTML
# @rbs @group: Store::Group
# @rbs @reading: Contact
@@ -21,7 +25,7 @@ module ProTacts
def initialize(group:, members:)
@group = group
@reading = group.reading
- @members = members
+ @members = members.sort_by { [Format.sort_key(it), it.id] }
end
def view_template
@@ -36,22 +40,25 @@ module ProTacts
div(class: "detail-header") do
h1(class: "type-h2") { render GroupLabel.new(group: @group) }
end
- # Never empty, unlike ContactsShow's: the members row
- # always renders (#members_row).
- dl(class: "detail-grid") { rows }
+ # No grid for a group that lends nothing, so its name
+ # sits alone in the card (.detail-header in admin.css).
+ dl(class: "detail-grid") { rows } if lends?
end
end
end
+ members_list
end
end
private
- # Members first, the order a contact's card puts its groups in:
- # the relationship frames the rows under it.
+ #: () -> bool
+ def lends?
+ @reading.addresses.any? || @reading.notes.any?
+ end
+
#: () -> void
def rows
- members_row
@reading.addresses.each do |address|
row(Format.type_label(nil, address.types, "address")) do
Format.address_lines(address).each { |line| div { line } }
@@ -62,22 +69,30 @@ module ProTacts
end
end
- # Rendered even with no members, because its link is the way to
- # add one. Membership is edited here rather than in the editor,
- # which edits only the lines the group holds. The link sits at the
- # row's right edge where a lent row's badge sits (admin.css), and
- # has no href until the members screen exists.
+ # Rendered even with no members, because its head holds the way
+ # to add one. Membership is edited here rather than in the
+ # editor, which edits only the lines the group holds. The link
+ # sits where a section's action does (admin.css), and has no
+ # href until the members screen exists.
#: () -> void
- def members_row
- row("members") do
- if @members.any?
- div(class: "tag-set") do
+ def members_list
+ section do
+ div(class: "section-head") do
+ h2(class: "type-label") { "members (#{@members.length})" }
+ a(class: "type-label") { "edit members" }
+ end
+ if @members.empty?
+ p(class: "type-body-sm") { "No members yet." }
+ else
+ ul(class: "card") do
@members.each do |member|
- a(href: "/contacts/#{member.id}", class: "tag") { member.name || member.id }
+ render ListItem.new(
+ href: "/contacts/#{member.id}",
+ avatar: Avatar.new(contact: member, size: "lg"),
+ ) { Format.name_label(member) }
end
end
end
- a(class: "type-label") { "edit members" }
end
end
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 06880f9..62bf84d 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -92,7 +92,7 @@ module ProTacts
Admin::GroupsEdit.call(group:, notice:)
end
- # A group's members as contacts, in the listing's own order.
+ # A group's members as contacts; the page puts them in order.
#: (Store::Group group) -> Array[Contact]
def members_of(group)
store.contacts.select { group.members.include?(it.id) }
diff --git a/public/admin.css b/public/admin.css
index 16abecb..f6b9598 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -647,6 +647,15 @@ section > h2 {
inset-inline-end: 0;
translate: 0 -50%;
}
+/* An action that is a type-label link rather than a button — a group
+ page's "edit members" — is the heading's own size, so it needs none
+ of the pinning above: in flow at the right edge, on the heading's
+ baseline. */
+.section-head:has(> a.type-label) {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
/* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
is a prose-list rule (Markdown bullet lists) that also matches
@@ -761,12 +770,10 @@ span.avatar[data-size="lg"] {
Baseline-aligned: a grid item blockifies, so the tag's inline-flex
becomes a flex container, which takes its baseline from its first
- item — the tag's text — and so shares the value's. A group card's
- "edit members" takes the same place, and is plain text with a
- baseline of its own; a contact card's "edit groups" button takes
- it too, a flex container like the tag. */
+ item — the tag's text — and so shares the value's. A contact
+ card's "edit groups" button takes the same place, a flex container
+ like the tag. */
.detail-grid dd:has(> .tag),
-.detail-grid dd:has(> a.type-label),
.detail-grid dd:has(> button) {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -774,13 +781,11 @@ span.avatar[data-size="lg"] {
column-gap: var(--gl-space-s);
}
.detail-grid dd > .tag,
-.detail-grid dd > a.type-label,
.detail-grid dd > button {
grid-column: 2;
grid-row: 1;
}
.detail-grid dd:has(> .tag) > div,
-.detail-grid dd:has(> a.type-label) > div,
.detail-grid dd:has(> button) > div {
grid-column: 1;
}
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 1e93bbe..96f84b0 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -130,23 +130,50 @@ class AdminGroupsPagesTest < Minitest::Test
assert_equal 200, last_response.status
assert_includes body, "7 Calculus Close"
assert_includes body, "Gate code 1854."
- assert_includes body, %(<a href="/contacts/george" class="tag">George Boole</a>)
- assert_includes body, %(<a href="/contacts/mary" class="tag">Mary Boole</a>)
+ assert_includes body, "members (2)"
+ assert_match %r{<a href="/contacts/george">.*?George Boole</div>}, body
+ assert_match %r{<a href="/contacts/mary">.*?Mary Boole</div>}, body
assert_includes body, "edit members"
refute_includes body, "Ada Lovelace"
end
end
+ # The members list the way /contacts does (Format.sort_key), not in
+ # the store's id order. Seeded so the two disagree.
+ def test_the_members_list_in_name_order
+ with_contacts(BOOLES.merge("zz" => "Alice Aardvark")) do |store|
+ id = household(store, members: %w[mary zz george])
+
+ get "/groups/#{id}"
+
+ assert_equal %w[zz george mary], last_response.body.scan(%r{<a href="/contacts/([^"]+)">}).flatten
+ end
+ end
+
def test_a_memberless_card_still_offers_to_edit_members
with_contacts(BOOLES) do |store|
id = household(store, members: [])
get "/groups/#{id}"
+ assert_includes last_response.body, "members (0)"
+ assert_includes last_response.body, "No members yet."
assert_includes last_response.body, "edit members"
end
end
+ # A group that lends nothing renders no grid, so its name is not
+ # held above an empty one (.detail-header in admin.css).
+ def test_a_card_that_lends_nothing_has_no_grid
+ with_contacts(BOOLES) do |store|
+ id = store.create_group(name: "Booles")
+
+ get "/groups/#{id}"
+
+ refute_includes last_response.body, "detail-grid"
+ end
+ end
+
def test_an_unknown_group_is_a_404
with_contacts({}) do
get "/groups/zzzz"