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
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.