admin: edit a contact's groups from its card
Edit groups opens a checklist with the contact's groups ticked;
the save moves only what you toggled, so no other tab's change gets nicked.

Assisted-by: Claude Opus 5 via Claude Code
change xqqxxwmmszwzrywlrptnuyprmooxlqtv
commit 51497e616dce0781f8ea19414e416b3f0d43910b
author Alpha Chen <alpha@kejadlen.dev>
date
parent ovwnvqyp
diff --git a/Steepfile b/Steepfile
index f062c0e..d1652cd 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ target :lib do
   # the files stay checked.
   ignore(*%w[
     avatar contact_dialog contacts_edit contacts_index contacts_show device_setup format
-    group_label groups_edit groups_index groups_show icons layout phlex upcoming_birthdays
+    group_dialog group_label groups_edit groups_index groups_show icons layout phlex upcoming_birthdays
   ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
   signature "sig"
 
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 8ac09c4..2167e6e 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.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_dialog"
 require "pro_tacts/admin/group_label"
 require "pro_tacts/admin/layout"
 
@@ -33,11 +34,12 @@ module ProTacts
       # beside it for the same reason and required for the stronger
       # one: a card with no entries is a card whose history was lost,
       # and an empty default would render that as an ordinary quiet
-      # record.
-      #: (Contact contact, Array[Store::Group] groups, Array[Store::Change] changes) -> void
-      def initialize(contact:, groups:, changes:)
+      # record. Every group is the groups dialog's (GroupDialog).
+      #: (Contact contact, Array[Store::Group] groups, Array[Store::Group] all_groups, Array[Store::Change] changes) -> void
+      def initialize(contact:, groups:, all_groups:, changes:)
         @contact = contact
         @groups = groups
+        @all_groups = all_groups
         @changes = changes
         @birthday = Format.birthday(contact)
       end
@@ -112,6 +114,8 @@ module ProTacts
               end
             end
           end
+          # No row, so outside the record's grid.
+          render GroupDialog.new(contact: @contact, groups: @all_groups) if @all_groups.any?
         end
       end
 
@@ -120,7 +124,7 @@ module ProTacts
       #: () -> bool
       def has_data?
         @contact.phones.any? || @contact.emails.any? || @contact.addresses.any? ||
-          !@birthday.nil? || @contact.notes.any? || @groups.any?
+          !@birthday.nil? || @contact.notes.any? || @all_groups.any?
       end
 
       # A missing TYPE parameter still gets a key: the fallback names
@@ -131,7 +135,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?
+        groups_row if @all_groups.any?
         @contact.phones.each do |phone|
           row(Format.type_label(phone.types, "phone"), phone.value, phone.line)
         end
@@ -152,14 +156,20 @@ module ProTacts
       # values are the group's rather than the contact's. Every tag opens
       # the group it names (docs/DESIGN.md, "Relationships are
       # navigable"), the other half of the member tags on a group's own
-      # card (Admin::GroupsShow).
+      # card (Admin::GroupsShow). Rendered for a contact in no group too
+      # while any group exists, because the row holds the way to join
+      # one — the group card's members row, for the same reason, with
+      # its "edit members" in the same place.
       #: () -> void
       def groups_row
         dt(class: "type-label") { "groups" }
         dd(class: "type-body-sm") do
-          div(class: "tag-set") do
-            @groups.each { group_tag(it) }
+          if @groups.any?
+            div(class: "tag-set") do
+              @groups.each { group_tag(it) }
+            end
           end
+          button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit groups" }
         end
       end
 
diff --git a/lib/pro_tacts/admin/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
new file mode 100644
index 0000000..afbc746
--- /dev/null
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -0,0 +1,61 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/group_label"
+
+module ProTacts
+  module Admin
+    # The groups dialog on a contact's card, opened by the groups row's
+    # "edit groups": ContactDialog's popover, over every group as a
+    # checkbox, the contact's own checked. The save sends what the page
+    # loaded with beside what it sends back (`was[]`), so the route can
+    # move only what was toggled (Web#apply_groups).
+    #
+    # Alpine only filters, matching each row's lowercased label in
+    # data-label so no group's name is spliced into script; a row it
+    # hides still submits its box. The filter sits outside the form,
+    # because Enter in a form's text box would save mid-filter.
+    class GroupDialog < Phlex::HTML
+      ID = "edit-groups" #: String
+      FORM = "edit-groups-form" #: String
+
+      STATE = "{ filter: '', " \
+              "shows(label) { return label.includes(this.filter.trim().toLowerCase()) }, " \
+              "get none() { return ![...this.$refs.options.querySelectorAll('[data-label]')]" \
+              ".some(row => this.shows(row.dataset.label)) } }" #: String
+
+      # Alphabetical rather than the id order a tag keeps: this is a list
+      # to find a name in, and nothing here stays put across a rename.
+      #: (contact: Contact, groups: Array[Store::Group]) -> void
+      def initialize(contact:, groups:)
+        @contact = contact
+        @groups = groups.sort_by { it.label.downcase }
+      end
+
+      def view_template
+        dialog(id: ID, popover: "auto", x_data: STATE) do
+          header { "Groups" }
+          div(class: "field-stack") do
+            input(type: "search", placeholder: "Filter groups", aria_label: "Filter groups",
+                  autofocus: true, x_model: "filter")
+            form(id: FORM, action: "/contacts/#{@contact.id}/groups", method: "post",
+                 class: "field-stack", x_ref: "options") do
+              @groups.each do |group|
+                joined = group.members.include?(@contact.id)
+                label(data: {label: group.label.downcase}, ":hidden": "!shows($el.dataset.label)") do
+                  input(type: "checkbox", name: "groups[]", value: group.id, checked: joined)
+                  render GroupLabel.new(group:)
+                end
+                input(type: "hidden", name: "was[]", value: group.id) if joined
+              end
+            end
+            template(x_if: "none") { p(class: "gl-muted") { "No groups match." } }
+          end
+          footer do
+            button(type: "button", popovertarget: ID, popovertargetaction: "hide") { "Cancel" }
+            button(type: "submit", form: FORM, data: {variant: "primary"}) { "Save" }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 46f5743..a4ecad4 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -670,6 +670,16 @@ module ProTacts
       end
     end
 
+    # A card's side of #edit_group: one transaction, leavers first and
+    # joiners last for that method's reason.
+    #: (String card_id, join: Array[String], leave: Array[String]) -> void
+    def regroup(card_id, join:, leave:)
+      @database.transaction do
+        leave.each { remove_member(it, card_id) }
+        join.each { add_member(it, card_id) }
+      end
+    end
+
     # The change log from a sequence number on, oldest first: the window
     # a sync-collection report answers from (RFC 6578 section 3.2) — the
     # entries after the client's token are the changes it has not seen.
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 06e1d42..918e8b4 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -63,6 +63,13 @@ module ProTacts
           end
         end
 
+        # Membership from the contact's side, the groups dialog's save
+        # (Admin::GroupDialog). Above the edit's POST, whose bare verb
+        # block would match this path too.
+        r.post "groups" do
+          apply_groups(r, id)
+        end
+
         r.post do
           apply_edit(r, id)
         end
@@ -88,11 +95,7 @@ module ProTacts
         r.get do
           contact = store.contact(id)
 
-          if contact
-            response["Content-Type"] = "text/html; charset=utf-8"
-            Admin::ContactsShow.call(contact:, groups: store.groups_of(id),
-                                     changes: store.changes_of(id))
-          end
+          contact_screen(contact) if contact
         end
       end
     end
@@ -171,6 +174,40 @@ module ProTacts
       r.redirect "/contacts/#{id}", 303
     end
 
+    # The dialog's save as what it toggled rather than the set it shows:
+    # `was` is the boxes checked when the page loaded, so a group joined
+    # or left elsewhere since stays as it is, and a stale page has
+    # nothing to revert and no snapshot to refuse. Only ids naming a
+    # group: Store#add_member reads the group with `sole`, so a doctored
+    # one would be a 500 rather than the bad input it is.
+    #: (untyped r, String id) -> String?
+    def apply_groups(r, id)
+      return if store.contact(id).nil?
+
+      known = store.all_groups.map(&:id)
+      checked = ids_in(r.params["groups"]) & known
+      was = ids_in(r.params["was"]) & known
+      store.regroup(id, join: checked - was, leave: was - checked)
+      r.redirect "/contacts/#{id}", 303
+    end
+
+    # A form's list of ids, and none for a param absent or not a list.
+    #: (untyped param) -> Array[String]
+    def ids_in(param)
+      param.is_a?(Array) ? param.map(&:to_s) : []
+    end
+
+    #: (Contact contact) -> String
+    def contact_screen(contact)
+      response["Content-Type"] = "text/html; charset=utf-8"
+      Admin::ContactsShow.call(
+        contact:,
+        groups: store.groups_of(contact.id),
+        all_groups: store.all_groups,
+        changes: store.changes_of(contact.id),
+      )
+    end
+
     #: (Contact contact, ?notice: String) -> String
     def edit_screen(contact, notice: nil)
       response["Content-Type"] = "text/html; charset=utf-8"
diff --git a/public/admin.css b/public/admin.css
index 251f92b..4e453a5 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -582,21 +582,25 @@ img.avatar {
    becomes a flex container, which takes its baseline from its first
    item — the tag's text — and so shares the value's. A group card's
    "edit members" takes the same place, and is plain text with a
-   baseline of its own. */
+   baseline of its own; a contact card's "edit groups" button takes
+   it too, a flex container like the tag. */
 .detail-grid dd:has(> .tag),
-.detail-grid dd:has(> a.type-label) {
+.detail-grid dd:has(> a.type-label),
+.detail-grid dd:has(> button) {
   display: grid;
   grid-template-columns: minmax(0, 1fr) auto;
   align-items: baseline;
   column-gap: var(--gl-space-s);
 }
 .detail-grid dd > .tag,
-.detail-grid dd > a.type-label {
+.detail-grid dd > a.type-label,
+.detail-grid dd > button {
   grid-column: 2;
   grid-row: 1;
 }
 .detail-grid dd:has(> .tag) > div,
-.detail-grid dd:has(> a.type-label) > div {
+.detail-grid dd:has(> a.type-label) > div,
+.detail-grid dd:has(> button) > div {
   grid-column: 1;
 }
 /* A nameless group shown by its id (Admin::GroupLabel): set in mono,
@@ -624,6 +628,21 @@ dialog > footer {
   padding-block: var(--gl-space-2xs);
 }
 
+/* The groups dialog (Admin::GroupDialog) is a list of one-line rows,
+   not the settings form Gloss spaces its stacks for, so it closes up
+   the way the record's form does (.card-body form.field-stack): the
+   rows to the scale's smallest step, the filter to the list at the
+   record's row gap, and the filter box shedding its block padding. */
+dialog .field-stack {
+  gap: var(--gl-space-2xs);
+}
+dialog form.field-stack {
+  gap: var(--gl-space-3xs);
+}
+dialog > .field-stack > input[type="search"] {
+  padding-block: var(--gl-space-3xs);
+}
+
 /* The stored card in its own collapsed card under the record — the
    column's gap spaces the two, so no margin here. <details> is native,
    keeping the admin UI script-free; the summary rides the label
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index b3c6836..8c283f5 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, groups: Array[Store::Group], changes: Array[Store::Change]) -> String
+      def self.call: (contact: Contact, groups: Array[Store::Group], all_groups: Array[Store::Group], changes: Array[Store::Change]) -> String
     end
 
     class ContactsEdit
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 3e8bfe3..233acae 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -319,9 +319,10 @@ class AdminContactsPagesTest < Minitest::Test
     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
+  # No membership and nothing to join, 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_with_no_group_to_show_or_join
     with_contacts({"ada" => ADA}) do
       get "/contacts/ada"
 
@@ -353,6 +354,90 @@ class AdminContactsPagesTest < Minitest::Test
     end
   end
 
+  # The row holds the way to join a group, so a contact in none still
+  # gets it while any group exists.
+  def test_show_offers_a_contact_in_no_group_its_groups_to_edit
+    with_contacts({"ada" => ADA}) do |store|
+      FixtureData.seed_group(store, name: "Booles")
+
+      get "/contacts/ada"
+
+      assert_includes last_response.body, %(<dt class="type-label">groups</dt>)
+      assert_includes last_response.body, %(popovertarget="edit-groups")
+    end
+  end
+
+  def test_the_groups_dialog_checks_the_groups_a_contact_is_in
+    with_contacts({"ada" => ADA}) do |store|
+      joined = FixtureData.seed_group(store, name: "Booles", members: ["ada"])
+      other = FixtureData.seed_group(store, name: "Babbages")
+
+      get "/contacts/ada"
+      dialog = last_response.body[%r{<dialog id="edit-groups".*?</dialog>}].to_s
+
+      assert_includes dialog, %(name="groups[]" value="#{joined}" checked>)
+      assert_includes dialog, %(name="groups[]" value="#{other}">)
+    end
+  end
+
+  # Joining is the membership and what comes of it: the card serves
+  # what the group lends, and the change log tells every client so.
+  def test_checking_a_group_adds_the_contact_to_it
+    with_contacts({"ada" => ADA}) do |store|
+      id = FixtureData.seed_group(store, name: "Booles", lines: HOUSEHOLD)
+
+      post "/contacts/ada/groups", groups: [id]
+
+      assert_equal 303, last_response.status
+      assert_equal "/contacts/ada", last_response["Location"]
+      assert_equal ["ada"], store.group(id).members
+      assert_includes store.contact("ada").vcard.to_s, "7 Calculus Close"
+      assert_equal "group", store.changes_of("ada").first.action
+    end
+  end
+
+  # Leaving twice is leaving once: the second finds nothing to remove
+  # and lands on the same page.
+  def test_unchecking_a_group_removes_the_contact_from_it
+    with_contacts({"ada" => ADA}) do |store|
+      id = FixtureData.seed_group(store, name: "Booles", members: ["ada"])
+
+      2.times do
+        post "/contacts/ada/groups", was: [id]
+
+        assert_equal 303, last_response.status
+        assert_equal "/contacts/ada", last_response["Location"]
+      end
+      assert_empty store.group(id).members
+    end
+  end
+
+  # A save moves only the boxes its page toggled: a group joined in
+  # another tab since stays joined, and an id naming no group is
+  # dropped rather than reaching a foreign key.
+  def test_a_save_moves_only_the_groups_it_toggled
+    with_contacts({"ada" => ADA}) do |store|
+      booles = FixtureData.seed_group(store, name: "Booles", members: ["ada"])
+      babbages = FixtureData.seed_group(store, name: "Babbages")
+      elsewhere = FixtureData.seed_group(store, name: "Clarks")
+      store.add_member(elsewhere, "ada")
+
+      post "/contacts/ada/groups", groups: [babbages, "zzzz"], was: [booles]
+
+      assert_equal [babbages, elsewhere].sort, store.groups_of("ada").map(&:id).sort
+    end
+  end
+
+  def test_a_save_for_an_unknown_contact_is_a_404
+    with_contacts({"ada" => ADA}) do |store|
+      id = FixtureData.seed_group(store, name: "Booles")
+
+      post "/contacts/nobody/groups", groups: [id]
+
+      assert_equal 404, last_response.status
+    end
+  end
+
   # A value with no TYPE parameter still gets a key — the fallback
   # names the kind of value, so every row in the grid is labeled.
   def test_show_labels_untyped_values_with_the_property_name
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 042ce58..21027ad 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1313,6 +1313,18 @@ class StoreTest < Minitest::Test
     end
   end
 
+  def test_a_regroup_leaves_and_joins
+    with_store({"aiden" => AIDEN}) do |store|
+      left = FixtureData.seed_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+      joined = FixtureData.seed_group(store, members: [])
+
+      store.regroup("aiden", join: [joined], leave: [left])
+
+      assert_equal [joined], store.groups_of("aiden").map(&:id)
+      assert_equal AIDEN, store.contact("aiden").vcard.to_s
+    end
+  end
+
   # Nothing a client downloads moved, so nothing is logged: a group
   # that lends nothing composes nothing into a new member, and a name
   # is on no card.