groups: the listing leads with the sync groups, then reads alphabetically
/groups came out in id order, which is minting order — nothing a
reader can scan. The rows now sort by the label they show, with the
`sync:` groups first, since those are the books a client syncs rather
than anything a person made.
Sorted in the view the way ContactsIndex sorts its rows: Store#all_groups
stays ordered by id, because that order is what keeps a rename from
moving a contact's tags, and the dump, the API and the group dialogs all
read it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUpdgWhLPyBKX4GJwYQTGV
diff --git a/lib/pro_tacts/admin/groups_index.rb b/lib/pro_tacts/admin/groups_index.rb
index fe080d6..faa099f 100644
--- a/lib/pro_tacts/admin/groups_index.rb
+++ b/lib/pro_tacts/admin/groups_index.rb
@@ -19,7 +19,16 @@ module ProTacts
#: (groups: Array[Store::Group], ?notice: String?) -> void
def initialize(groups:, notice: nil)
- @groups = groups
+ # The `sync:` groups first — the books a client syncs, which
+ # are the store's own rather than anything a person made — and
+ # the rest alphabetically. Sorted here rather than asked of the
+ # store the way ContactsIndex sorts its rows: #all_groups is
+ # ordered by id so that a rename never moves a tag, and only
+ # this listing wants the reader's order. The key is the label
+ # the row shows (GroupLabel), so a nameless group sorts by the
+ # id it is shown by, and the id breaks a tie between two groups
+ # labelled the same.
+ @groups = groups.sort_by { [it.sync? ? 0 : 1, it.label.downcase, it.id] }
@notice = notice
end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index f17220d..5257991 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -112,6 +112,16 @@ module ProTacts
Contact.new(id:, stored: VCard.new(lines.map { "#{it}\r\n" }.join), birthday: nil, inherited: [])
end
+ # Whether this is one of the `sync:` groups that choose what a
+ # client syncs — #sync_name?'s rule, said of a group a screen
+ # already holds rather than of a row's name, for the listing that
+ # sets those groups apart from the ones a person made
+ # (Admin::GroupsIndex).
+ #: () -> bool
+ def sync?
+ name&.start_with?(SYNC_PREFIX) == true
+ end
+
# The group's etag, for the editor's snapshot guard: a hash of
# everything the edit screen shows and the save writes, so a
# group edited since the page loaded refuses the stale save
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 2296daf..1e93bbe 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -51,6 +51,20 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
+ # The `sync:` groups are the books a client syncs, so they lead the
+ # list; the rest read alphabetically, whatever their case.
+ def test_the_list_leads_with_the_sync_groups_and_then_reads_alphabetically
+ with_contacts({}) do |store|
+ %w[zoetrope Abacus sync:alpha barometer sync:*].each { store.create_group(name: it) }
+ ids = store.all_groups.to_h { [it.name, it.id] }
+
+ get "/groups"
+
+ assert_equal %w[sync:* sync:alpha Abacus barometer zoetrope].map { ids.fetch(it) },
+ last_response.body.scan(%r{<a href="/groups/([^"]+)">}).flatten
+ end
+ end
+
def test_an_empty_list_says_so
with_contacts({}) do
get "/groups"