group tag: name the group's first five members on hover
A group tag carries Gloss's tooltip listing the group's members in the
contacts page's order, the first five by name and then "and N more".
The store lists a group's members only for the reads that tag groups
(Store#groups_of with listed_members:, Store#all_groups with listing:),
so a group composed for a client reads none. Contact and ListedContact
answer <=>, which replaces Format.sort_key at each sort.
Gloss's card clips its contents and centers a tooltip on its trigger,
so a card holding a tooltip overflows, its end rows keep its corners
themselves, and a tag's list aligns to the tag.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0191V6gdWnYUcU5DvJBAWoMj
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index f6fc42a..061f9e9 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -33,6 +33,7 @@ module ProTacts
# @rbs @contact: Contact
# @rbs @groups: Array[Group]
# @rbs @walk: bool
+ # @rbs @listed_groups: Hash[String, Group]
#: (contact: Contact, groups: Array[Group], ?walk: bool) -> void
def initialize(contact:, groups:, walk: false)
@@ -40,6 +41,7 @@ module ProTacts
@groups = groups
@walk = walk
@birthday = Format.birthday(contact)
+ @listed_groups = groups.to_h { [it.id, it] }
end
def view_template
@@ -122,7 +124,7 @@ module ProTacts
dt(class: "type-label") { "notes" }
dd(class: "type-body-sm") do
div(style: "white-space: pre-wrap;") { text }
- render GroupTag.new(group: lent.group)
+ render GroupTag.new(group: @listed_groups.fetch(lent.group.id))
end
end
end
@@ -183,7 +185,7 @@ module ProTacts
# The groups row's own tag, pinned to this row's first line
# at the right edge of the value column (admin.css) — under
# the value it could be read as marking the row below.
- render GroupTag.new(group: lent_by) if lent_by
+ render GroupTag.new(group: @listed_groups.fetch(lent_by.id)) if lent_by
end
end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 97b7227..ee9979e 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -9,7 +9,7 @@ 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"), in Format.sort_key's order. Rows carry the
+ # "The core idea"), in Contact#<=>'s order. Rows carry the
# label the dashboard's rows do (Format.name_label) and the
# contact's groups as chips under it.
#
@@ -25,7 +25,7 @@ module ProTacts
#: (contacts: Array[ListedContact], groups: Array[Group], login: String) -> void
def initialize(contacts:, groups:, login:)
@login = login
- @rows = contacts.sort_by { [Format.sort_key(it), it.id] }
+ @rows = contacts.sort
# Whole groups inverted into memberships, the way Admin::Search
# inverts them: which groups a contact is in is the store's
# fact, read from that side once instead of per row. The
@@ -81,7 +81,7 @@ module ProTacts
end
end
- # The rows cut at each first letter of Format.sort_key, the way
+ # The rows cut at each first letter of Contact#sort_key, the way
# a phone's contacts list is: a contact files under the letter
# it sorts by, so one with no family name files under its FN's.
# An accent files with its base letter, whose rows it follows,
@@ -97,7 +97,7 @@ module ProTacts
#: (ListedContact contact) -> String
def letter_of(contact)
- base = Format.sort_key(contact).unicode_normalize(:nfd)[0].to_s
+ base = contact.sort_key.unicode_normalize(:nfd)[0].to_s
base.match?(/[a-z]/) ? base.upcase : "#"
end
end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 7ada939..d6826bd 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -23,18 +23,6 @@ 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 | ListedContact contact) -> String
- def self.sort_key(contact)
- family, = contact.name_components || []
- (family || contact.name || contact.id).to_s.downcase
- end
-
# The string a search compares on: the query and the value it
# is matched against both lowercased and accents folded to the
# letters they sit on, so "munoz" finds "Muñoz". NFD plus
diff --git a/lib/pro_tacts/admin/group_tag.rb b/lib/pro_tacts/admin/group_tag.rb
index 876c07c..5d5ecf5 100644
--- a/lib/pro_tacts/admin/group_tag.rb
+++ b/lib/pro_tacts/admin/group_tag.rb
@@ -1,25 +1,40 @@
require "pro_tacts/admin/phlex"
+require "pro_tacts/admin/format"
require "pro_tacts/admin/group_label"
require "pro_tacts/admin/icons"
module ProTacts
module Admin
# A contact's group as a chip linking to the group, marked when the
- # group has a photo so a reader knows its page holds one.
+ # group has a photo so a reader knows its page holds one, and naming
+ # its first few members in Gloss's tooltip.
class GroupTag < Phlex::HTML
+ SHOWN = 5 #: Integer
+
# @rbs @group: Group
+ # @rbs @members: Array[ListedContact]
#: (group: Group) -> void
def initialize(group:)
@group = group
+ @members = group.listed_members || raise(ArgumentError, "group #{group.id} was read without its members listed")
end
def view_template
- a(href: "/groups/#{@group.id}", class: "tag") do
- render Icon.new(:image, size: 12) if @group.photo
- render GroupLabel.new(group: @group)
- span(class: "gl-visually-hidden") { "has a photo" } if @group.photo
+ shown = @members.take(SHOWN)
+ rest = @members.drop(SHOWN)
+ span do
+ a(href: "/groups/#{@group.id}", class: "tag") do
+ render Icon.new(:image, size: 12) if @group.photo
+ render GroupLabel.new(group: @group)
+ span(class: "gl-visually-hidden") { "has a photo" } if @group.photo
+ end
+ # Not a ul, which Gloss's .card-body ul sets as prose.
+ ol(role: "tooltip") do
+ shown.each { |contact| li { Format.name_label(contact) } }
+ li { "and #{rest.length} more" } if rest.any?
+ end
end
end
end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 3b899ff..828dcb0 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -15,7 +15,7 @@ module ProTacts
# 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). Under them the group's history, the change
+ # (Contact#<=>). Under them the group's history, the change
# log the contact page renders in this page's own shape. The whole
# book comes in beside the members (Store#contacts) because that
# log names the cards a join or a leave moved, and a name is what
@@ -35,8 +35,7 @@ module ProTacts
@contacts = contacts
@changes = changes
@reading = group.reading
- @members = contacts.select { group.members.include?(it.id) }
- .sort_by { [Format.sort_key(it), it.id] }
+ @members = contacts.select { group.members.include?(it.id) }.sort
end
def view_template
diff --git a/lib/pro_tacts/admin/member_picker.rb b/lib/pro_tacts/admin/member_picker.rb
index acdbe23..86e3825 100644
--- a/lib/pro_tacts/admin/member_picker.rb
+++ b/lib/pro_tacts/admin/member_picker.rb
@@ -45,9 +45,9 @@ module ProTacts
def initialize(contacts:, login:, notice: nil)
@login = login
@notice = notice
- # The order the show screen's list reads (Format.sort_key),
+ # The order the show screen's list reads (Contact#<=>),
# which is the order the book is browsed in everywhere else.
- @contacts = contacts.sort_by { [Format.sort_key(it), it.id] }
+ @contacts = contacts.sort
# The ticked are never capped (GroupFilter.capped).
@capped = GroupFilter.capped(@contacts.map(&:id), joined: ticked)
end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9f22592..c74c18e 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -342,6 +342,23 @@ module ProTacts
text_of(vcard.property("NICKNAME"))
end
+ # The order a contact lists under: N's family name (RFC 2426
+ # section 3.1.2), falling back the way Admin::Format.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.
+ #: () -> String
+ def sort_key
+ family, = name_components || []
+ (family || name || id).to_s.downcase
+ end
+
+ # Not Comparable, for Group#<=>'s reason.
+ #: (Contact | ListedContact other) -> Integer?
+ def <=>(other)
+ [sort_key, id] <=> [other.sort_key, other.id]
+ end
+
# ORG's value (RFC 2426 section 3.5.5), in text form — the whole
# value as the card spells it, org name and units together, which
# is what a search reads; no screen has asked for the components
diff --git a/lib/pro_tacts/group.rb b/lib/pro_tacts/group.rb
index e73b72e..d55df22 100644
--- a/lib/pro_tacts/group.rb
+++ b/lib/pro_tacts/group.rb
@@ -7,22 +7,23 @@ require "pro_tacts/vcard"
module ProTacts
# A group as the admin screens read one: its own row, the lines it
# lends in the order it lends them, its members' card ids, and
- # whether it has a photo (docs/plans/2026-10-12-group-photos.md). The
- # label is the SQL one Store#group_label computes, so a tag and a
+ # whether it has a photo (docs/plans/2026-10-12-group-photos.md).
+ # Only the reads that ask fill in `listed_members`, so a group
+ # composed into a card for a client reads none. The label is the SQL one Store#group_label computes, so a tag and a
# heading can never disagree about what to call a nameless group.
#
# The signature lives in sig/pro_tacts/group.rbs, this being a Data
# class.
# @rbs skip
- Group = Data.define(:id, :name, :label, :lines, :members, :photo)
+ Group = Data.define(:id, :name, :label, :lines, :members, :photo, :listed_members)
# Reopened rather than defined in the block above, for the reason
# CardDiff is.
class Group
# A photo is the exception, not the rule, so a group built without
# saying is one without.
- #: (?photo: bool, **untyped) -> void
- def initialize(photo: false, **)
+ #: (?photo: bool, ?listed_members: Array[ListedContact]?, **untyped) -> void
+ def initialize(photo: false, listed_members: nil, **)
super
end
diff --git a/lib/pro_tacts/listed_contact.rb b/lib/pro_tacts/listed_contact.rb
index b46f06d..987e524 100644
--- a/lib/pro_tacts/listed_contact.rb
+++ b/lib/pro_tacts/listed_contact.rb
@@ -18,6 +18,7 @@ module ProTacts
class ListedContact
# @rbs @contact: Contact
# @rbs @photo: bool
+ # @rbs @sort_key: String?
#: (Contact contact, photo: bool) -> void
def initialize(contact, photo:)
@@ -37,6 +38,16 @@ module ProTacts
#: () -> String?
def nickname = @contact.nickname
+ # Kept, since a sort asks at every comparison; nothing writes
+ # through a listed contact to change it.
+ #: () -> String
+ def sort_key = @sort_key ||= @contact.sort_key
+
+ #: (Contact | ListedContact other) -> Integer?
+ def <=>(other)
+ [sort_key, id] <=> [other.sort_key, other.id]
+ end
+
#: () -> String?
def organization = @contact.organization
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 205ba62..141b3dc 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -360,15 +360,17 @@ module ProTacts
# rather than on it: what a group lends is in the card and belongs
# to the model of one, and which groups a contact is in is a
# relationship the card never carries.
- #: (String id) -> Array[Group]
- def groups_of(id)
- load_groups(
- labeled_groups
- .join(:group_members, group_id: :id)
- .where(card_id: id)
- .order(Sequel[:groups][:id])
- .all,
- )
+ #: (String id, ?listed_members: bool) -> Array[Group]
+ def groups_of(id, listed_members: false)
+ rows = labeled_groups
+ .join(:group_members, group_id: :id)
+ .where(card_id: id)
+ .order(Sequel[:groups][:id])
+ .all
+ return load_groups(rows) unless listed_members
+
+ members = group_members.where(group_id: rows.map { it.fetch(:id) }).select_map(:card_id).map(&:to_s).uniq
+ load_groups(rows, listing: listed_contacts(members))
end
# The cards one user's client syncs: every member of `sync:*` and of
@@ -661,10 +663,12 @@ module ProTacts
end
# Every group, ordered by id — the order #groups_of and the
- # composition both use, so a rename never moves one.
- #: () -> Array[Group]
- def all_groups
- load_groups(labeled_groups.order(:id).all)
+ # composition both use, so a rename never moves one. A `listing`
+ # the caller already read lists each group's members out of it,
+ # since `sync:*` would otherwise read the whole book again.
+ #: (?listing: Array[ListedContact]?) -> Array[Group]
+ def all_groups(listing: nil)
+ load_groups(labeled_groups.order(:id).all, listing:)
end
# Every group as a picker needs it: the row, its label and how many
@@ -1124,24 +1128,29 @@ module ProTacts
# Groups rows read whole: the lines and members of every group in
# `rows`, and which have a photo, three reads however many groups
- # there are. The photo read takes ids, never bytes.
- #: (Array[Hash[Symbol, untyped]] rows) -> Array[Group]
- def load_groups(rows)
+ # there are. The photo read takes ids, never bytes. A `listing` is
+ # sorted once here rather than by every tag that shows a group.
+ #: (Array[Hash[Symbol, untyped]] rows, ?listing: Array[ListedContact]?) -> Array[Group]
+ def load_groups(rows, listing: nil)
ids = rows.map { it.fetch(:id).to_s }
lines = group_properties.where(group_id: ids).order(:group_id, :position).all
.group_by { it.fetch(:group_id).to_s }
members = group_members.where(group_id: ids).order(:card_id).all
.group_by { it.fetch(:group_id).to_s }
photos = group_photos.where(group_id: ids).select_map(:group_id).map(&:to_s)
+ sorted = listing&.sort
rows.map { |row|
id = row.fetch(:id).to_s
+ member_ids = members.fetch(id, []).map { it.fetch(:card_id).to_s }
+ joined = member_ids.to_set
Group.new(
id:,
name: row.fetch(:name)&.to_s,
label: row.fetch(:label).to_s,
lines: lines.fetch(id, []).map { it.fetch(:line).to_s },
- members: members.fetch(id, []).map { it.fetch(:card_id).to_s },
+ members: member_ids,
photo: photos.include?(id),
+ listed_members: sorted&.select { joined.include?(it.id) },
)
}
end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 574bd99..46dbaf1 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -24,7 +24,8 @@ module ProTacts
# contacts (docs/DESIGN.md, "The core idea").
r.get do
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ContactsIndex.call(contacts: store.listed_contacts, groups: store.all_groups, login: @login)
+ contacts = store.listed_contacts
+ Admin::ContactsIndex.call(contacts:, groups: store.all_groups(listing: contacts), login: @login)
end
# The browser's create, from the dashboard's dialog: POST is
@@ -295,7 +296,7 @@ module ProTacts
Admin::ContactsShow.call(
contact:,
login: @login,
- groups: store.groups_of(contact.id),
+ groups: store.groups_of(contact.id, listed_members: true),
all_groups: store.group_choices,
changes: store.changes_of(contact.id),
notice:,
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 2872db6..8d5f38f 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -152,11 +152,11 @@ module ProTacts
#: (VCard card, Integer index, Integer losses) -> Import::Staged::Row
def import_row(card, index, losses)
contact = import_contact(card, index)
- Import::Staged::Row.new(label: Admin::Format.name_label(contact), sort_key: Admin::Format.sort_key(contact), losses:)
+ Import::Staged::Row.new(label: Admin::Format.name_label(contact), sort_key: contact.sort_key, losses:)
end
# The rows' places in the file, in the order the list shows them:
- # the contacts page's (Format.sort_key), so a file reads the way
+ # the contacts page's (Contact#sort_key), so a file reads the way
# the book it is joining does. The place in the file breaks a tie
# where the book's id would, as a number: a row's id is that place
# spelled as a string, and "10" sorts before "2". A row is still
@@ -215,7 +215,7 @@ module ProTacts
Admin::ImportSaved.call(
contact:,
login: @login,
- groups: store.groups_of(contact.id),
+ groups: store.groups_of(contact.id, listed_members: true),
all_groups: store.group_choices,
aside: Admin::ImportOriginal.new(card: original, dropped:),
sidebar:,
diff --git a/public/admin.css b/public/admin.css
index a0e0bd2..d35c978 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -964,6 +964,55 @@ section > h2 {
position: relative;
}
+/* Gloss's card clips, which would cut off a group tag's member list
+ (Admin::GroupTag). The clip kept a row's hover fill in the card's
+ corners, so the end rows take the corners themselves. */
+.card:has([role="tooltip"]) {
+ overflow: visible;
+}
+.card:has([role="tooltip"]) > li:first-child {
+ border-start-start-radius: inherit;
+ border-start-end-radius: inherit;
+}
+.card:has([role="tooltip"]) > li:last-child {
+ border-end-start-radius: inherit;
+ border-end-end-radius: inherit;
+}
+/* Gloss centers a tooltip, which runs a list wider than its tag past
+ the edge the tag sits near: a set's tag starts the list with it, a
+ lent row's tag, at the column's end, ends it with it. */
+.tag-set [role="tooltip"] {
+ left: 0;
+ transform: none;
+}
+.detail-grid dd > span > [role="tooltip"] {
+ left: auto;
+ right: 0;
+ transform: none;
+}
+ol[role="tooltip"] {
+ list-style: none;
+}
+/* base.css's prose gap between list items. */
+ol[role="tooltip"] > li {
+ margin: 0;
+}
+/* On a phone a late tag sits too near the edge for a name after it,
+ so the set holds the list, which wraps inside it. */
+@media (max-width: 48rem) {
+ .tag-set {
+ position: relative;
+ }
+ .tag-set > span:has(> [role="tooltip"]) {
+ position: static;
+ }
+ .tag-set [role="tooltip"] {
+ width: max-content;
+ max-width: 100%;
+ white-space: normal;
+ }
+}
+
/* The members screen (Admin::GroupsMembers). The form owns the
picker's Alpine state and so wraps both cards — the record's own,
holding the filter and the footer, and the list under it —
@@ -1116,12 +1165,13 @@ span.avatar[data-size="lg"] {
them one flex item, and no source-order inversion — the value is
still read before the group that lends it.
- 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 contact
+ Baseline-aligned: a grid item blockifies, so the span the tag's
+ tooltip hangs from (Admin::GroupTag), inline-flex in Gloss, becomes
+ a flex container, which takes its baseline from its first item —
+ the tag, and so the tag's text — and shares the value's. A contact
card's "edit / new" take the same place as Gloss' button group,
a flex container like the tag. */
-.detail-grid dd:has(> .tag),
+.detail-grid dd:has(> span > .tag),
.detail-grid dd:has(> button),
.detail-grid dd:has(> [role="group"]) {
display: grid;
@@ -1129,13 +1179,13 @@ span.avatar[data-size="lg"] {
align-items: baseline;
column-gap: var(--gl-space-s);
}
-.detail-grid dd > .tag,
+.detail-grid dd > span:has(> .tag),
.detail-grid dd > button,
.detail-grid dd > [role="group"] {
grid-column: 2;
grid-row: 1;
}
-.detail-grid dd:has(> .tag) > div,
+.detail-grid dd:has(> span > .tag) > div,
.detail-grid dd:has(> button) > div,
.detail-grid dd:has(> [role="group"]) > div:not([role="group"]) {
grid-column: 1;
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 3254979..9f3199c 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -86,11 +86,9 @@ module ProTacts
def self.call: (contact: Contact, login: String, groups: Array[Group], all_groups: Array[Store::GroupChoice], aside: untyped, sidebar: untyped, edit: String, land: String, ?notice: String?) -> String
end
- # Its sort_key and name_label are the import's list rows'
- # (Web#import_row), ordered the way the listings of contacts are, and
+ # Its name_label is the import's list rows' (Web#import_row), and
# its search_key is the fold Search matches on.
module Format
- def self.sort_key: (Contact | ListedContact contact) -> String
def self.name_label: (Contact | ListedContact contact) -> String
def self.search_key: (String value) -> String
end
diff --git a/sig/pro_tacts/group.rbs b/sig/pro_tacts/group.rbs
index 7c5ebc1..8cb6d8a 100644
--- a/sig/pro_tacts/group.rbs
+++ b/sig/pro_tacts/group.rbs
@@ -10,7 +10,8 @@ module ProTacts
attr_reader lines: Array[String]
attr_reader members: Array[String]
attr_reader photo: bool
+ attr_reader listed_members: Array[ListedContact]?
- def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool) -> instance
+ def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String], ?photo: bool, ?listed_members: Array[ListedContact]?) -> instance
end
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 3915856..9acf827 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -186,7 +186,8 @@ class AdminContactsPagesTest < Minitest::Test
get "/contacts"
- assert_includes last_response.body, %(<a href="/groups/#{group_id}" class="tag">Booles</a>)
+ assert_includes last_response.body,
+ %(<a href="/groups/#{group_id}" class="tag">Booles</a><ol role="tooltip"><li>Ada Lovelace</li></ol>)
end
end
@@ -604,7 +605,7 @@ class AdminContactsPagesTest < Minitest::Test
id = FixtureData.seed_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
get "/contacts/ada"
- marks = last_response.body.scan(%(<a href="/groups/#{id}" class="tag">Booles</a></dd>))
+ marks = last_response.body.scan(%(<a href="/groups/#{id}" class="tag">Booles</a><ol role="tooltip"><li>Ada Lovelace</li></ol></span></dd>))
assert_equal 2, marks.size
assert_includes last_response.body, "7 Calculus Close"
@@ -642,12 +643,29 @@ class AdminContactsPagesTest < Minitest::Test
id = FixtureData.seed_group(store, members: ["ada"], lines: HOUSEHOLD)
get "/contacts/ada"
- marks = last_response.body.scan(%r{<a href="/groups/#{id}" class="tag">((?:(?!</a>).)*)</a></dd>})
+ marks = last_response.body.scan(%r{<a href="/groups/#{id}" class="tag">((?:(?!</a>).)*)</a><ol role="tooltip"><li>[^<]*</li></ol></span></dd>})
assert_equal [[%(<span data-nameless>#{id}</span>)]] * 2, marks
end
end
+ # In the group page's order, by family name.
+ def test_a_tag_names_the_first_five_members_and_counts_the_rest
+ families = %w[Grey Abel Fox Cole Eddy Baker Drew]
+ cards = families.to_h {
+ [it.downcase, "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Sam #{it}\r\nN:#{it};Sam;;;\r\nUID:#{it.downcase}\r\nEND:VCARD\r\n"]
+ }
+
+ with_contacts(cards) do |store|
+ FixtureData.seed_group(store, name: "Club", members: cards.keys)
+
+ get "/contacts/grey"
+ names = last_response.body[%r{<ol role="tooltip">(.*?)</ol>}, 1].to_s.scan(%r{<li>(.*?)</li>}).flatten
+
+ assert_equal ["Sam Abel", "Sam Baker", "Sam Cole", "Sam Drew", "Sam Eddy", "and 2 more"], names
+ end
+ end
+
# A contact in no group is marked nowhere: the tag is provenance,
# not decoration, so a card with nothing lent renders none.
def test_show_marks_nothing_on_a_contact_in_no_group
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 13dbb1c..c3dda56 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -155,7 +155,7 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
- # The members list the way /contacts does (Format.sort_key), not in
+ # The members list the way /contacts does (Contact#<=>), not in
# the store's id order. Seeded so the two disagree. The scan takes
# the list's own rows — an li's link, not the change log's, which
# names the same cards for other reasons — and only from the
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index afc4eda..81be911 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -190,7 +190,7 @@ class AdminImportPagesTest < Minitest::Test
end
end
- # The list reads the way the contacts page does (Format.sort_key),
+ # The list reads the way the contacts page does (Contact#sort_key),
# not in the file's order, and the walk goes down it as it reads:
# the upload opens on the first row, and a Save steps to the next.
# The row keeps its place in the file as its name.
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 2dca832..5ec0610 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1372,6 +1372,17 @@ class StoreTest < Minitest::Test
end
end
+ def test_a_group_read_with_its_members_lists_them_in_order
+ with_store({"znorth" => ZED, "aiden" => AIDEN}) do |store|
+ FixtureData.seed_group(store, name: "Booles", members: ["znorth", "aiden"])
+
+ assert_equal [%w[aiden znorth]], store.groups_of("znorth", listed_members: true).map { it.listed_members.to_a.map(&:id) }
+ assert_equal [%w[aiden znorth]], store.all_groups(listing: store.listed_contacts).map { it.listed_members.to_a.map(&:id) }
+ assert_nil store.groups_of("znorth").fetch(0).listed_members
+ assert_nil store.all_groups.fetch(0).listed_members
+ end
+ end
+
def test_a_group_inspects_to_its_label_and_member_count
with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
id = FixtureData.seed_group(store, name: "Household", members: %w[znorth aiden])