admin: a contact's card shows the groups it is in
The card said what a group lent but never said whose crew;
the tag row leads the grid now, and the nameless ones show through.
The household lost a word — plain "Booles" is what it answers to;
one helper spells the label, so a tag and badge stay true.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index a78a227..f144bda 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -24,9 +24,15 @@ module ProTacts
ELIDE_ABOVE = 120 #: Integer
private_constant :ELIDE_ABOVE
- #: (Contact contact) -> void
- def initialize(contact:)
+ # The groups come from the store beside the contact rather than
+ # off it: a contact's card carries what its groups lend it, never
+ # which groups those are (Store#groups_of). Required, and not
+ # defaulted to none, so a caller cannot render a contact as
+ # belonging to nothing by forgetting to ask.
+ #: (Contact contact, Array[String] groups) -> void
+ def initialize(contact:, groups:)
@contact = contact
+ @groups = groups
@birthday = Format.birthday(contact)
end
@@ -93,7 +99,7 @@ module ProTacts
#: () -> bool
def has_data?
@contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
- !@birthday.nil? || @contact.notes.any?
+ !@birthday.nil? || @contact.notes.any? || @groups.any?
end
# A missing TYPE parameter still gets a key: the fallback names
@@ -104,6 +110,7 @@ module ProTacts
# and a group holds only addresses and notes anyway (see
# db/migrations/004_groups.rb).
def rows
+ groups_row if @groups.any?
@contact.phones.each do |phone|
row(phone.type || "phone", phone.value, phone.line)
end
@@ -119,6 +126,27 @@ module ProTacts
end
end
+ # Membership as its own row, above the card's own: what a contact
+ # belongs to frames the values under it, and several of those
+ # values are the group's rather than the contact's. Gloss' Tag
+ # rather than the Badge the inherited rows carry — a badge marks
+ # the row it sits on, and these are the row's whole value
+ # (docs/DESIGN.md, "Relationships are navigable").
+ #
+ # Tags, not links, for now: the design has every tag opening the
+ # record it names, and a group has no screen to open yet. They
+ # become links with the group card, which is also what gives a
+ # nameless group somewhere for its id to lead.
+ #: () -> void
+ def groups_row
+ dt(class: "type-label") { "groups" }
+ dd(class: "type-body-sm") do
+ div(class: "tag-set") do
+ @groups.each { |group| span(class: "tag") { group } }
+ end
+ end
+ end
+
# The type in one column and the value in the other, with the
# group that lends the line named under the value when one does.
# In the value cell rather than beside the type label: the type
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 8e7a514..5a2cb12 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -222,6 +222,24 @@ module ProTacts
nil
end
+ # The groups a contact belongs to, by the label each is shown
+ # under, ordered by group id — a rename must not move a tag, the
+ # same reason #inherited_rows orders by it. Membership is the whole
+ # answer here, so a group that lends nothing appears too, where the
+ # inherited reads have nothing of it to carry. Off the contact
+ # 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[String]
+ def groups_of(id)
+ group_members
+ .join(:groups, id: Sequel[:group_members][:group_id])
+ .where(card_id: id)
+ .order(Sequel[:groups][:id])
+ .select(group_label.as(:label))
+ .map { it.fetch(:label).to_s }
+ end
+
# The id of the card whose UID property holds this value, if one
# does — the read behind the no-uid-conflict precondition (RFC 6352
# section 6.3.2.1). It reads the index, so a card whose UID line
@@ -640,6 +658,15 @@ module ProTacts
.transform_values { |rows| rows.map { inherited_from(it) } }
end
+ # What to call a group on a screen: its name, or its id where it
+ # has none (db/migrations/005_group_identity.rb). In SQL rather
+ # than in Ruby so that both reads of it — a tag and an inherited
+ # row's mark — are the one answer.
+ #: () -> untyped
+ def group_label
+ Sequel.function(:coalesce, Sequel[:groups][:name], Sequel[:groups][:id])
+ end
+
# The join both inherited reads walk: a membership to the property
# it inherits and to the group's own row for its name, qualified
# and ordered so the composition neither depends on what SQLite
@@ -660,7 +687,7 @@ module ProTacts
.join(:groups, id: Sequel[:group_members][:group_id])
.select(
Sequel[:group_members][:card_id],
- Sequel.function(:coalesce, Sequel[:groups][:name], Sequel[:groups][:id]).as(:group_name),
+ group_label.as(:group_name),
Sequel[:group_properties][:line],
)
.order(
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index a2c226d..17bb878 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -183,7 +183,7 @@ module ProTacts
if contact
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ContactsShow.call(contact:)
+ Admin::ContactsShow.call(contact:, groups: store.groups_of(id))
end
end
end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 9dce50b..3371e50 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -7,7 +7,7 @@
module ProTacts
module Admin
class ContactsShow
- def self.call: (contact: Contact) -> String
+ def self.call: (contact: Contact, groups: Array[String]) -> String
end
class ContactsEdit
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 15c0c49..a894dab 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -86,7 +86,7 @@ module FixtureData
def self.seed_groups(store)
database = store.instance_variable_get(:@database)
- household = store.create_group(name: "Boole household")
+ household = store.create_group(name: "Booles")
HOUSEHOLD.each.with_index do |line, position|
database[:group_properties].insert(group_id: household, position:, line:)
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7a59b71..b51cc88 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -318,10 +318,10 @@ class AdminContactsPagesTest < Minitest::Test
# marked.
def test_show_marks_the_rows_a_group_lends
with_contacts({"ada" => ADA}) do |store|
- add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+ add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
get "/contacts/ada"
- marks = last_response.body.scan('<span class="badge">Boole household</span>')
+ marks = last_response.body.scan('<span class="badge">Booles</span>')
assert_equal 2, marks.size
assert_includes last_response.body, "7 Calculus Close"
@@ -330,6 +330,34 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # The card says what the contact belongs to, as tags: the group
+ # lending it two rows and the group lending it nothing both appear,
+ # because the row is membership rather than what came of it.
+ def test_show_tags_the_groups_a_contact_belongs_to
+ with_contacts({"ada" => ADA}) do |store|
+ named = add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
+ nameless = add_group(store, members: ["ada"], lines: [])
+
+ get "/contacts/ada"
+ tags = last_response.body.scan(%r{<span class="tag">([^<]*)</span>}).flatten
+
+ assert_equal({named => "Booles", nameless => nameless}.sort.map { it.last }, tags)
+ # The grid leads with what the contact belongs to, so the values
+ # under it are read already knowing whose they might be.
+ assert_equal "groups", last_response.body[%r{<dt class="type-label">([^<]*)<}, 1]
+ end
+ end
+
+ # No membership, no row: an empty tag set would say a contact
+ # belongs to nothing in the space where belonging is shown.
+ def test_show_omits_the_groups_row_for_a_contact_in_none
+ with_contacts({"ada" => ADA}) do
+ get "/contacts/ada"
+
+ refute_includes last_response.body, "groups</dt>"
+ end
+ end
+
# A group with no name marks its rows with its id, which is what the
# mark is for: an empty badge would say a row came from somewhere and
# then not say where.
@@ -714,7 +742,7 @@ class AdminContactsPagesTest < Minitest::Test
# page is where an inherited row is read.
def test_the_edit_screen_omits_the_rows_a_group_lends
with_contacts({"ada" => ADA}) do |store|
- add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+ add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
get "/contacts/ada/edit"
@@ -731,7 +759,7 @@ class AdminContactsPagesTest < Minitest::Test
noteless = ADA.sub("NOTE:Countess of Lovelace.\r\n", "")
with_contacts({"ada" => noteless}) do |store|
- add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+ add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
get "/contacts/ada/edit"
@@ -745,7 +773,7 @@ class AdminContactsPagesTest < Minitest::Test
# is not the screen — matches nothing and writes nothing.
def test_a_save_naming_a_lent_line_touches_nothing
with_contacts({"ada" => ADA}) do |store|
- add_group(store, name: "Boole household", members: ["ada"], lines: HOUSEHOLD)
+ add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
contact = store.contact("ada")
lent = contact.addresses.find { contact.group_of(it.line) }
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index e1f8894..983379a 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -27,7 +27,7 @@ class ContactTest < Minitest::Test
# Inherited lines are given as bare strings and lent by GROUP, the
# name a composed row is marked with; a test that needs a second
# lender passes its own.
- GROUP = "Boole household" #: String
+ GROUP = "Booles" #: String
def contact(vcard = CARD, id: "aiden", birthday: nil, inherited: [], group: GROUP)
lent = inherited.map { ProTacts::Contact::Inherited.new(group:, line: it) }
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 4283fad..6a6930d 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1078,6 +1078,23 @@ class StoreTest < Minitest::Test
end
end
+ # Membership is a read of its own: every group a contact is in,
+ # under the label a screen shows it by — the name, or the id where
+ # there is no name — ordered by id so a rename never moves a tag. A
+ # group that lends nothing is a membership like any other; it is the
+ # inherited reads, not this one, that have nothing of it to carry.
+ def test_the_groups_a_contact_is_in_are_read_with_their_labels
+ with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+ named = add_group(store, name: "Booles", members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ nameless = add_group(store, members: ["aiden"], lines: [])
+ add_group(store, name: "Neighbours", members: [], lines: [HOUSEHOLD_NOTE])
+
+ expected = {named => "Booles", nameless => nameless}.sort.map { it.last }
+ assert_equal expected, store.groups_of("aiden")
+ assert_empty store.groups_of("znorth")
+ end
+ end
+
# The schema is the only gate on what a group may hold until
# authoring exists, so what it admits is worth pinning: an address
# or a note, bare or parameterized, in whatever case the author
@@ -1194,7 +1211,7 @@ class StoreTest < Minitest::Test
Sequel.connect("sqlite://#{path}") do |db|
Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 4)
db[:cards].insert(id: "aiden", vcard: AIDEN)
- db[:groups].insert(id: "nous", name: "Boole household")
+ db[:groups].insert(id: "nous", name: "Booles")
db[:group_properties].insert(group_id: "nous", position: 0, line: HOUSEHOLD_ADDRESS)
db[:group_members].insert(group_id: "nous", card_id: "aiden")
end
@@ -1202,7 +1219,7 @@ class StoreTest < Minitest::Test
ProTacts::Store.connect(path) do |store|
contact = store.contact("aiden")
assert_includes contact.vcard.to_s, HOUSEHOLD_ADDRESS
- assert_equal "Boole household", contact.group_of(contact.addresses.fetch(0).line)
+ assert_equal "Booles", contact.group_of(contact.addresses.fetch(0).line)
database(store)[:cards].where(id: "aiden").delete
assert_empty database(store)[:group_members].all