Add the group members screen
The contact side of membership picks from a dialog capped by its own
popover; a group picks from the whole book, so the screen is the show
screen's list made tickable and the save moves only what was toggled.
Assisted-by: GLM-5.3 via pi
diff --git a/Steepfile b/Steepfile
index e7a0a43..65dc443 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ 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 groups_edit groups_index groups_show icons import_groups import_original import_saved import_sidebar
+ group_dialog group_filter group_label 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 record_card upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/docs/plans/2026-09-23-the-members-screen.md b/docs/plans/2026-09-23-the-members-screen.md
new file mode 100644
index 0000000..6522891
--- /dev/null
+++ b/docs/plans/2026-09-23-the-members-screen.md
@@ -0,0 +1,71 @@
+# The members screen
+
+2026-09-23. The group's card grows its "edit members" link a working
+one: `/groups/:id/members`, a box per contact in the book, ticked for
+the ones in the group, filtered and capped the way the groups pickers
+are. The link had stood without an href since the show screen was
+written, waiting for this.
+
+## Why a screen and not the dialog
+
+The contact's side of the same relationship is a popover
+(`Admin::GroupDialog`). A contact's groups are a handful; a group's
+members are the whole book. The popover caps its list at eight rows
+because a popover cannot scroll past its own footer
+(docs/plans/2026-09-22-a-few-groups-at-a-time.md); a page scrolls, and
+the cap there is for the reading rather than the clipping — which is
+the import's situation too. And the show screen already renders the
+members list under the card; the screen is that list made editable,
+which is where its link has always said it would be.
+
+## The save is a diff, not a set
+
+The dialog's own posture (`was[]`, `Web#apply_groups`): the form sends
+what it toggled beside what the page loaded with, and the save moves
+only the difference. A membership moved elsewhere since the page
+loaded is in neither list, so the save says nothing about it — no
+version guard, nothing to revert, no snapshot to refuse. The editor's
+guard protects a wholesale replace (a name, the lines); membership
+toggles touch only what was toggled, so there is no overlap to lose.
+
+`Store#edit_members` wraps the moves in one transaction, leavers first
+and joiners last (`#edit_group`'s reason), over `add_member` and
+`remove_member` themselves — so a `sync:` group still moves cards
+between books exactly as the editor moves them, and the change log
+still records every card whose served bytes moved.
+
+## The filter is the groups' own
+
+`Admin::GroupFilter` takes a third consumer. Its nouns are
+parameterized — the placeholder, the empty line, the show-all count —
+and the members screen passes "contacts"; the filter, the cap, the
+never-capped ticked row, and the show-all button are unchanged, for
+the plan's own reason: a second answer to that problem would only be
+a place where the two could disagree.
+
+The offer to make what the filter names is not rendered. A filter
+word cannot mint a contact — that is the dashboard's dialog, with its
+name fields — so the placeholder says nothing about adding, and
+`Empty` drops its `!creatable` clause along with the offer: the clause
+exists to hold the message back while the make-it row stands in for
+it, and on a list where nothing can be made it would hide "No
+contacts match" behind a name no row will ever carry.
+
+The list is the show screen's own — `ul.card` rows, avatar and name
+at the list typography and spacing, the checkbox standing where a
+row's link does — because the screen is that list made editable, and
+a settings-checkbox stack wearing list parts reads as neither. The
+rows being `li`s rather than the labels the dialog and the import
+tick is why `GroupFilter`'s state reads its rows off `data-label`
+(the attribute every row already carried) instead of the element:
+the cap must hide the whole row, border and padding included, not
+the box alone inside an li that stays standing. Rows are matched on
+`Format.name_label`, nickname and all, because that is the name a
+person filters by, and sort the way every contact list sorts
+(`Format.sort_key`).
+
+The form owns the picker's Alpine state and so wraps both cards, the
+record's own above and the list under it — which puts the filter
+inside the form it filters, Enter in it guarded rather than
+submitted, the import's own answer where there is no outside to
+stand in.
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index e6acec2..61b9f0e 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -2,18 +2,25 @@ require "pro_tacts/admin/phlex"
module ProTacts
module Admin
- # Picking groups out of a list too long to read: the filter over
+ # Picking from a list too long to read: the filter over
# the rows, the cap on how many stand shown, the button that lifts
# it, and the offer to make the group the filter names
# (docs/plans/2026-09-22-a-few-groups-at-a-time.md).
#
- # Shared by the two places a contact's groups are picked: the
- # dialog on a stored contact's card (Admin::GroupDialog) and the
- # boxes beside an arriving one (Admin::ImportGroups). A book with
+ # Shared by the three places too long a list is picked from:
+ # the two where a contact's groups are picked — the dialog on a
+ # stored contact's card (Admin::GroupDialog) and the boxes beside
+ # an arriving one (Admin::ImportGroups) — and the members screen,
+ # where a group's contacts are (Admin::GroupsMembers). A book with
# three groups and a book with three hundred are the same screen,
# and the import's is the screen the walk opens once per contact,
# so the two are the same problem — and a second answer to it
- # would only be a place where they could disagree.
+ # would only be a place where they could disagree. The members
+ # screen renders the filter, the cap, and the show-all button,
+ # and not the offer to make what the filter names: a filter word
+ # cannot mint a contact — that is the dashboard's dialog, with
+ # its name fields — so its placeholder says nothing about adding,
+ # and `named` idles, nothing rendering Fresh to push onto it.
#
# Alpine does the filtering, matching each row's lowercased label
# in `data-label` so no group's name is ever spliced into script.
@@ -41,19 +48,20 @@ module ProTacts
# make one vanish would be hiding a decision rather than
# narrowing a list — and the tick is read off the checkbox at
# each evaluation, so a row answers the next filter change
- # already ticked. `rows` is every row standing in the options,
- # the element list `none` reads: no row visible, which is what
- # "No groups match" claims. `named` is the names a tick on the
- # offer committed to making (Named): `creatable` and `none`
- # read it directly rather than off the rows' `data-label`s,
- # which those two only re-read when the filter changes — a
- # name committed since would be invisible to both until the
- # next keystroke.
+ # already ticked. `rows` is whatever carries `data-label` — a
+ # label in the dialog and the import, an li on the members
+ # screen, whose label sits inside its row — and is the element
+ # list `none` reads: no row visible, which is what "No groups
+ # match" claims. `named` is the names a tick on the offer
+ # committed to making (Named): `creatable` and `none` read it
+ # directly rather than off the rows' `data-label`s, which those
+ # two only re-read when the filter changes — a name committed
+ # since would be invisible to both until the next keystroke.
STATE = "{ filter: '', all: false, named: [], " \
"get needle() { return this.filter.trim().toLowerCase() }, " \
"get labels() { return [...this.$refs.options.querySelectorAll('[data-label]')]" \
".map(row => row.dataset.label) }, " \
- "get rows() { return [...this.$refs.options.querySelectorAll('label')] }, " \
+ "get rows() { return [...this.$refs.options.querySelectorAll('[data-label]')] }, " \
"shows(label) { return label.includes(this.needle) }, " \
"visible(row) { return row.querySelector('input[type=checkbox]').checked || " \
"(this.shows(row.dataset.label) && " \
@@ -66,10 +74,10 @@ module ProTacts
# What a filterable row wears: the label to match on, read off
# the element rather than out of a closure, whether the cap holds
# it back, and the hiding the two of them drive. Every row the
- # filter is meant to see carries it — a group this book has, and
- # a name an import is about to make — because `creatable` is
- # "none of these", and a row the filter cannot see is a row it
- # would offer to create twice.
+ # filter is meant to see carries it — a group this book has, a
+ # name an import is about to make, a contact the book holds —
+ # because `creatable` is "none of these", and a row the filter
+ # cannot see is a row it would offer to create twice.
#: (String label, ?capped: bool) -> Hash[untyped, untyped]
def self.row(label, capped: false)
{data: {label: label.downcase, capped:}, ":hidden": "!visible($el)"}
@@ -93,6 +101,7 @@ module ProTacts
# @rbs @autofocus: bool
# @rbs @in_form: bool
+ # @rbs @placeholder: String
# `autofocus` where the picker is a popover that just opened and
# nothing else wants the caret; not where it is one field among
@@ -103,15 +112,20 @@ module ProTacts
# find a group is not a save. The dialog needs neither the flag
# nor the guard, its filter standing outside the form it
# filters, which is the sturdier answer where it is available.
- #: (?autofocus: bool, ?in_form: bool) -> void
- def initialize(autofocus: false, in_form: false)
+ #
+ # `placeholder` where the list is not groups and the filter
+ # cannot offer to make a row of it: the default names the offer,
+ # which is the groups' own.
+ #: (?autofocus: bool, ?in_form: bool, ?placeholder: String) -> void
+ def initialize(autofocus: false, in_form: false, placeholder: "Filter or add groups")
@autofocus = autofocus
@in_form = in_form
+ @placeholder = placeholder
end
def view_template
- input(type: "search", placeholder: "Filter or add groups",
- aria_label: "Filter or add groups", autofocus: @autofocus,
+ input(type: "search", placeholder: @placeholder,
+ aria_label: @placeholder, autofocus: @autofocus,
x_model: "filter", **enter)
end
@@ -167,18 +181,25 @@ module ProTacts
# What the list says when the filter matches nothing and is not
# a name either — which, the filter being blank, is also what an
- # empty book says.
+ # empty book says. `offer` where the picker renders Fresh: there
+ # the make-it row stands in for the message while the filter
+ # names something creatable, and the message is held back for
+ # it; where nothing can be made, `none` alone is the message.
class Empty < Phlex::HTML
# @rbs @any: bool
+ # @rbs @offer: bool
+ # @rbs @noun: String
- #: (any: bool) -> void
- def initialize(any:)
+ #: (any: bool, ?offer: bool, ?noun: String) -> void
+ def initialize(any:, offer: true, noun: "groups")
@any = any
+ @offer = offer
+ @noun = noun
end
def view_template
- template(x_if: "none && !creatable") do
- p(class: "gl-muted") { @any ? "No groups match." : "No groups yet." }
+ template(x_if: @offer ? "none && !creatable" : "none") do
+ p(class: "gl-muted") { @any ? "No #{@noun} match." : "No #{@noun} yet." }
end
end
end
@@ -197,10 +218,12 @@ module ProTacts
# left to x-text alone, so the button reads before Alpine runs.
class Rest < Phlex::HTML
# @rbs @count: Integer
+ # @rbs @noun: String
- #: (count: Integer) -> void
- def initialize(count:)
+ #: (count: Integer, ?noun: String) -> void
+ def initialize(count:, noun: "groups")
@count = count
+ @noun = noun
end
def view_template
@@ -211,7 +234,7 @@ module ProTacts
private
#: () -> String
- def label_text = "show all #{@count} groups"
+ def label_text = "show all #{@count} #{@noun}"
end
end
end
diff --git a/lib/pro_tacts/admin/groups_members.rb b/lib/pro_tacts/admin/groups_members.rb
new file mode 100644
index 0000000..e8cba2c
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_members.rb
@@ -0,0 +1,91 @@
+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"
+
+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). The filter, the
+ # cap, and the show-all button are GroupFilter's; the offer to
+ # make what the filter names is not rendered, a filter word not
+ # being able to mint a contact.
+ class GroupsMembers < Phlex::HTML
+ # @rbs @group: Store::Group
+ # @rbs @contacts: Array[Contact]
+ # @rbs @capped: Array[String]
+
+ # The form's id, for the footer's Save outside it.
+ FORM = "members-form" #: String
+ private_constant :FORM
+
+ #: (group: Store::Group, contacts: Array[Contact]) -> void
+ def initialize(group:, contacts:)
+ @group = group
+ # 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 members are never capped, GroupFilter.capped's own rule:
+ # those are the boxes the screen is opened to untick.
+ @capped = GroupFilter.capped(@contacts.map(&:id), joined: group.members)
+ end
+
+ def view_template
+ render Layout.new(title: "Members of #{@group.label}") do
+ form(action: "/groups/#{@group.id}/members", 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 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)
+ # 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: member)
+ 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
+ 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/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 391a86a..afa68bd 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -71,15 +71,16 @@ module ProTacts
# Rendered even with no members, because its head holds the way
# to add one. Membership is edited here rather than in the
- # editor, which edits only the lines the group holds. The link
- # sits where a section's action does (admin.css), and has no
- # href until the members screen exists.
+ # editor, which edits only the lines the group holds; the link
+ # opens the members screen (Admin::GroupsMembers), this list
+ # made editable. It sits where a section's action does
+ # (admin.css).
#: () -> void
def members_list
section do
div(class: "section-head") do
h2(class: "type-label") { "members (#{@members.length})" }
- a(class: "type-label") { "edit members" }
+ a(href: "/groups/#{@group.id}/members", class: "type-label") { "edit members" }
end
if @members.empty?
p(class: "type-body-sm") { "No members yet." }
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 005ac82..aa194d2 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -756,6 +756,18 @@ module ProTacts
end
end
+ # The members screen's save, #regroup's shape over a group: what
+ # it toggled rather than the set it shows, one transaction so a
+ # failure part-way leaves the membership as it was. Leavers first
+ # and joiners last, #edit_group's reason.
+ #: (String id, join: Array[String], leave: Array[String]) -> void
+ def edit_members(id, join:, leave:)
+ @database.transaction do
+ leave.each { remove_member(id, it) }
+ join.each { add_member(id, it) }
+ end
+ end
+
# A card joins a group, and starts serving what the group lends.
# Joining twice is joining once. Joining a `sync:` group puts the
# card in a book, which is logged whether or not its bytes moved.
diff --git a/lib/pro_tacts/web/groups.rb b/lib/pro_tacts/web/groups.rb
index 62bf84d..d261cdd 100644
--- a/lib/pro_tacts/web/groups.rb
+++ b/lib/pro_tacts/web/groups.rb
@@ -1,13 +1,17 @@
require "pro_tacts/admin/card_form"
require "pro_tacts/admin/groups_edit"
require "pro_tacts/admin/groups_index"
+require "pro_tacts/admin/groups_members"
require "pro_tacts/admin/groups_show"
module ProTacts
class Web < Roda
# The group screens, the contacts' own shape: the collection's
- # GET and create, then a record's GET, its editor, and the POST
- # that applies it. What the writes rest on is
+ # GET and create, then a record's GET, its editor, the POST that
+ # applies it, and the members screen beside both — membership is
+ # a relationship rather than a line the group holds, so it is
+ # edited from the card's own screen, not the editor
+ # (Admin::GroupsShow's reason). What the writes rest on is
# docs/plans/2026-09-09-group-edits-propagate.md. A create lands
# on the editor, a new group being nothing until something is
# added to it.
@@ -35,6 +39,18 @@ module ProTacts
group_edit_screen(group) if group
end
+ r.get "members" do
+ group = store.group(id)
+ members_screen(group) if group
+ end
+
+ # Membership from the group's side, the members screen's save
+ # (Admin::GroupsMembers). Above the editor's POST, whose bare
+ # verb block would match this path too.
+ r.post "members" do
+ apply_members(r, id)
+ end
+
r.post do
apply_group_edit(r, id)
end
@@ -86,12 +102,36 @@ module ProTacts
r.redirect "/groups/#{id}", 303
end
+ # The members screen's save, #apply_groups' shape over a group:
+ # the diff of what came back against what the page loaded, so a
+ # membership moved elsewhere since stays as it is, and a stale
+ # page has nothing to revert and no snapshot to refuse. Only ids
+ # that name a card — a membership row is a foreign key
+ # (#apply_group_edit's reason).
+ #: (untyped r, String id) -> String?
+ def apply_members(r, id)
+ group = store.group(id)
+ return if group.nil?
+
+ known = store.contacts.map(&:id)
+ checked = ids_in(r.params["members"]) & known
+ was = ids_in(r.params["was"]) & known
+ store.edit_members(id, join: checked - was, leave: was - checked)
+ r.redirect "/groups/#{id}", 303
+ end
+
#: (Store::Group group, ?notice: String) -> String
def group_edit_screen(group, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
Admin::GroupsEdit.call(group:, notice:)
end
+ #: (Store::Group group) -> String
+ def members_screen(group)
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::GroupsMembers.call(group:, contacts: store.contacts)
+ end
+
# A group's members as contacts; the page puts them in order.
#: (Store::Group group) -> Array[Contact]
def members_of(group)
diff --git a/public/admin.css b/public/admin.css
index 85b2932..dcd4782 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -717,6 +717,38 @@ section > h2 {
position: relative;
}
+/* The members screen (Admin::GroupsMembers). The form owns the
+ picker's Alpine state and so wraps both cards — the record's own,
+ holding the filter and the footer, and the list under it —
+ because $refs and the state reach only inside their x-data
+ element. Gloss bounds a bare form at the settings width and
+ spaces its children like fields; this one is a page, so it stacks
+ its cards the way main stacks a record and its sections
+ (.admin-main's own gap) and runs as wide as the column. */
+.member-picker {
+ max-width: none;
+ gap: var(--gl-space-m);
+}
+/* The list card and what trails it — the empty line, the show-all
+ button — one step apart, the dialog's own spacing under its
+ rows. */
+.member-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--gl-space-2xs);
+}
+/* A card list's row, tickable: the label is the row's clickable
+ surface and fills it, .card > li's own geometry doing the rest,
+ and its gap is the row's — the label rule's own 2xs is a settings
+ row's, and a list row's is the li's. The :has matches the label
+ rule's own specificity game, this layer loading after Gloss but
+ order not beating it there. */
+.card > li > label:has(> input[type="checkbox"]) {
+ flex: 1;
+ min-width: 0;
+ gap: var(--gl-space-xs);
+}
+
/* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
is a prose-list rule (Markdown bullet lists) that also matches
Card's row list, adding a stray gap below every row but the last. */
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 84b5a72..73448a9 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -37,6 +37,10 @@ module ProTacts
def self.call: (group: Store::Group, ?notice: String?) -> String
end
+ class GroupsMembers
+ def self.call: (group: Store::Group, contacts: Array[Contact]) -> String
+ end
+
class DeviceSetup
def self.call: (hostname: String, name: String) -> String
end
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index 96f84b0..3526fe9 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -298,6 +298,129 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
+ ## The members screen
+
+ def test_the_card_links_to_the_members_screen
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ get "/groups/#{id}"
+
+ assert_includes last_response.body, %(<a href="/groups/#{id}/members" class="type-label">edit members</a>)
+ end
+ end
+
+ def test_the_screen_ticks_the_members_and_only_them
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ get "/groups/#{id}/members"
+
+ body = last_response.body
+ assert_equal 200, last_response.status
+ assert_includes body, %(placeholder="Filter contacts")
+ %w[george mary].each do |member|
+ assert_includes body, %(name="members[]" value="#{member}" checked)
+ assert_includes body, %(name="was[]" value="#{member}")
+ end
+ assert_includes body, %(<input type="checkbox" name="members[]" value="ada">)
+ refute_includes body, %(name="was[]" value="ada")
+ # Every row carries the name a person filters by, nickname and all
+ assert_includes body, %(data-label="george boole")
+ assert_includes body, %(data-label="ada lovelace")
+ end
+ end
+
+ def test_the_members_list_in_name_order_on_the_screen_too
+ with_contacts(BOOLES.merge("zz" => "Alice Aardvark")) do |store|
+ id = household(store, members: %w[mary zz george])
+
+ get "/groups/#{id}/members"
+
+ assert_equal ["ada lovelace", "alice aardvark", "george boole", "mary boole"],
+ last_response.body.scan(%r{<li data-label="([^"]+)"}).flatten
+ end
+ end
+
+ # The whole save: only what was toggled moves, and every card whose
+ # served bytes moved is told so in the change log.
+ def test_a_save_moves_only_what_was_toggled
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ post "/groups/#{id}/members", members: ["george", "ada"], was: %w[george mary]
+
+ assert_equal 303, last_response.status
+ assert_equal "/groups/#{id}", URI(last_response["Location"]).path
+ assert_equal %w[ada george], store.group(id).members.sort
+ assert_equal "group", store.changes_of("mary").first.action
+ assert_equal "group", store.changes_of("ada").first.action
+ end
+ end
+
+ # The `was[]` posture: a membership moved elsewhere since the page
+ # loaded is in neither list, and the save says nothing about it.
+ def test_a_save_leaves_a_membership_moved_elsewhere_alone
+ with_contacts(BOOLES) do |store|
+ id = household(store, members: %w[george])
+ store.add_member(id, "mary")
+
+ post "/groups/#{id}/members", members: %w[george], was: %w[george]
+
+ assert_equal 303, last_response.status
+ assert_equal %w[george mary], store.group(id).members.sort
+ end
+ end
+
+ def test_a_save_ignores_ids_that_name_no_card
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ post "/groups/#{id}/members", members: %w[george nobody], was: %w[george mary]
+
+ assert_equal 303, last_response.status
+ assert_equal %w[george], store.group(id).members
+ end
+ end
+
+ def test_an_empty_book_says_so_on_the_members_screen
+ with_contacts({}) do |store|
+ id = store.create_group(name: "Booles")
+
+ get "/groups/#{id}/members"
+
+ assert_includes last_response.body, "No contacts yet."
+ end
+ end
+
+ # The cap and its lifting (Admin::GroupFilter), over contacts: the
+ # ninth row stands hidden until asked for, and a member is never
+ # capped, however far down the book it sorts.
+ def test_a_long_book_opens_capped_and_offers_the_rest
+ names = ("A".."I").each_with_index.to_h { |letter, i| ["p#{i}", "Person #{letter}"] }
+ with_contacts(names) do |store|
+ id = store.create_group(name: "Booles")
+ store.add_member(id, "p8")
+
+ get "/groups/#{id}/members"
+
+ body = last_response.body
+ assert_includes body, %(<li data-label="person i" :hidden="!visible($el)">)
+ assert_equal 1, body.scan("data-capped").length
+ assert_includes body, "show all 9 contacts"
+ end
+ end
+
+ def test_an_unknown_group_is_a_404_on_the_members_screen
+ with_contacts({}) do
+ get "/groups/zzzz/members"
+ assert_equal 404, last_response.status
+
+ post "/groups/zzzz/members", members: []
+ assert_equal 404, last_response.status
+ end
+ end
+
## Search
def test_search_finds_a_group_by_its_name