contact card: new opens the members screen, Save makes the group
"new" no longer makes a group on the press. It opens the members
screen of a group not yet made, the contact ticked; Cancel goes back to
the card and nothing exists, and Save is the create, POST /groups with
the ticked members, landing on the group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Ru2zvdWEwdwGhQ5V9z5X
change
commit de0d23fc1cfc780197dbd9f0afb48b18fa8181cf
author Claude <noreply@anthropic.com>
date
parent 4ff0ba67
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 6459580..c2df651 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -25,17 +25,12 @@ module ProTacts
     # carries its "edit" button, which needs GroupDialog on the
     # page: the contact's page and a walk's saved row both render it,
     # each telling the save where to land. `new` names whether it also
-    # carries "new", which needs the NEW form on the page: the
-    # contact's page alone, its save landing on the group it made
-    # rather than back on a walk. `card` names
+    # carries "new": the contact's page alone, its save landing on the
+    # group it made rather than back on a walk. `card` names
     # whether this renders the card around its own body or the body
     # alone for a card someone else supplies — the walk's read screen
     # (ImportSaved), whose trunk RecordCard owns.
     class ContactCard < Phlex::HTML
-      # The form "new" submits, outside the grid for the dialog's
-      # reason (ContactsShow).
-      NEW = "new-group" #: String
-
       # @rbs @contact: Contact
       # @rbs @groups: Array[Group]
       # @rbs @dialog: bool
@@ -148,9 +143,9 @@ module ProTacts
       # Without the dialog the row stands only when there is a member
       # to name: an empty row with nothing to press is nothing at all.
       #
-      # "edit" opens the dialog over the groups that exist; "new" makes
-      # a new nameless one holding this contact and opens its members
-      # screen, the household's start — a family needs no name to be
+      # "edit" opens the dialog over the groups that exist; "new" opens
+      # the members screen of a nameless one not yet made, this contact
+      # ticked, the household's start — a family needs no name to be
       # one, and the dialog's offer to make a group is a name typed.
       #: () -> void
       def groups_row
@@ -168,7 +163,7 @@ module ProTacts
             div(role: "group", aria_label: "Groups") do
               edit_button
               span(class: "gl-muted", aria_hidden: true) { "/" }
-              button(type: "submit", form: NEW, data_size: "sm") { "new" }
+              a(href: "/contacts/#{@contact.id}/groups/new", class: "btn", data_size: "sm") { "new" }
             end
           elsif @dialog
             edit_button
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 7e5c2bb..9b682ab 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -78,7 +78,6 @@ module ProTacts
           # so the dialog is told which boxes to check rather than
           # reading every group's members to work it out.
           render GroupDialog.new(contact: @contact, groups: @all_groups, joined: @groups.map(&:id))
-          form(id: ContactCard::NEW, action: "/contacts/#{@contact.id}/groups/new", method: "post")
         end
       end
 
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
index c4e2f66..b17eaaf 100644
--- a/lib/pro_tacts/admin/groups_members.rb
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -28,41 +28,53 @@ module ProTacts
     # rather than submitted, the import's own answer where there is
     # no outside to stand in (Admin::GroupFilter), whose filter, cap,
     # and show-all button these are, without its offer.
+    #
+    # The same screen picks a group's first members before the group
+    # exists: a contact card's "new" (Admin::ContactCard) opens it
+    # with that contact ticked, and its Save is the create (POST
+    # /groups), so a group someone backs out of is never made. With no
+    # group there is nothing toggled against, so no `was[]`, and the
+    # way back is the card it was opened from.
     class GroupsMembers < Phlex::HTML
-      # @rbs @group: Group
+      # @rbs @group: Group?
       # @rbs @login: String
       # @rbs @contacts: Array[ListedContact]
+      # @rbs @members: Array[String]
       # @rbs @capped: Array[String]
+      # @rbs @from: Contact?
 
       # The form's id, for the footer's Save outside it.
       FORM = "members-form" #: String
       private_constant :FORM
 
-      #: (group: Group, contacts: Array[ListedContact], login: String) -> void
-      def initialize(group:, contacts:, login:)
+      # `group` for a group that exists; `from` for one that does not
+      # yet, the contact it is being started from.
+      #: (contacts: Array[ListedContact], login: String, ?group: Group?, ?from: Contact?) -> void
+      def initialize(contacts:, login:, group: nil, from: nil)
         @group = group
+        @from = from
         @login = login
         # The order the show screen's list reads (Format.sort_key),
         # which is the order the book is browsed in everywhere else.
         @contacts = contacts.sort_by { [Format.sort_key(it), it.id] }
+        @members = group&.members || [from&.id].compact
         # The members are never capped (GroupFilter.capped).
