admin: show the nickname on the contact detail page
A labeled grid row leading the channels, rather than a second name in
the header — the grid is where attributes live, and the header stays
the name and the avatar alone.

Assisted-by: GLM-5.3 via pi
change vtzrqzoqxkutlwyqqxtssnmnooznlllr
commit 8526c511175cd9e940c78dc53f6d5208f9b14d30
author Alpha Chen <alpha@kejadlen.dev>
date
parent pktxrttm
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index dfa1ae5..540a325 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -50,13 +50,17 @@ module ProTacts
 
       #: () -> bool
       def has_data?
-        @contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
+        !@contact.nickname.nil? ||
+          @contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
           !@birthday.nil? || !@contact.notes.nil?
       end
 
       # A missing TYPE parameter still gets a key: the fallback names
       # the kind of value, so no row renders unlabeled in the grid.
       def rows
+        # The nickname leads the grid: closest to the name the header
+        # already shows, ahead of the channels that reach the person.
+        row("nickname", @contact.nickname) if @contact.nickname
         @contact.phones.each { |phone| row(phone.type || "phone", phone.value) }
         @contact.emails.each { |email| row(email.type || "email", email.value) }
         @contact.addresses.each { |address| row(address.type || "address", address_lines(address)) }
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index e6b423b..a08e0ea 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -250,6 +250,7 @@ class AdminContactsPagesTest < Minitest::Test
       get "/contacts/bare"
 
       assert_equal 200, last_response.status
+      refute_includes last_response.body, '<dt class="type-label">nickname</dt>'
       refute_includes last_response.body, '<dt class="type-label">notes</dt>'
       refute_includes last_response.body, '<dt class="type-label">birthday</dt>'
     end
@@ -282,6 +283,23 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The nickname is an attribute like the others here — a labeled
+  # grid row, leading the channels — not a second name in the header,
+  # which stays the name and the avatar alone.
+  def test_show_renders_the_nickname_as_a_row
+    red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
+
+    with_contacts({"red" => red}) do
+      get "/contacts/red"
+
+      assert_includes last_response.body, '<dt class="type-label">nickname</dt>'
+      assert_includes last_response.body, "Red"
+      # The row leads the grid, ahead of the first phone.
+      body = last_response.body
+      assert body.index('">nickname</dt>') < body.index('">mobile</dt>')
+    end
+  end
+
   def test_show_of_an_unknown_contact_is_404
     with_contacts({}) { get "/contacts/nope" }