1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
require "pro_tacts/admin/phlex"
require "pro_tacts/admin/group_filter"
require "pro_tacts/admin/group_label"
module ProTacts
module Admin
# The groups one arriving contact will join, rendered into the
# editor's own form on the import's review screen
# (docs/plans/2026-09-21-import-a-vcf.md).
#
# Inside that form rather than beside it, because a staged
# contact's groups are one more thing the walk is deciding about
# it: one Save writes the card and the groups together, and there
# is no second button to wonder about. That is the difference from
# a stored contact, whose groups are a dialog on the details page
# (Admin::GroupDialog) — there the card exists and a membership is
# a write of its own, here neither exists until that Save.
#
# A box per group this book has, then a box per group this card
# has been told to make and has not made yet, then the filter
# itself offered as another. The two a card comes in under
# whatever else is ticked stand among them rather than out of
# sight, ticked: everyone's book, and the group for the import.
# Both can be unticked — an arrival that does not belong with the
# lot is a thing the walk is for deciding, and so is a card that
# is to sit on the server without going out to anybody's phone.
# The filter, the cap on how many rows stand shown and the button
# that lifts it are Admin::GroupFilter's.
#
# A named group is not created here: it is made by the Save, with
# the contact it was named for (Import::Write#group_id), because a
# group created while a card is still being looked over is one
# left behind if that card is never saved. 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::GroupChoice]
# @rbs @joined: Array[String]
# @rbs @named: Array[String]
# @rbs @capped: Array[String]
# @rbs @was: Array[String]
# The `sync:` groups lead and the nameless ones trail
# (GroupChoice#<=>), then largest first and alphabetical among
# groups the same size: the groups an
# arrival is likeliest to belong in are the ones most of the
# book already does, so they are the rows the cap leaves
# standing, and the filter reaches the rest. The groups dialog
# keeps the plain listing order — there a contact's groups are
# being looked over, not guessed at. `groups` is every group as
# a choice, counted rather than its members read
# (Store#group_choices); `joined` the ids ticked, `named` the
# groups this card is making that do not exist yet.
#
# Those last two are empty whenever the card is opened, and hold
# what was ticked only when a save was sent back to be fixed
# (Web#card_screen): nothing is staged between screens, because
# the Save that would have staged it is the Save that writes the
# contact.
#
# Both are rows the cap spares, the names counted into the limit
# rather than listed in it (GroupFilter.capped;
# docs/plans/2026-09-22-a-few-groups-at-a-time.md, "Both
# pickers").
#
# `was` is the groups a contact the book already has is in, for a
# card being folded into it (Import::Merge): sent back beside the
# boxes, the groups dialog's own `was[]`, so the save moves only
# what was toggled (Web#apply_groups). A new contact is in none.
#: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String], ?was: Array[String]) -> void
def initialize(groups:, joined:, named:, was: [])
@groups = groups.sort_by { [it.sync? ? 0 : 1, it.name.nil? ? 1 : 0, -it.member_count, it.label.downcase] }
@joined = joined
@named = named
@was = was
@capped = GroupFilter.capped(@groups.map(&:id), joined:, shown: named.length)
end
def view_template
div(class: "field-stack", x_data: GroupFilter::STATE) do
span(class: "type-label") { "groups" }
@was.each do |id|
input(type: "hidden", name: "was[]", value: id)
end
# Enter guarded, the boxes being the editor's own fields with
# no outside to the form (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, capped: @capped.include?(group.id))) do
input(type: "checkbox", name: "groups[]", value: group.id,
checked: @joined.include?(group.id))
render GroupLabel.new(group:)
end
end
# 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. Always shown and never capped, being
# ticked (`visible`'s own rule); the `data-label` is for
# `creatable`, 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
# The same row client-side, for names the offer's tick has
# committed since this page loaded: the tick is what
# commits (Admin::GroupFilter), so the name stands here
# rather than riding the filter that named it.
render GroupFilter::Named.new
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
end
end