-        @capped = GroupFilter.capped(@contacts.map(&:id), joined: group.members)
+        @capped = GroupFilter.capped(@contacts.map(&:id), joined: @members)
       end
 
       def view_template
-        render Layout.new(title: "Members of #{@group.label}", login: @login) do
-          form(action: "/groups/#{@group.id}/members", method: "post",
+        group = @group
+        title = group ? "Members of #{group.label}" : "New group"
+        render Layout.new(title:, login: @login) do
+          form(action: group ? "/groups/#{group.id}/members" : "/groups", method: "post",
                class: "member-picker", id: FORM, x_data: GroupFilter::STATE) do
-            render RecordCard.new(
-              back: ["/groups/#{@group.id}", GroupLabel.new(group: @group)],
-              submit: "Save", form: FORM,
-            ) do
+            render RecordCard.new(back:, submit: "Save", form: FORM) do
               render GroupFilter.new(autofocus: true, in_form: true, placeholder: "Filter contacts")
             end
             div(class: "member-list") do
               ul(class: "card", x_ref: "options") do
                 @contacts.each do |contact|
-                  member = @group.members.include?(contact.id)
+                  member = @members.include?(contact.id)
                   # The row's label is the name the lists show
                   # (Format.name_label), nickname and all, because
                   # that is the name a person filters by; the row
@@ -75,7 +87,7 @@ module ProTacts
                       render Avatar.new(contact:, size: "lg")
                       div(style: "flex: 1; min-width: 0; font-weight: 550;") { Format.name_label(contact) }
                     end
-                    input(type: "hidden", name: "was[]", value: contact.id) if member
+                    input(type: "hidden", name: "was[]", value: contact.id) if member && @group
                   end
                 end
               end
@@ -85,6 +97,19 @@ module ProTacts
           end
         end
       end
+
+      private
+
+      # The record the screen was opened from: the group's page, or
+      # the card a new group is being started from.
+      #: () -> [String, (String | Phlex::HTML)]
+      def back
+        group = @group
+        return ["/groups/#{group.id}", GroupLabel.new(group:)] if group
+
+        from = @from or raise ArgumentError, "a members screen needs a group or a contact"
+        ["/contacts/#{from.id}", from.name || from.id]
+      end
     end
   end
 end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index ec7fbe3..87166be 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -4,6 +4,7 @@ require "pro_tacts/admin/contacts_edit"
 require "pro_tacts/admin/contacts_index"
 require "pro_tacts/admin/contacts_show"
 require "pro_tacts/admin/dashboard"
+require "pro_tacts/admin/groups_members"
 require "pro_tacts/change_id"
 
 module ProTacts
@@ -97,11 +98,16 @@ module ProTacts
           apply_groups(r, id)
         end
 
-        # The groups row's "new" (Admin::ContactCard): a nameless group
-        # holding this contact, landing on its members screen, where
-        # the rest of the household is picked.
-        r.post "groups", "new" do
-          r.redirect "/groups/#{store.create_group(members: [id])}/members", 303 if store.contact(id)
+        # The groups row's "new" (Admin::ContactCard): the members
+        # screen of a group not yet made, this contact ticked; its Save
+        # is the create (Admin::GroupsMembers).
+        r.get "groups", "new" do
+          contact = store.contact(id)
+
+          if contact
+            response["Content-Type"] = "text/html; charset=utf-8"
+            Admin::GroupsMembers.call(from: contact, contacts: store.listed_contacts, login: @login)
+          end
         end
 
         r.post do
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index b57b119..533d910 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -28,10 +28,17 @@ module ProTacts
           Admin::GroupsIndex.call(groups: store.all_groups, login: @login)
         end
 
+        # A create from the groups page names the group and lands on
+        # its editor; one from the members screen of a group not yet
+        # made (Admin::GroupsMembers, opened from a card's "new")
+        # carries its first members and lands on the group itself,
+        # being something already. Only ids that name a card
+        # (#apply_group_edit's reason).
         r.post do
           name = typecast_params.str("name", "").strip
-          id = store.create_group(name: name.empty? ? nil : name)
-          r.redirect "/groups/#{id}/edit", 303
+          members = ids_in("members") & store.contact_ids
+          id = store.create_group(name: name.empty? ? nil : name, members:)
+          r.redirect(r.params.key?("members") ? "/groups/#{id}" : "/groups/#{id}/edit", 303)
         rescue Sequel::UniqueConstraintViolation
           # A taken name (db/migrations/008_group_names.rb).
           response["Content-Type"] = "text/html; charset=utf-8"
diff --git a/public/admin.css b/public/admin.css
index aea9f1d..722354e 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -1156,7 +1156,7 @@ span.avatar[data-size="lg"] {
 .detail-grid dd > [role="group"] > button:first-child {
   padding-inline-end: 0;
 }
-.detail-grid dd > [role="group"] > button:last-child {
+.detail-grid dd > [role="group"] > .btn:last-child {
   padding-inline-start: 0;
 }
 /* On a phone the group stacks and the slash goes, the line break
@@ -1174,10 +1174,10 @@ span.avatar[data-size="lg"] {
   /* With no slash between them the two keep their whole padding, so
      their right edges stand together. */
   .detail-grid dd > [role="group"] > button:first-child,
-  .detail-grid dd > [role="group"] > button:last-child {
+  .detail-grid dd > [role="group"] > .btn:last-child {
     padding-inline: var(--_pad-x);
   }
-  .detail-grid dd > [role="group"] > span + button {
+  .detail-grid dd > [role="group"] > span + .btn {
     margin-block-start: calc(-2 * var(--gl-space-3xs));
   }
 }
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 2a8c35b..8bc4c1f 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -49,7 +49,7 @@ module ProTacts
     end
 
     class GroupsMembers
-      def self.call: (group: Group, contacts: Array[ListedContact], login: String) -> String
+      def self.call: (contacts: Array[ListedContact], login: String, ?group: Group?, ?from: Contact?) -> String
     end
 
     class DeviceSetup
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 5d0f671..1e2cb35 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -902,29 +902,43 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   # The groups row's two ways in: "edit" opens the dialog over the
-  # groups that exist, and "new" submits the form that makes one.
+  # groups that exist, and "new" opens the members screen of one not
+  # yet made.
   def test_show_offers_to_edit_the_groups_or_start_a_new_one
     with_contacts({"ada" => ADA}) do
       get "/contacts/ada"
 
       assert_includes last_response.body, %(popovertarget="edit-groups">edit</button>)
-      assert_includes last_response.body, %(<button type="submit" form="new-group" data-size="sm">new</button>)
-      assert_includes last_response.body, %(<form id="new-group" action="/contacts/ada/groups/new" method="post">)
+      assert_includes last_response.body, %(<a href="/contacts/ada/groups/new" class="btn" data-size="sm">new</a>)
     end
   end
 
-  # A family needs no name: the new group is nameless, holds the
-  # contact it was made from, and the save lands on its members screen
-  # for the rest of the household.
-  def test_a_new_group_from_the_card_is_nameless_and_holds_the_contact
+  # "new" makes nothing: it is the members screen with the contact
+  # ticked, its Save the create and its Cancel the card again.
+  def test_new_opens_the_members_screen_of_a_group_not_yet_made
     with_contacts({"ada" => ADA}) do |store|
-      post "/contacts/ada/groups/new"
+      get "/contacts/ada/groups/new"
+
+      assert_equal 200, last_response.status
+      assert_empty store.all_groups
+      assert_includes last_response.body, %(<form action="/groups" method="post")
+      assert_includes last_response.body, %(<input type="checkbox" name="members[]" value="ada" checked>)
+      assert_includes last_response.body, %(<a href="/contacts/ada" class="btn">Cancel</a>)
+      refute_includes last_response.body, %(name="was[]")
+    end
+  end
+
+  # A family needs no name: the save makes a nameless group of the
+  # ticked contacts and lands on it.
+  def test_the_new_groups_save_makes_a_nameless_group_of_the_ticked
+    with_contacts({"ada" => ADA}) do |store|
+      post "/groups", members: %w[ada nobody]
 
       groups = store.all_groups
       group = groups.fetch(0)
       assert_equal 1, groups.length
       assert_equal 303, last_response.status
-      assert_equal "/groups/#{group.id}/members", last_response["Location"]
+      assert_equal "/groups/#{group.id}", last_response["Location"]
       assert_nil group.name
       assert_equal ["ada"], group.members
       assert_equal %w[create join], store.group_changes_of(group.id).map(&:action).reverse
@@ -932,11 +946,10 @@ class AdminContactsPagesTest < Minitest::Test
   end
 
   def test_a_new_group_for_an_unknown_contact_is_a_404
-    with_contacts({"ada" => ADA}) do |store|
-      post "/contacts/nobody/groups/new"
+    with_contacts({"ada" => ADA}) do
+      get "/contacts/nobody/groups/new"
 
       assert_equal 404, last_response.status
-      assert_empty store.all_groups
     end
   end
 
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index af03f38..afc4eda 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -412,7 +412,7 @@ class AdminImportPagesTest < Minitest::Test
       refute_includes last_response.body, %(name="etag")
       assert_includes last_response.body, %(popovertarget="edit-groups")
       # And no "new", which would land on the new group, off the walk.
-      refute_includes last_response.body, %(form="new-group")
+      refute_includes last_response.body, %(/groups/new")
       assert_includes last_response.body,
                     %(<input type="hidden" name="land" value="/import/#{id}/0">)
       # And the row's verdict is the check landed in its circle.