members screen: a picker the group's and the new group's screens subclass
GroupsMembers took a Group or a Contact and asked which at every
difference. Admin::MemberPicker now renders the list and calls title,
action, back, submit, ticked and was, which GroupsMembers (a group's)
and GroupsNew (one not yet made, from a contact) each define.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Ru2zvdWEwdwGhQ5V9z5X
diff --git a/Steepfile b/Steepfile
index 437122c..d0bfc4c 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,8 +21,8 @@ target :lib do
# the files stay checked.
ignore(*%w[
avatar contact_card contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
- group_dialog group_filter group_label group_tag groups_edit groups_index groups_members groups_show icons import_groups import_original import_saved import_sidebar
- import_target import_upload layout list_item phlex recent_row record_card search_dialog search_field search_page search_results upcoming_birthdays
+ group_dialog group_filter group_label group_tag groups_edit groups_index groups_members groups_new groups_show icons import_groups import_original import_saved import_sidebar
+ import_target import_upload layout list_item member_picker phlex recent_row record_card search_dialog search_field search_page search_results upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
index 987ac45..9db9221 100644
--- a/lib/pro_tacts/admin/groups_members.rb
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -1,119 +1,38 @@
-require "pro_tacts/admin/phlex"
-
-require "pro_tacts/admin/avatar"
-require "pro_tacts/admin/format"
-require "pro_tacts/admin/group_filter"
require "pro_tacts/admin/group_label"
-require "pro_tacts/admin/layout"
-require "pro_tacts/admin/record_card"
+require "pro_tacts/admin/member_picker"
module ProTacts
module Admin
- # GET /groups/:id/members — the members screen, the groups
- # dialog's mirror (Admin::GroupDialog): a box per contact in the
- # book, ticked for the ones in the group, and a save that moves
- # only what was toggled (`was[]`, Web#apply_members). A screen
- # and not the dialog's popover for the reason the cap gives from
- # the other side: a contact's groups are a handful, a group's
- # members are the whole book, and a page scrolls where a popover
- # clips. The list is the show screen's own (admin.css, the rows
- # carrying a checkbox where a row's link stands), so the screen
- # reads as that list made editable rather than as a form's option
- # stack (Admin::GroupsShow).
- #
- # The form owns the picker's Alpine state and so wraps both
- # cards, the record's own above and the list under it — $refs and
- # the state reach only inside their x-data element — which puts
- # the filter inside the form it filters: Enter in it is guarded
- # 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 Create is the create
- # (Web#apply_new_group), so a group someone backs out of is never
- # made.
- class GroupsMembers < Phlex::HTML
- # @rbs @login: String
- # @rbs @notice: String?
- # @rbs @contacts: Array[ListedContact]
- # @rbs @title: String
- # @rbs @action: String
- # @rbs @back: [String, (String | Phlex::HTML)]
- # @rbs @submit: String
- # @rbs @ticked: Array[String]
- # @rbs @was: Array[String]
- # @rbs @capped: Array[String]
-
- # The form's id, for the footer's Save outside it.
- FORM = "members-form" #: String
- private_constant :FORM
+ # GET /groups/:id/members — the picker (Admin::MemberPicker) over
+ # a group that exists, its Save moving only what was toggled.
+ class GroupsMembers < MemberPicker
+ # @rbs @group: Group
- # `from` is the record the screen was opened from: a group's
- # page edits that group's members, and a contact's card starts a
- # group with that contact in it. `was` is what the page loaded
- # with, which a group not yet made has none of.
- #: (from: Group | Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> void
- def initialize(from:, contacts:, login:, notice: nil)
- @login = login
- @notice = notice
- # 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] }
- case from
- in Group
- @title = "Members of #{from.label}"
- @action = "/groups/#{from.id}/members"
- @back = ["/groups/#{from.id}", GroupLabel.new(group: from)]
- @submit = "Save"
- @ticked = from.members
- @was = from.members
- in Contact
- @title = "New group"
- @action = "/contacts/#{from.id}/groups/new"
- @back = ["/contacts/#{from.id}", from.name || from.id]
- # The verb the two create dialogs end on (GroupsIndex,
- # ContactDialog).
- @submit = "Create"
- @ticked = [from.id]
- @was = []
- end
- # The ticked are never capped (GroupFilter.capped).
- @capped = GroupFilter.capped(@contacts.map(&:id), joined: @ticked)
+ #: (group: Group, contacts: Array[ListedContact], login: String) -> void
+ def initialize(group:, contacts:, login:)
+ @group = group
+ super(contacts:, login:)
end
- def view_template
- render Layout.new(title: @title, login: @login, notice: @notice) do
- form(action: @action, method: "post", class: "member-picker", id: FORM, x_data: GroupFilter::STATE) do
- render RecordCard.new(back: @back, submit: @submit, 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|
- # 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
- # itself is the li, so the cap hides the whole of
- # it and not the box alone (Admin::GroupFilter).
- li(**GroupFilter.row(Format.name_label(contact),
- capped: @capped.include?(contact.id))) do
- label do
- input(type: "checkbox", name: "members[]", value: contact.id, checked: @ticked.include?(contact.id))
- 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 @was.include?(contact.id)
- end
- end
- end
- render GroupFilter::Empty.new(any: @contacts.any?, offer: false, noun: "contacts")
- render GroupFilter::Rest.new(count: @contacts.length, noun: "contacts") if @capped.any?
- end
- end
- end
- end
+ private
+
+ #: () -> String
+ def title = "Members of #{@group.label}"
+
+ #: () -> String
+ def action = "/groups/#{@group.id}/members"
+
+ #: () -> [String, (String | Phlex::HTML)]
+ def back = ["/groups/#{@group.id}", GroupLabel.new(group: @group)]
+
+ #: () -> String
+ def submit = "Save"
+
+ #: () -> Array[String]
+ def ticked = @group.members
+
+ #: () -> Array[String]
+ def was = @group.members
end
end
end
diff --git a/lib/pro_tacts/admin/groups_new.rb b/lib/pro_tacts/admin/groups_new.rb
new file mode 100644
index 0000000..29cbaad
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_new.rb
@@ -0,0 +1,43 @@
+require "pro_tacts/admin/member_picker"
+
+module ProTacts
+ module Admin
+ # GET /contacts/:id/groups/new — the picker (Admin::MemberPicker)
+ # over a group not yet made, opened from a contact card's "new"
+ # (Admin::ContactCard) with that contact ticked. Its Create is the
+ # create (Web#apply_new_group), so a group someone backs out of is
+ # never made, and with nothing yet to toggle against it sends no
+ # `was`.
+ class GroupsNew < MemberPicker
+ # @rbs @contact: Contact
+
+ #: (contact: Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> void
+ def initialize(contact:, contacts:, login:, notice: nil)
+ @contact = contact
+ super(contacts:, login:, notice:)
+ end
+
+ private
+
+ #: () -> String
+ def title = "New group"
+
+ #: () -> String
+ def action = "/contacts/#{@contact.id}/groups/new"
+
+ #: () -> [String, (String | Phlex::HTML)]
+ def back = ["/contacts/#{@contact.id}", @contact.name || @contact.id]
+
+ # The verb the two create dialogs end on (GroupsIndex,
+ # ContactDialog).
+ #: () -> String
+ def submit = "Create"
+
+ #: () -> Array[String]
+ def ticked = [@contact.id]
+
+ #: () -> Array[String]
+ def was = []
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/member_picker.rb b/lib/pro_tacts/admin/member_picker.rb
new file mode 100644
index 0000000..acdbe23
--- /dev/null
+++ b/lib/pro_tacts/admin/member_picker.rb
@@ -0,0 +1,88 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/group_filter"
+require "pro_tacts/admin/layout"
+require "pro_tacts/admin/record_card"
+
+module ProTacts
+ module Admin
+ # A group's members picked from the whole book, the groups
+ # dialog's mirror (Admin::GroupDialog): a box per contact, ticked
+ # for the ones in the group. A screen and not the dialog's popover
+ # for the reason the cap gives from the other side: a contact's
+ # groups are a handful, a group's members are the whole book, and a
+ # page scrolls where a popover clips. The list is the show screen's
+ # own (admin.css, the rows carrying a checkbox where a row's link
+ # stands), so the screen reads as that list made editable rather
+ # than as a form's option stack (Admin::GroupsShow).
+ #
+ # The form owns the picker's Alpine state and so wraps both
+ # cards, the record's own above and the list under it — $refs and
+ # the state reach only inside their x-data element — which puts
+ # the filter inside the form it filters: Enter in it is guarded
+ # 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.
+ #
+ # Two screens are this one: a group's (Admin::GroupsMembers) and a
+ # group's not yet made (Admin::GroupsNew). Each names its title,
+ # its form's action, its way back, its submit, the boxes ticked,
+ # and `was`, what the page loaded with, sent back so the save can
+ # move only what was toggled (Web#apply_members).
+ class MemberPicker < Phlex::HTML
+ # @rbs @login: String
+ # @rbs @notice: String?
+ # @rbs @contacts: Array[ListedContact]
+ # @rbs @capped: Array[String]
+
+ # The form's id, for the footer's submit outside it.
+ FORM = "members-form" #: String
+ private_constant :FORM
+
+ #: (contacts: Array[ListedContact], login: String, ?notice: String?) -> void
+ def initialize(contacts:, login:, notice: nil)
+ @login = login
+ @notice = notice
+ # 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] }
+ # The ticked are never capped (GroupFilter.capped).
+ @capped = GroupFilter.capped(@contacts.map(&:id), joined: ticked)
+ end
+
+ def view_template
+ render Layout.new(title:, login: @login, notice: @notice) do
+ form(action:, method: "post", class: "member-picker", id: FORM, x_data: GroupFilter::STATE) do
+ render RecordCard.new(back:, submit:, 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|
+ # 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
+ # itself is the li, so the cap hides the whole of
+ # it and not the box alone (Admin::GroupFilter).
+ li(**GroupFilter.row(Format.name_label(contact),
+ capped: @capped.include?(contact.id))) do
+ label do
+ input(type: "checkbox", name: "members[]", value: contact.id, checked: ticked.include?(contact.id))
+ 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 was.include?(contact.id)
+ end
+ end
+ end
+ render GroupFilter::Empty.new(any: @contacts.any?, offer: false, noun: "contacts")
+ render GroupFilter::Rest.new(count: @contacts.length, noun: "contacts") if @capped.any?
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index 81125bd..574bd99 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -4,7 +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/admin/groups_new"
require "pro_tacts/change_id"
module ProTacts
@@ -286,7 +286,7 @@ module ProTacts
#: (Contact contact, ?notice: String) -> String
def new_group_screen(contact, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::GroupsMembers.call(from: contact, contacts: store.listed_contacts, login: @login, notice:)
+ Admin::GroupsNew.call(contact:, contacts: store.listed_contacts, login: @login, notice:)
end
#: (Contact contact, ?notice: String) -> String
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index ec00357..b57b119 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -183,7 +183,7 @@ module ProTacts
#: (Group group) -> String
def members_screen(group)
response["Content-Type"] = "text/html; charset=utf-8"
- Admin::GroupsMembers.call(from: group, contacts: store.listed_contacts, login: @login)
+ Admin::GroupsMembers.call(group:, contacts: store.listed_contacts, login: @login)
end
end
end
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 6f43e1b..3254979 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -49,7 +49,11 @@ module ProTacts
end
class GroupsMembers
- def self.call: (from: Group | Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> String
+ def self.call: (group: Group, contacts: Array[ListedContact], login: String) -> String
+ end
+
+ class GroupsNew
+ def self.call: (contact: Contact, contacts: Array[ListedContact], login: String, ?notice: String?) -> String
end
class DeviceSetup