import: cap the groups beside a card, as the dialog does
Main capped the groups dialog while this branch was extracting its
filter into Admin::GroupFilter: eight rows stand shown, the rest are
in the page for the filter to reach, and a button counts and unrolls
the whole list (docs/plans/2026-09-22-a-few-groups-at-a-time.md).
The import's per-contact picker asks the same question of the same
list, on the screen the walk opens once per contact, so it takes the
same treatment rather than a second answer to it. The cap, the button
and the rule that a ticked row is never capped all moved into
GroupFilter beside the filter, and both pickers render them.
The import spares two kinds of row: the groups already ticked for this
contact, the dialog's own rule, and the names the walk has said to
make, which are ticked too. Those names take rows from the limit
without being in the list the cap reads, so they are counted into it
rather than listed in it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KrREeoPLXx1qsYYq7qkL8h
diff --git a/docs/plans/2026-09-21-import-a-vcf.md b/docs/plans/2026-09-21-import-a-vcf.md
index a51d455..3e2badd 100644
--- a/docs/plans/2026-09-21-import-a-vcf.md
+++ b/docs/plans/2026-09-21-import-a-vcf.md
@@ -192,11 +192,12 @@ dialog make:
The per-contact boxes ride in the editor's own form, so one Save
writes the card and its groups together and there is no second
- button to wonder about. They are filtered, the groups dialog's own
- filter rather than a second one (`Admin::GroupFilter`, which both
- pickers now render): a book with three hundred groups is the same
- screen as a book with three, and this is the screen the walk opens
- once per contact.
+ button to wonder about. They are filtered and capped, the groups
+ dialog's own filter and cap rather than a second set
+ (`Admin::GroupFilter`, which both pickers now render — see
+ `2026-09-22-a-few-groups-at-a-time.md`): a book with three hundred
+ groups is the same screen as a book with three, and this is the
+ screen the walk opens once per contact.
The filter is also how a group this book does not have yet is
asked for. Naming no group exactly, it offers itself as one to
diff --git a/docs/plans/2026-09-22-a-few-groups-at-a-time.md b/docs/plans/2026-09-22-a-few-groups-at-a-time.md
index 7057969..9279c02 100644
--- a/docs/plans/2026-09-22-a-few-groups-at-a-time.md
+++ b/docs/plans/2026-09-22-a-few-groups-at-a-time.md
@@ -61,6 +61,29 @@ Two things, and they are not the same gesture:
and the dialog re-renders on every open, so the list comes back
capped next time.
+## Both pickers, not just the dialog
+
+The import's per-contact picker (`Admin::ImportGroups`) asks the same
+question of the same list, on the screen the import walk opens once
+per contact, so it gets the same treatment rather than a second answer
+to it. The filter, the cap, the `show all` button, the offer to create
+what the filter names, and the "no groups match" line all live in
+`Admin::GroupFilter` and are rendered by both.
+
+Two differences stay, and both are about where the picker sits. The
+dialog keeps its filter box outside the form it filters, because Enter
+in a form's text box would save mid-filter; the import's boxes are the
+contact editor's own fields, so there is no outside and it guards the
+key instead. And the dialog resets itself on close, a popover being
+hidden rather than rebuilt; the import's picker is on a page that is
+loaded afresh per contact, so there is nothing to reset.
+
+The rows the cap spares are the same rule read twice: for the dialog,
+the groups the contact is in; for the import, the boxes the walk has
+already ticked, plus the names it has been told to make, which are
+ticked too and take rows from the limit without being in the list the
+cap reads.
+
## Non-goals
- Paging. A cap that the filter lifts needs no page two, and pages
@@ -68,7 +91,9 @@ Two things, and they are not the same gesture:
guess.
- A scroller on the popover. It would fix the overflow and leave the
reading problem, and the filter is the answer to that.
-- Anything server-side: no limit on `Store#all_groups`, no second
- read, no query. The dialog already has the list.
+- Anything server-side: no limit on the list, no paging query. The
+ picker already has it, and reads it in one go — `Store#group_choices`
+ narrowed that read to the group row and its label, but it is still
+ one read of every group, which is what the client-side filter needs.
- The group list at `/groups` (`Admin::GroupsIndex`), which is a page
and scrolls like one.
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index ebd5704..4b794ac 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -17,12 +17,14 @@ 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 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 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, the cap on how many rows
+ # stand shown and the button that lifts it are the dialog's
+ # (Admin::GroupFilter,
+ # docs/plans/2026-09-22-a-few-groups-at-a-time.md): a book with
+ # three hundred groups buries either picker, and this is the one
+ # the walk opens 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
@@ -33,17 +35,25 @@ module ProTacts
# @rbs @groups: Array[Store::GroupChoice]
# @rbs @joined: Array[String]
# @rbs @named: Array[String]
+ # @rbs @capped: Array[String]
# Alphabetical, the groups dialog's own order: this is a list to
# find a name in, and nothing here stays put across a rename.
# `groups` is every group as a choice, no members read
# (Store#group_choices); `joined` the ids ticked so far, `named`
# the groups this import is making that do not exist yet.
+ #
+ # Both of those are rows the cap spares, for the dialog's own
+ # reason: a box this contact has already been given is one the
+ # walk came back to untick. The names take rows from the limit
+ # without being in the list the cap reads, so they are counted
+ # into it rather than listed in it.
#: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String]) -> void
def initialize(groups:, joined:, named:)
@groups = groups.sort_by { it.label.downcase }
@joined = joined
@named = named
+ @capped = GroupFilter.capped(@groups.map(&:id), joined:, shown: named.length)
end
def view_template
@@ -56,7 +66,7 @@ module ProTacts
render GroupFilter.new(in_form: true)
div(class: "field-stack", x_ref: "options") do
@groups.each do |group|
- label(**GroupFilter.row(group.label)) do
+ label(**GroupFilter.row(group.label, capped: @capped.include?(group.id))) do
input(type: "checkbox", name: "groups[]", value: group.id,
checked: @joined.include?(group.id))
render GroupLabel.new(group:)
@@ -67,7 +77,7 @@ module ProTacts
# 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.
+ # name to make, and never capped, being ticked.
@named.each do |name|
label(**GroupFilter.row(name)) do
input(type: "checkbox", name: "named[]", value: name, checked: true)
@@ -77,6 +87,7 @@ module ProTacts
render GroupFilter::Fresh.new
end
render GroupFilter::Empty.new(any: @groups.any? || @named.any?)
+ render GroupFilter::Rest.new(count: @groups.length) if @capped.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 3fee465..1570859 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -313,6 +313,49 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # The cap the dialog got (docs/plans/2026-09-22-a-few-groups-at-a-time.md),
+ # over the picker the walk opens once per contact: eight rows stand
+ # shown, the ninth is marked for the filter to reach, and the button
+ # counts the whole list. A group already ticked for this contact
+ # takes a row from the cap rather than standing outside it.
+ def test_the_groups_beside_a_card_show_a_few_at_a_time
+ with_contacts({}) do |store|
+ ("a".."h").each { store.create_group(name: "Group #{it}") }
+ zulus = store.create_group(name: "Zulus")
+ id = upload(JANE)
+
+ get "/import/#{id}/0"
+
+ # Alphabetical, so Zulus is the ninth and the one that goes under.
+ assert_includes last_response.body, %(<label data-label="zulus" data-capped :hidden="!visible($el)">)
+ assert_equal 1, last_response.body.scan("data-capped").length
+ assert_includes last_response.body, "show all 9 groups"
+
+ post "/import/#{id}/0",
+ "etag" => etag, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => "", "groups" => [zulus]
+
+ # Ticked, so Zulus is spared and Group h goes under in its place.
+ get "/import/#{id}/0"
+
+ assert_includes last_response.body, %(<label data-label="zulus" :hidden="!visible($el)">)
+ assert_includes last_response.body, %(<label data-label="group h" data-capped :hidden="!visible($el)">)
+ assert_equal 1, last_response.body.scan("data-capped").length
+ end
+ end
+
+ def test_the_groups_beside_a_card_offer_no_such_thing_uncapped
+ with_contacts({}) do |store|
+ ("a".."h").each { store.create_group(name: "Group #{it}") }
+ id = upload(JANE)
+
+ get "/import/#{id}/0"
+
+ refute_includes last_response.body, "show all"
+ refute_includes last_response.body, "data-capped"
+ 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