import: filter the groups beside a contact's card
Before: every group this book has was a checkbox beside every
arriving contact, in one flat list, with a text box under it to name
another. Three groups read fine. Three hundred is not a list anyone
reads, and the walk opens it once per contact.
After: the boxes are filtered, and the filter is the groups dialog's
own — typing narrows the rows, and a filter naming no group offers
itself as one to make, which retires the separate text box. One place
to name a group rather than a list and a field under it.
The filter was the dialog's alone, so it moved to Admin::GroupFilter
and both pickers render it: the x-data, the row attributes that carry
a name to match on, the offer to create, and what the list says when
nothing matches. Two pickers over the same question are a place where
the two could disagree.
The dialog keeps its filter outside the form it filters, which is the
sturdier answer to Enter-saves-mid-filter where it is available. The
import has no outside — the boxes are the editor's own fields — so it
guards the key instead.
The names an import has staged are filtered like the groups, so a
name it is already making is not offered a second time as a name to
make.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
diff --git a/Steepfile b/Steepfile
index c56d4cf..31930df 100644
--- a/Steepfile
+++ b/Steepfile
@@ -22,7 +22,7 @@ target :lib do
# the files stay checked.
ignore(*%w[
avatar contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
- group_dialog group_label groups_edit groups_index groups_show icons import_groups import_original import_review
+ group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_review
import_upload layout list_item phlex upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/lib/pro_tacts/admin/group_dialog.rb b/lib/pro_tacts/admin/group_dialog.rb
index 1ad7580..87c45ed 100644
--- a/lib/pro_tacts/admin/group_dialog.rb
+++ b/lib/pro_tacts/admin/group_dialog.rb
@@ -1,5 +1,6 @@
require "pro_tacts/admin/phlex"
+require "pro_tacts/admin/group_filter"
require "pro_tacts/admin/group_label"
module ProTacts
@@ -10,100 +11,56 @@ module ProTacts
# loaded with beside what it sends back (`was[]`), so the route can
# move only what was toggled (Web#apply_groups).
#
- # Alpine filters, matching each row's lowercased label in
- # data-label so no group's name is spliced into script; a row it
- # hides still submits its box. The filter sits outside the form,
- # because Enter in a form's text box would save mid-filter.
- #
- # Only LIMIT rows stand shown, the rest hidden behind the filter or
- # the button that reveals them
- # (docs/plans/2026-09-22-a-few-groups-at-a-time.md). Every group is
- # still rendered: the filter is client-side, so a group that is not
- # in the page is one nothing in the dialog can reach.
+ # The filter over those boxes, the cap on how many stand shown, the
+ # button that lifts it, and the offer to make the group the filter
+ # names are Admin::GroupFilter's, shared with the import's own
+ # picker (Admin::ImportGroups). The filter box sits outside the
+ # form here, because Enter in a form's text box would save
+ # mid-filter and there is somewhere outside to put it.
#
# Closing the popover puts the cap and the filter back. Alpine's
# state belongs to the element, and a popover is hidden rather
# than rebuilt, so without the reset a dialog reopens on whatever
# the last visit left in it — a filter typed ten minutes ago, or
# the whole list still unrolled.
- #
- # A filter naming no group exactly also offers itself as a new
- # group (`new`), created and joined by the save. Unchecked until
- # ticked: a filter is usually half a name typed to find a group,
- # and a save must not create "boo" on the way to Booles.
class GroupDialog < Phlex::HTML
ID = "edit-groups" #: String
FORM = "edit-groups-form" #: String
- # How many rows a dialog opens with. The popover does not scroll
- # (Gloss gives a dialog `height: fit-content` and
- # `overflow: hidden`), so a list longer than the screen is a
- # Save clipped off the bottom of it: enough rows here that a
- # household's whole list is on the screen, few enough that a
- # long one still ends in its footer.
- LIMIT = 8 #: Integer
-
- # `needle` is the filter as the rows are matched against it, one
- # definition for the three readers of it. `visible` is what the
- # cap adds to `shows`: a capped row joins the list once the
- # filter is typed into or the whole list is asked for, and a
- # filter is the one of the two that can reach a single group
- # without the rest.
- STATE = "{ filter: '', all: false, " \
- "get needle() { return this.filter.trim().toLowerCase() }, " \
- "get labels() { return [...this.$refs.options.querySelectorAll('[data-label]')]" \
- ".map(row => row.dataset.label) }, " \
- "shows(label) { return label.includes(this.needle) }, " \
- "visible(row) { return this.shows(row.dataset.label) && " \
- "(this.all || this.needle !== '' || !('capped' in row.dataset)) }, " \
- "get none() { return !this.labels.some(label => this.shows(label)) }, " \
- "get creatable() { return this.needle !== '' && !this.labels.includes(this.needle) } }" #: String
-
# Alphabetical rather than the id order a tag keeps: this is a list
# to find a name in, and nothing here stays put across a rename.
+ # The groups this contact is in are what the cap spares: those
+ # are the boxes the dialog is opened to untick.
#: (contact: Contact, groups: Array[Store::Group]) -> void
def initialize(contact:, groups:)
@contact = contact
@groups = groups.sort_by { it.label.downcase }
- # A group the contact is in is never capped, however far down
- # the list it sorts: those are the boxes the dialog is opened
- # to untick, and one hidden from the person unticking it makes
- # the dialog disagree with the card it was opened from. They
- # take rows from the limit rather than standing outside it, so
- # the list is the limit or the membership, whichever is longer.
- joined, rest = @groups.partition { it.members.include?(contact.id) }
- @capped = rest.drop((LIMIT - joined.length).clamp(0, LIMIT)).map(&:id)
+ @capped = GroupFilter.capped(
+ @groups.map(&:id),
+ joined: @groups.select { it.members.include?(contact.id) }.map(&:id),
+ )
end
def view_template
- dialog(id: ID, popover: "auto", x_data: STATE,
+ dialog(id: ID, popover: "auto", x_data: GroupFilter::STATE,
"@toggle": "if ($event.newState === 'closed') { all = false; filter = '' }") do
header { "Groups" }
div(class: "field-stack") do
- input(type: "search", placeholder: "Filter or add groups", aria_label: "Filter or add groups",
- autofocus: true, x_model: "filter")
+ render GroupFilter.new(autofocus: true)
form(id: FORM, action: "/contacts/#{@contact.id}/groups", method: "post",
class: "field-stack", x_ref: "options") do
@groups.each do |group|
joined = group.members.include?(@contact.id)
- label(data: {label: group.label.downcase, capped: @capped.include?(group.id)},
- ":hidden": "!visible($el)") do
+ label(**GroupFilter.row(group.label, capped: @capped.include?(group.id))) do
input(type: "checkbox", name: "groups[]", value: group.id, checked: joined)
render GroupLabel.new(group:)
end
input(type: "hidden", name: "was[]", value: group.id) if joined
end
- template(x_if: "creatable") do
- label do
- input(type: "checkbox", name: "new", ":value": "filter.trim()")
- span(class: "gl-muted", style: "font-style: italic;", x_text: "filter.trim()")
- end
- end
+ render GroupFilter::Fresh.new
end
- template(x_if: "none && !creatable") do
- p(class: "gl-muted") { @groups.empty? ? "No groups yet." : "No groups match." }
- end
- rest_button if @capped.any?
+ render GroupFilter::Empty.new(any: @groups.any?)
+ render GroupFilter::Rest.new(count: @groups.length) if @capped.any?
end
footer do
button(type: "button", popovertarget: ID, popovertargetaction: "hide") { "Cancel" }
@@ -111,29 +68,6 @@ module ProTacts
end
end
end
-
- private
-
- # The way past the cap for someone who is looking rather than
- # naming: the filter reaches a group already known by name, this
- # reaches the list. It counts the whole list rather than the part
- # under the cap, that being the number a person is deciding
- # whether to read. It stands down while the filter is typed into,
- # which lifts the cap itself.
- #
- # It goes both ways, the label saying which way the next press
- # goes: a list unrolled is the long dialog the cap exists to
- # avoid, and rolling it back up should not mean closing the
- # dialog. The opening label is rendered rather than left to
- # x-text alone, so the button reads before Alpine runs.
- #: () -> void
- def rest_button
- button(type: "button", data_size: "sm", "x-show": "needle === ''", "@click": "all = !all",
- x_text: "all ? 'show fewer' : #{rest_label.inspect}") { rest_label }
- end
-
- #: () -> String
- def rest_label = "show all #{@groups.length} groups"
end
end
end
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
new file mode 100644
index 0000000..1bc70f0
--- /dev/null
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -0,0 +1,177 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+ module Admin
+ # Picking groups out of 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
+ # 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.
+ #
+ # Alpine does the filtering, matching each row's lowercased label
+ # in `data-label` so no group's name is ever spliced into script.
+ # A row it hides still submits its box: hiding is not unticking,
+ # and a contact's other groups are not being answered for by a
+ # word typed to find one of them. Every row stays in the page for
+ # the same reason the plan gives — the filter is client-side, so a
+ # row the server left out is a row nothing here can reach.
+ class GroupFilter < Phlex::HTML
+ # How many rows a list opens with. The dialog is a popover and
+ # cannot scroll past its own footer (see the plan, and the
+ # scroller admin.css gives it); the import's list is on an
+ # ordinary page and caps for the reading rather than the
+ # clipping. Enough rows that a household's whole list is there,
+ # few enough that a long one still ends in its buttons.
+ LIMIT = 8 #: Integer
+
+ # `needle` is the filter as the rows are matched against it, one
+ # definition for the three readers of it. `visible` is what the
+ # cap adds to `shows`: a capped row joins the list once the
+ # filter is typed into or the whole list is asked for, and a
+ # filter is the one of the two that can reach a single group
+ # without the rest.
+ STATE = "{ filter: '', all: false, " \
+ "get needle() { return this.filter.trim().toLowerCase() }, " \
+ "get labels() { return [...this.$refs.options.querySelectorAll('[data-label]')]" \
+ ".map(row => row.dataset.label) }, " \
+ "shows(label) { return label.includes(this.needle) }, " \
+ "visible(row) { return this.shows(row.dataset.label) && " \
+ "(this.all || this.needle !== '' || !('capped' in row.dataset)) }, " \
+ "get none() { return !this.labels.some(label => this.shows(label)) }, " \
+ "get creatable() { return this.needle !== '' && !this.labels.includes(this.needle) } }" #: String
+
+ # 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.
+ #: (String label, ?capped: bool) -> Hash[untyped, untyped]
+ def self.row(label, capped: false)
+ {data: {label: label.downcase, capped:}, ":hidden": "!visible($el)"}
+ end
+
+ # Which of `ids` the cap holds back, in the order they render.
+ # A row already ticked is never capped, however far down the
+ # list it sorts: those are the boxes a person opens the list to
+ # untick, and one hidden from them makes the list disagree with
+ # the screen it was opened from. They take rows from the limit
+ # rather than standing outside it, so what stands shown is the
+ # limit or the ticked rows, whichever is longer. `shown` is for
+ # rows that are always there and are not in `ids` — the names an
+ # import is about to make — which take from the limit the same
+ # way.
+ #: (Array[String] ids, joined: Array[String], ?shown: Integer) -> Array[String]
+ def self.capped(ids, joined:, shown: 0)
+ ticked, rest = ids.partition { joined.include?(it) }
+ rest.drop((LIMIT - ticked.length - shown).clamp(0, LIMIT))
+ end
+
+ # @rbs @autofocus: bool
+ # @rbs @in_form: bool
+
+ # `autofocus` where the picker is a popover that just opened and
+ # nothing else wants the caret; not where it is one field among
+ # a card's own.
+ #
+ # `in_form` where the boxes sit inside a larger form. Enter in a
+ # text box submits the form around it, and half a name typed to
+ # 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)
+ @autofocus = autofocus
+ @in_form = in_form
+ end
+
+ def view_template
+ input(type: "search", placeholder: "Filter or add groups",
+ aria_label: "Filter or add groups", autofocus: @autofocus,
+ x_model: "filter", **enter)
+ end
+
+ private
+
+ #: () -> Hash[untyped, untyped]
+ def enter
+ return {} unless @in_form
+
+ {"@keydown.enter.prevent": "filter = filter.trim()"}
+ end
+
+ # The filter itself, offered as a group to make, under the rows
+ # it matched none of. Inside the form, being a field of it, and
+ # unticked: a filter is as often half a name typed to find a
+ # group as it is a new one, and a save must not make "boo" on
+ # the way to Booles.
+ class Fresh < Phlex::HTML
+ def view_template
+ template(x_if: "creatable") do
+ label do
+ input(type: "checkbox", name: "new", ":value": "filter.trim()")
+ span(class: "gl-muted", style: "font-style: italic;", x_text: "filter.trim()")
+ end
+ end
+ end
+ end
+
+ # 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.
+ class Empty < Phlex::HTML
+ # @rbs @any: bool
+
+ #: (any: bool) -> void
+ def initialize(any:)
+ @any = any
+ end
+
+ def view_template
+ template(x_if: "none && !creatable") do
+ p(class: "gl-muted") { @any ? "No groups match." : "No groups yet." }
+ end
+ end
+ end
+
+ # The way past the cap for someone who is looking rather than
+ # naming: the filter reaches a group already known by name, this
+ # reaches the list. It counts the whole list rather than the part
+ # under the cap, that being the number a person is deciding
+ # whether to read. It stands down while the filter is typed
+ # into, which lifts the cap itself.
+ #
+ # It goes both ways, the label saying which way the next press
+ # goes: a list unrolled is the long list the cap exists to
+ # avoid, and rolling it back up should not mean closing the
+ # screen it is on. The opening label is rendered rather than
+ # left to x-text alone, so the button reads before Alpine runs.
+ class Rest < Phlex::HTML
+ # @rbs @count: Integer
+
+ #: (count: Integer) -> void
+ def initialize(count:)
+ @count = count
+ end
+
+ def view_template
+ button(type: "button", data_size: "sm", "x-show": "needle === ''", "@click": "all = !all",
+ x_text: "all ? 'show fewer' : #{label_text.inspect}") { label_text }
+ end
+
+ private
+
+ #: () -> String
+ def label_text = "show all #{@count} groups"
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index c21baa6..274a525 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -1,5 +1,6 @@
require "pro_tacts/admin/phlex"
+require "pro_tacts/admin/group_filter"
require "pro_tacts/admin/group_label"
module ProTacts
@@ -16,13 +17,18 @@ module ProTacts
# (Admin::GroupDialog) — there the card exists and a membership is
# a write of its own, here neither is true until the confirm.
#
- # A box per group this book has, then a box per group this import
- # has been told to make and has not made yet, then somewhere to
- # name another. A named group is not created here: it is made at
- # the confirm (Import::Write#group_id), because a group created
- # while the walk is still going is a group left behind by an
- # import that was abandoned. So it rides as a name until then, and
- # shows as a ticked box like any other so that it can be unticked.
+ # A filter over a box per group this book has, then a box per
+ # group this import has been told to make and has not made yet,
+ # then the filter itself offered as another. The filter is the
+ # dialog's own (Admin::GroupFilter): a book with three hundred
+ # groups is a screen either picker would otherwise bury, and this
+ # one is walked once per contact.
+ #
+ # A named group is not created here: it is made at the confirm
+ # (Import::Write#group_id), because a group created while the walk
+ # is still going is a group left behind by an import that was
+ # abandoned. So it rides as a name until then, and shows as a
+ # ticked box like any other so that it can be unticked.
class ImportGroups < Phlex::HTML
# @rbs @groups: Array[Store::Group]
# @rbs @joined: Array[String]
@@ -40,32 +46,36 @@ module ProTacts
end
def view_template
- div(class: "field-stack") do
+ div(class: "field-stack", x_data: GroupFilter::STATE) do
span(class: "type-label") { "groups" }
- @groups.each do |group|
- label do
- input(type: "checkbox", name: "groups[]", value: group.id,
- checked: @joined.include?(group.id))
- render GroupLabel.new(group:)
+ # Enter guarded rather than the filter moved: the dialog
+ # keeps its filter outside the form it filters, and here
+ # there is no outside — the boxes are the editor's own
+ # fields (GroupFilter#initialize).
+ render GroupFilter.new(in_form: true)
+ div(class: "field-stack", x_ref: "options") do
+ @groups.each do |group|
+ label(**GroupFilter.row(group.label)) do
+ input(type: "checkbox", name: "groups[]", value: group.id,
+ checked: @joined.include?(group.id))
+ render GroupLabel.new(group:)
+ end
end
- end
- # Italic and muted, the shape the groups dialog gives a name
- # that is not a group yet (Admin::GroupDialog): the box
- # behaves like the ones above it, and the styling is what
- # says this one is about to be made.
- @named.each do |name|
- label do
- input(type: "checkbox", name: "named[]", value: name, checked: true)
- span(class: "gl-muted", style: "font-style: italic;") { name }
+ # Italic and muted, the shape the groups dialog gives a
+ # name that is not a group yet: the box behaves like the
+ # ones above it, and the styling is what says this one is
+ # about to be made. Filterable like them too, so that a
+ # name this import is already making is not offered as a
+ # name to make.
+ @named.each do |name|
+ label(**GroupFilter.row(name)) do
+ input(type: "checkbox", name: "named[]", value: name, checked: true)
+ span(class: "gl-muted", style: "font-style: italic;") { name }
+ end
end
+ render GroupFilter::Fresh.new
end
- # One at a time, and blank by default: a name typed here is
- # added to the list above on save, where it can be read back
- # and taken off again.
- label(class: "field") do
- span { "New group" }
- input(type: "text", name: "new", placeholder: "made when the import is confirmed")
- end
+ render GroupFilter::Empty.new(any: @groups.any? || @named.any?)
end
end
end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 1ae2a66..3fee465 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -293,6 +293,26 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # A book with three hundred groups is the same screen as a book
+ # with three, and the walk opens it once per contact: the boxes are
+ # filtered, and the filter is the groups dialog's own
+ # (Admin::GroupFilter). Every row it is meant to see says which
+ # name it matches on, and the filter itself is the way to name a
+ # group this book does not have — one place rather than a list and
+ # a text field under it.
+ def test_the_groups_beside_a_card_are_filtered_like_the_dialogs
+ with_contacts({}) do |store|
+ store.create_group(name: "school")
+ id = upload(JANE)
+
+ get "/import/#{id}/0"
+
+ assert_includes last_response.body, %(<input type="search" placeholder="Filter or add groups")
+ assert_includes last_response.body, %(data-label="school")
+ assert_includes last_response.body, %(<input type="checkbox" name="new" :value="filter.trim()">)
+ end
+ end
+
# A group that does not exist yet is named beside the contact and
# made at the confirm, not before: a group created while the walk
# is still going is one left behind by an import that was
@@ -310,11 +330,14 @@ class AdminImportPagesTest < Minitest::Test
assert_empty store.all_groups
# Read back as a box of its own, ticked, so it can be taken off
- # again before the confirm.
+ # again before the confirm — and filtered like the rest, so a
+ # name this import is already making is not offered again as a
+ # name to make.
get "/import/#{id}/0"
assert_includes last_response.body,
%(<input type="checkbox" name="named[]" value="Clarks" checked>)
+ assert_includes last_response.body, %(data-label="clarks")
post "/import/#{id}/confirm", "group" => ""
clarks = store.all_groups.find { it.name == "Clarks" }