admin: show each contact's groups on /contacts
Assisted-by: GLM-5.3 via pi
change qkypoxxmqmzvlyoqtlpkuorntwqrnuou
commit 8a9b9782ae76ba6b6edd61fd5ce67bbe60735497
author Alpha Chen <alpha@kejadlen.dev>
date
parent kllwpukr
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"