admin: show each contact's groups on /contacts
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 3ec5f00..28e8925 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -2,6 +2,7 @@ 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"
@@ -12,13 +13,22 @@ module ProTacts
# "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).
+ # 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]) -> void
- def initialize(contacts:)
+ #: (contacts: Array[Contact], groups: Array[Store::Group]) -> void
+ def initialize(contacts:, groups:)
@rows = contacts.sort_by { [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.
+ # all_groups arrives ordered by id, so the inversion keeps the
+ # order a rename must not move a tag in.
+ @groups_of = {}
+ groups.each { |group| group.members.each { (@groups_of[it] ||= []) << group } }
end
def view_template
@@ -55,7 +65,18 @@ module ProTacts
render ListItem.new(
href: "/contacts/#{contact.id}",
avatar: Avatar.new(contact:, size: "lg"),
- ) { Format.name_label(contact) }
+ ) do
+ div { Format.name_label(contact) }
+ groups = @groups_of[contact.id]
+ if groups
+ # Spans rather than the record page's linked tags, because
+ # the row is a link and an anchor cannot hold one; the
+ # weight reset keeps the chips from inheriting the name's.
+ div(class: "tag-set", style: "margin-top: var(--gl-space-2xs); font-weight: 400;") do
+ groups.each { |group| span(class: "tag") { render GroupLabel.new(group:) } }
+ end
+ end
+ end
end
end
end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 24a3b15..5bda5db 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -23,7 +23,7 @@ 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.contacts)
+ Admin::ContactsIndex.call(contacts: store.contacts, groups: store.all_groups)
end
# The browser's create, from the dashboard's dialog: POST is
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 8c21362..df50045 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -19,7 +19,7 @@ module ProTacts
end
class ContactsIndex
- def self.call: (contacts: Array[Contact]) -> String
+ def self.call: (contacts: Array[Contact], groups: Array[Store::Group]) -> String
end
class GroupsIndex
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index d19a717..c5afa6c 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -124,6 +124,25 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # The row says what the contact belongs to, as chips under the name
+ # — spans rather than the record page's linked tags, because the row
+ # is already a link and an anchor cannot hold one.
+ def test_contacts_chips_each_row_with_its_groups
+ with_contacts({"ada" => ADA}) do |store|
+ FixtureData.seed_group(store, name: "Booles", members: ["ada"])
+
+ get "/contacts"
+
+ assert_includes last_response.body, %(<span class="tag">Booles</span>)
+ end
+ end
+
+ def test_contacts_rows_without_groups_carry_no_chips
+ with_contacts({"ada" => ADA}) { get "/contacts" }
+
+ refute_includes last_response.body, %(class="tag-set")
+ end
+
def test_contacts_lists_a_nameless_card_under_its_id
nameless = "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:nameless\r\nEND:VCARD\r\n"