contact card: start a nameless group from the groups row
The groups row's "edit groups" becomes "edit", beside a "new" that
makes a nameless group holding the contact and lands on its members
screen, where the rest of the household is picked. Store#create_group
takes the members to join in its own transaction.
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 9a56a03..870bb6b 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -22,23 +22,32 @@ module ProTacts
#
# `groups` is the contact's membership (Store#groups_of), the row
# framing the values under it. `dialog` names whether that row
- # carries its "edit groups" button, which needs GroupDialog on the
+ # 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. `card` names
+ # 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
# 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
+ # @rbs @new: bool
# @rbs @card: bool
- #: (contact: Contact, groups: Array[Group], ?dialog: bool, ?card: bool) -> void
- def initialize(contact:, groups:, dialog: false, card: true)
+ #: (contact: Contact, groups: Array[Group], ?dialog: bool, ?new: bool, ?card: bool) -> void
+ def initialize(contact:, groups:, dialog: false, new: false, card: true)
@contact = contact
@groups = groups
@dialog = dialog
+ @new = new
@card = card
@birthday = Format.birthday(contact)
end
@@ -138,6 +147,11 @@ module ProTacts
# exists, because the row holds the way to join or start one.
# 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
+ # one, and the dialog's offer to make a group is a name typed.
#: () -> void
def groups_row
dt(class: "type-label") { "groups" }
@@ -147,7 +161,8 @@ module ProTacts
@groups.each { render GroupTag.new(group: it) }
end
end
- button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit groups" } if @dialog
+ button(type: "button", data_size: "sm", popovertarget: GroupDialog::ID) { "edit" } if @dialog
+ button(type: "submit", form: NEW, data_size: "sm") { "new" } if @new
end
end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 6b60522..7e5c2bb 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -43,7 +43,7 @@ module ProTacts
a(href: "/", class: "type-label") { "‹ contacts" }
a(href: "/contacts/#{@contact.id}/edit", class: "btn", data_size: "sm") { "edit" }
end
- render ContactCard.new(contact: @contact, groups: @groups, dialog: true)
+ render ContactCard.new(contact: @contact, groups: @groups, dialog: true, new: true)
end
# The stored card in its own card under the record, collapsed
# (docs/DESIGN.md, "The stored card is reachable"). Native
@@ -78,6 +78,7 @@ 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/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
index fdf77a0..a290960 100644
--- a/lib/pro_tacts/admin/group_dialog.rb
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -6,7 +6,7 @@ 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
+ # "edit": 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).
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 123982b..8505d5b 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -632,14 +632,20 @@ module ProTacts
# drawing at once would both find the same id free, so a duplicate
# comes back as the primary key's own violation and the next
# attempt draws again.
- #: (?name: String?) -> String
- def create_group(name: nil)
+ #
+ # `members` join in the same transaction, for a group made from a
+ # contact's card (Web's add): a group that exists only once its
+ # first member is in it, rather than one left empty and nameless
+ # when the join fails.
+ #: (?name: String?, ?members: Array[String]) -> String
+ def create_group(name: nil, members: [])
GROUP_ID_ATTEMPTS.times do
id = ChangeId.mint(4)
begin
@database.transaction do
groups.insert(id:, name:)
record_group_change(id, action: GroupAction::CREATE, detail: {"name" => name})
+ members.each { add_member(id, it) }
end
return id
rescue Sequel::UniqueConstraintViolation
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 59c306d..ec7fbe3 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -97,6 +97,13 @@ 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)
+ end
+
r.post do
apply_edit(r, id)
end
diff --git a/public/admin.css b/public/admin.css
index 5ff6cbe..28c2fd2 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -1119,8 +1119,10 @@ span.avatar[data-size="lg"] {
Baseline-aligned: a grid item blockifies, so the tag's inline-flex
becomes a flex container, which takes its baseline from its first
item — the tag's text — and so shares the value's. A contact
- card's "edit groups" button takes the same place, a flex container
- like the tag. */
+ card's "edit" button takes the same place, a flex container like
+ the tag, and its "new" the column after it — or, on a phone, the
+ row under it, where side by side the two would take the tags'
+ column down to a word and push a long tag under the buttons. */
.detail-grid dd:has(> .tag),
.detail-grid dd:has(> button) {
display: grid;
@@ -1137,6 +1139,26 @@ span.avatar[data-size="lg"] {
.detail-grid dd:has(> button) > div {
grid-column: 1;
}
+.detail-grid dd:has(> button + button) {
+ grid-template-columns: minmax(0, 1fr) auto auto;
+}
+.detail-grid dd > button + button {
+ grid-column: 3;
+}
+@media (max-width: 48rem) {
+ .detail-grid dd:has(> button + button) {
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto 1fr;
+ }
+ .detail-grid dd:has(> button + button) > div {
+ grid-row: span 2;
+ }
+ .detail-grid dd > button + button {
+ grid-column: 2;
+ grid-row: 2;
+ justify-self: end;
+ }
+}
/* A nameless group shown by its id (Admin::GroupLabel): set in mono,
a step quieter, and at regular weight even inside a bold heading or
row, so it reads as an identifier standing in for a name rather than
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 8399155..5d0f671 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -901,6 +901,45 @@ class AdminContactsPagesTest < Minitest::Test
end
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.
+ 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">)
+ 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
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada/groups/new"
+
+ 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_nil group.name
+ assert_equal ["ada"], group.members
+ assert_equal %w[create join], store.group_changes_of(group.id).map(&:action).reverse
+ end
+ end
+
+ def test_a_new_group_for_an_unknown_contact_is_a_404
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/nobody/groups/new"
+
+ assert_equal 404, last_response.status
+ assert_empty store.all_groups
+ end
+ end
+
def test_show_labels_untyped_values_with_the_property_name
untyped = ADA.sub("TEL;TYPE=mobile:", "TEL:").sub("EMAIL;TYPE=home:", "EMAIL:")
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 3f2f747..af03f38 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -410,7 +410,9 @@ class AdminImportPagesTest < Minitest::Test
# back offers is the groups dialog, the contact page's own,
# landed back on the row.
refute_includes last_response.body, %(name="etag")
- assert_includes last_response.body, "edit groups"
+ 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")
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.