store, admin: groups get screens of their own
A group had rows but no door to walk through,
so /groups lists one, shows one, and edits it too.
Search finds it by name, a member's tag leads the way,
and each card a save moves is logged right away.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 5a2125a..6fcea8d 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -66,7 +66,8 @@ module ProTacts
# the save splices around. Field name to placeholder: a
# component is not an attribute and earns no type column of its
# own, so the placeholder is the label and the conventional
- # order carries it.
+ # order carries it. Public because a group's address rows are
+ # these same rows (Admin::GroupsEdit).
ADDRESS_FIELDS = [
["street", "street"],
["extended", "street 2"],
@@ -75,7 +76,6 @@ module ProTacts
["postal_code", "postal code"],
["country", "country"],
].freeze #: Array[[String, String]]
- private_constant :ADDRESS_FIELDS
# @rbs @contact: Contact
# @rbs @own: Contact
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index ef4d7ac..e45173f 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -22,14 +22,23 @@ module ProTacts
# @rbs @query: String
# @rbs @upcoming: Array[Store::UpcomingBirthday]
+ # @rbs @labels: Hash[String, Array[String]]
+ # @rbs @groups: Array[Store::Group]
# @rbs @rows: Array[Store::RecentContact]
# @rbs @notice: String?
- #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> void
- def initialize(recent:, upcoming:, query:, notice: nil)
+ # The groups arrive whole because a search answers from both
+ # sides of the relationship: a group is findable by its own name,
+ # and a contact by the names of the groups it is in.
+ #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> void
+ def initialize(recent:, upcoming:, query:, groups:, notice: nil)
@query = query.to_s.strip
@upcoming = upcoming
@notice = notice
+ @labels = {}
+ groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
+ q = @query.downcase
+ @groups = @query.empty? ? [] : groups.select { it.label.downcase.include?(q) }
@rows = @query.empty? ? recent.first(RECENT_LIMIT) : recent.select { matches?(it, @query) }
end
@@ -54,6 +63,7 @@ module ProTacts
end
end
end
+ group_results if @groups.any?
end
render UpcomingBirthdays.new(upcoming: @upcoming)
end
@@ -90,10 +100,30 @@ module ProTacts
return true if row.contact.nickname&.downcase&.include?(q)
return true if row.contact.phones.any? { it.value.downcase.include?(q) }
return true if row.contact.emails.any? { it.value.downcase.include?(q) }
+ return true if @labels.fetch(row.contact.id, []).any? { it.downcase.include?(q) }
false
end
+ # The groups whose names match, under the contacts: a group is a
+ # record the search reaches as surely as a contact is
+ # (docs/DESIGN.md, "When adding something new").
+ #: () -> void
+ def group_results
+ div(class: "section-head") do
+ h2(class: "type-label") { "groups" }
+ end
+ ul(class: "card") do
+ @groups.each do |group|
+ li do
+ a(href: "/groups/#{group.id}") do
+ div(style: "flex: 1; min-width: 0; font-weight: 550;") { group.label }
+ end
+ end
+ end
+ end
+ end
+
#: (Store::RecentContact row) -> void
def render_row(row)
li do
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index 695e4c5..576fbfb 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -33,7 +33,7 @@ module ProTacts
# one: a card with no entries is a card whose history was lost,
# and an empty default would render that as an ordinary quiet
# record.
- #: (Contact contact, Array[String] groups, Array[Store::Change] changes) -> void
+ #: (Contact contact, Array[Store::Group] groups, Array[Store::Change] changes) -> void
def initialize(contact:, groups:, changes:)
@contact = contact
@groups = groups
@@ -138,7 +138,7 @@ module ProTacts
row(email.type || "email", email.value, email.line)
end
@contact.addresses.each do |address|
- row(address.type || "address", address_lines(address), address.line)
+ row(address.type || "address", Format.address_lines(address), address.line)
end
row("birthday", @birthday) if @birthday
@contact.notes.each do |note|
@@ -151,18 +151,15 @@ module ProTacts
# values are the group's rather than the contact's. Gloss' Tag
# rather than the Badge the inherited rows carry — a badge marks
# the row it sits on, and these are the row's whole value
- # (docs/DESIGN.md, "Relationships are navigable").
- #
- # Tags, not links, for now: the design has every tag opening the
- # record it names, and a group has no screen to open yet. They
- # become links with the group card, which is also what gives a
- # nameless group somewhere for its id to lead.
+ # (docs/DESIGN.md, "Relationships are navigable"). Every tag opens
+ # the group it names, the other half of the member tags on a
+ # group's own card (Admin::GroupsShow).
#: () -> void
def groups_row
dt(class: "type-label") { "groups" }
dd(class: "type-body-sm") do
div(class: "tag-set") do
- @groups.each { |group| span(class: "tag") { group } }
+ @groups.each { |group| a(href: "/groups/#{group.id}", class: "tag") { group.label } }
end
end
end
@@ -189,18 +186,6 @@ module ProTacts
end
end
- # ADR's components as two display lines — street, then everything
- # after it — the same shape the design's own address rows settled
- # on. Presentation: the components themselves are Contact's.
- #: (Contact::Address address) -> Array[String]
- def address_lines(address)
- [
- [address.extended, address.street].compact.join(" "),
- [address.locality, address.region, address.postal_code].compact.join(", "),
- address.country,
- ].compact.reject { it.empty? }
- end
-
#: (String | Array[String] value) -> void
def render_value(value)
if value.is_a?(Array)
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index ec93e61..8e12aab 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -43,6 +43,19 @@ module ProTacts
birthday&.to_s || property.value
end
+ # ADR's components as display lines — street, then everything
+ # after it — the shape the design's own address rows settled on,
+ # and one shape for a contact's card and a group's alike.
+ # Presentation: the components themselves are Contact's.
+ #: (Contact::Address address) -> Array[String]
+ def self.address_lines(address)
+ [
+ [address.extended, address.street].compact.join(" "),
+ [address.locality, address.region, address.postal_code].compact.join(", "),
+ address.country,
+ ].compact.reject { it.empty? }
+ end
+
# How much of an etag's hash is shown before it is cut. Twelve
# hex digits is git's own longest short-hash, and this address
# book will never hold enough cards for two to collide in a
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
new file mode 100644
index 0000000..7a3e3c4
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -0,0 +1,161 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/contacts_edit"
+require "pro_tacts/admin/layout"
+
+module ProTacts
+ module Admin
+ # GET /groups/:id/edit — a group's editor: Admin::ContactsEdit's
+ # shape over what a group holds, which is a name, the addresses and
+ # note it lends, and who it lends them to. The address rows are the
+ # contact editor's own — addressed by digest, blank removes
+ # (docs/plans/2026-09-05-web-card-editor.md) — and the save runs
+ # the contact editor's own splice over the group's lines
+ # (Web#apply_group_edit). A group's version rides along hidden, the
+ # snapshot guard a contact's etag is on its editor.
+ #
+ # Membership is checkboxes, native form state rather than script: a
+ # member is a checked tag, unchecking one removes it on save, and
+ # the add dialog lists every contact not yet in the group as an
+ # unchecked box that submits with the form it sits outside of (the
+ # `form` attribute) — the dialog being the view's one floated layer,
+ # docs/DESIGN.md's rule for adding. A hidden empty `members[]`
+ # makes the list present on every save of this form, so a POST that
+ # carries no list at all is one that says nothing about membership
+ # (#apply_edit's is-a-Hash posture) rather than one that empties it.
+ class GroupsEdit < Phlex::HTML
+ # @rbs @group: Store::Group
+ # @rbs @reading: Contact
+ # @rbs @members: Array[Contact]
+ # @rbs @others: Array[Contact]
+ # @rbs @notice: String?
+
+ #: (group: Store::Group, contacts: Array[Contact], ?notice: String?) -> void
+ def initialize(group:, contacts:, notice: nil)
+ @group = group
+ @reading = group.reading
+ @members, @others = contacts.partition { group.members.include?(it.id) }
+ @notice = notice
+ end
+
+ def view_template
+ render Layout.new(title: "Edit #{@group.label}", notice: @notice) do
+ div(class: "record") do
+ div(class: "record-nav") do
+ a(href: "/groups/#{@group.id}", class: "type-label") { "‹ #{@group.label}" }
+ if @others.any?
+ button(type: "button", data_size: "sm", popovertarget: "add-members") { "add members" }
+ end
+ end
+ div(class: "card") do
+ div(class: "card-body") do
+ form(id: "group-form", action: "/groups/#{@group.id}", method: "post", class: "field-stack") do
+ input(type: "hidden", name: "version", value: @group.version)
+ input(type: "hidden", name: "members[]", value: "")
+ # The id as the blank's placeholder, because a group
+ # with no name is shown as its id everywhere else.
+ label(class: "field") do
+ span { "Name" }
+ input(type: "text", name: "name", value: @group.name,
+ placeholder: @group.id, autofocus: true)
+ end
+ members_row if @members.any?
+ @reading.addresses.each { |address| address_row(address) }
+ added_address_row
+ # The first NOTE, the contact editor's own rule: this
+ # row edits one, and a save writes one.
+ note = @reading.notes.first&.value
+ label(class: "field", data: {blank_removes: !!note}) do
+ span { "Note" }
+ textarea(name: "note", rows: 4,
+ placeholder: note ? "removed on save" : nil) { note.to_s }
+ end
+ div(class: "form-actions") do
+ button(type: "submit", data: {variant: "primary"}) { "Save" }
+ a(href: "/groups/#{@group.id}", class: "btn") { "Cancel" }
+ end
+ end
+ end
+ end
+ end
+ add_members_dialog if @others.any?
+ end
+ end
+
+ private
+
+ #: () -> void
+ def members_row
+ div(class: "field") do
+ span { "Members" }
+ div(class: "tag-set") do
+ @members.each do |member|
+ label(class: "tag") do
+ input(type: "checkbox", name: "members[]", value: member.id, checked: true)
+ plain(member.name || member.id)
+ end
+ end
+ end
+ end
+ end
+
+ # ContactsEdit#address_row, over a group's line.
+ #: (Contact::Address address) -> void
+ def address_row(address)
+ div(class: "field", data: {blank_removes: address.po_box.nil?}) do
+ span { address.type || "address" }
+ div(class: "field-stack") do
+ ContactsEdit::ADDRESS_FIELDS.each do |component, label|
+ input(type: "text", name: "address[#{address.line.digest}][#{component}]",
+ value: address.public_send(component), placeholder: label, aria_label: label)
+ end
+ end
+ end
+ end
+
+ # One address added per save, revealed on ask so no empty row
+ # stands in the form until one is wanted (ContactsEdit#added_rows'
+ # reason, and Alpine's). `display: contents` keeps both halves on
+ # the form's own grid.
+ #: () -> void
+ def added_address_row
+ div(x_data: "{ adding: false }", style: "display: contents;") do
+ template("x-if": "adding") do
+ div(class: "field") do
+ span { "address" }
+ div(class: "field-stack") do
+ ContactsEdit::ADDRESS_FIELDS.each_with_index do |(component, label), index|
+ input(type: "text", name: "new_address[0][#{component}]", placeholder: label,
+ aria_label: label, "x-init": index.zero? ? "$el.focus()" : nil)
+ end
+ end
+ end
+ end
+ div(class: "field", "x-show": "!adding") do
+ span
+ button(type: "button", data_size: "sm", "@click": "adding = true") { "add address" }
+ end
+ end
+ end
+
+ #: () -> void
+ def add_members_dialog
+ dialog(id: "add-members", popover: "auto") do
+ header { "Add members" }
+ div(class: "field-stack") do
+ @others.each do |contact|
+ label do
+ input(type: "checkbox", name: "members[]", value: contact.id, form: "group-form")
+ plain(contact.name || contact.id)
+ end
+ end
+ end
+ footer do
+ button(type: "button", popovertarget: "add-members",
+ popovertargetaction: "hide") { "Done" }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/groups_index.rb b/lib/pro_tacts/admin/groups_index.rb
new file mode 100644
index 0000000..aaae82c
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_index.rb
@@ -0,0 +1,78 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+
+module ProTacts
+ module Admin
+ # GET /groups — every group and how many cards it lends to, with
+ # the create dialog riding along hidden the way the dashboard's
+ # does (see ContactDialog for why a popover). A group needs nothing
+ # to exist — no name, no lines, no members (db/migrations/
+ # 005_group_identity.rb) — so the dialog's one field is optional,
+ # and a create lands on the new group's editor, where everything
+ # it will hold is added.
+ class GroupsIndex < Phlex::HTML
+ # @rbs @groups: Array[Store::Group]
+ # @rbs @notice: String?
+
+ #: (groups: Array[Store::Group], ?notice: String?) -> void
+ def initialize(groups:, notice: nil)
+ @groups = groups
+ @notice = notice
+ end
+
+ def view_template
+ render Layout.new(title: "Groups", notice: @notice) do
+ section do
+ div(class: "section-head") do
+ h2(class: "type-label") { "groups" }
+ button(data_size: "sm", popovertarget: "new-group") { "add group" }
+ end
+ if @groups.empty?
+ p(class: "type-body-sm") { "No groups yet." }
+ else
+ ul(class: "card") do
+ @groups.each do |group|
+ li do
+ a(href: "/groups/#{group.id}") do
+ div(style: "flex: 1; min-width: 0; font-weight: 550;") { group.label }
+ span(class: "type-label") { members_label(group) }
+ end
+ end
+ end
+ end
+ end
+ end
+ new_group_dialog
+ end
+ end
+
+ private
+
+ #: (Store::Group group) -> String
+ def members_label(group)
+ count = group.members.length
+ count == 1 ? "1 member" : "#{count} members"
+ end
+
+ #: () -> void
+ def new_group_dialog
+ dialog(id: "new-group", popover: "auto") do
+ header { "New group" }
+ form(id: "new-group-form", action: "/groups", method: "post") do
+ label(class: "field") do
+ plain "Name"
+ input(type: "text", name: "name", autofocus: true)
+ end
+ end
+ footer do
+ button(type: "button", popovertarget: "new-group",
+ popovertargetaction: "hide") { "Cancel" }
+ button(type: "submit", form: "new-group-form",
+ data: {variant: "primary"}) { "Create" }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
new file mode 100644
index 0000000..9263137
--- /dev/null
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -0,0 +1,87 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/format"
+require "pro_tacts/admin/layout"
+
+module ProTacts
+ module Admin
+ # GET /groups/:id — a group's card. Groups are records, not labels
+ # (docs/DESIGN.md, "Relationships are navigable"), so this is a
+ # contact's card in every way that carries over: what the group
+ # lends on the same type-and-value grid, and an attribute with no
+ # data gets no row. Its members are tags that open their records,
+ # the other half of the tags on a member's own card.
+ class GroupsShow < Phlex::HTML
+ # @rbs @group: Store::Group
+ # @rbs @reading: Contact
+ # @rbs @members: Array[Contact]
+
+ #: (group: Store::Group, members: Array[Contact]) -> void
+ def initialize(group:, members:)
+ @group = group
+ @reading = group.reading
+ @members = members
+ end
+
+ def view_template
+ render Layout.new(title: @group.label) do
+ div(class: "record") do
+ div(class: "record-nav") do
+ a(href: "/groups", class: "type-label") { "‹ groups" }
+ a(href: "/groups/#{@group.id}/edit", class: "btn", data_size: "sm") { "edit" }
+ end
+ div(class: "card") do
+ div(class: "card-body") do
+ div(class: "detail-header") do
+ h1(class: "type-h2", style: "margin: 0;") { @group.label }
+ end
+ # ContactsShow#has_data?'s reason: an empty grid would
+ # still take card-body's gap.
+ dl(class: "detail-grid") { rows } if has_data?
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ #: () -> bool
+ def has_data?
+ @members.any? || @reading.addresses.any? || @reading.notes.any?
+ end
+
+ # Members first, the order a contact's card puts its groups in:
+ # the relationship frames the rows under it.
+ #: () -> void
+ def rows
+ members_row if @members.any?
+ @reading.addresses.each do |address|
+ row(address.type || "address") do
+ Format.address_lines(address).each { |line| div { line } }
+ end
+ end
+ @reading.notes.each do |note|
+ row("notes") { div(style: "white-space: pre-wrap;") { note.value } }
+ end
+ end
+
+ #: () -> void
+ def members_row
+ row("members") do
+ div(class: "tag-set") do
+ @members.each do |member|
+ a(href: "/contacts/#{member.id}", class: "tag") { member.name || member.id }
+ end
+ end
+ end
+ end
+
+ #: (String type) { () -> void } -> void
+ def row(type, &)
+ dt(class: "type-label") { type }
+ dd(class: "type-body-sm", &)
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index a1e3eea..20d4c89 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -108,10 +108,13 @@ module ProTacts
# Device setup belongs here because the dashboard is a
# search over contacts and /setup is not a contact — a
# header link would put it beside the search on every
- # screen, at the weight the app's own name has.
+ # screen, at the weight the app's own name has. The groups
+ # list is here for the same weight: search finds a group by
+ # name, and this is where one is created.
footer(class: "admin-footer") do
span(class: "type-label") { ProTacts.config.version || "dev server" }
span(class: "type-label") { "+debug" } if ProTacts.config.debug?
+ a(href: "/groups", class: "type-label") { "groups" }
a(href: "/setup", class: "type-label") { "device setup" }
end
# A server-rendered toast, Gloss's [role=status] contract:
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index bfcf897..97349e8 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -87,6 +87,36 @@ module ProTacts
# @rbs skip
GroupEdit = Data.define(:group_id, :position, :line)
+ # A group as the admin screens read one: its own row, the lines it
+ # lends in the order it lends them, and its members' card ids. The
+ # label is the SQL one #group_label computes, so a tag and a
+ # heading can never disagree about what to call a nameless group.
+ # @rbs skip
+ Group = Data.define(:id, :name, :label, :lines, :members)
+
+ # Reopened rather than defined in the block above, for the reason
+ # CardDiff is.
+ class Group
+ # The group's lines read through the one model that knows how to
+ # read an address and a note — a Contact over a card made of
+ # those lines and nothing else — for the screens that render and
+ # edit them. It composes nothing and is never stored or served.
+ #: () -> Contact
+ def reading
+ Contact.new(id:, stored: VCard.new(lines.map { "#{it}\r\n" }.join), birthday: nil, inherited: [])
+ end
+
+ # The group's etag, for the editor's snapshot guard: a hash of
+ # everything the edit screen shows and the save writes, so a
+ # group edited since the page loaded refuses the stale save
+ # rather than reverting what changed (Web#apply_edit's rule).
+ # Nothing serves it, so nothing has to agree with it but the form.
+ #: () -> String
+ def version
+ Digest::SHA256.hexdigest([name, lines, members].to_json)
+ end
+ end
+
# SQLite has no ON UPDATE, so the column default stamps a row on
# insert and this stamps it again on the way past. Same expression as
# the migration's, deliberately: the database keeps the clock, so two
@@ -131,6 +161,10 @@ module ProTacts
# group cannot hold").
SHAREABLE_NAMES = %w[ADR NOTE].freeze #: Array[String]
+ # A groups row's own columns, qualified for #group_label's reason:
+ # the label is written against the table by name.
+ GROUP_COLUMNS = [Sequel[:groups][:id], Sequel[:groups][:name]].freeze #: Array[untyped]
+
# Sequel's migrations, run on open. They ship with the code rather
# than with a deployment, so the path is relative to this file.
# `__dir__` is nil only for code with no file behind it, which a
@@ -273,22 +307,24 @@ module ProTacts
nil
end
- # The groups a contact belongs to, by the label each is shown
- # under, ordered by group id — a rename must not move a tag, the
+ # The groups a contact belongs to, whole — a tag names one and
+ # opens it — ordered by group id: a rename must not move a tag, the
# same reason #inherited_rows orders by it. Membership is the whole
# answer here, so a group that lends nothing appears too, where the
# inherited reads have nothing of it to carry. Off the contact
# rather than on it: what a group lends is in the card and belongs
# to the model of one, and which groups a contact is in is a
# relationship the card never carries.
- #: (String id) -> Array[String]
+ #: (String id) -> Array[Group]
def groups_of(id)
- group_members
- .join(:groups, id: Sequel[:group_members][:group_id])
- .where(card_id: id)
- .order(Sequel[:groups][:id])
- .select(group_label.as(:label))
- .map { it.fetch(:label).to_s }
+ load_groups(
+ groups
+ .join(:group_members, group_id: :id)
+ .where(card_id: id)
+ .order(Sequel[:groups][:id])
+ .select(*GROUP_COLUMNS, group_label.as(:label))
+ .all,
+ )
end
# The id of the card whose UID property holds this value, if one
@@ -484,6 +520,83 @@ module ProTacts
raise "no free group id in #{GROUP_ID_ATTEMPTS} draws: #{groups.count} groups already"
end
+ # Every group, ordered by id — the order #groups_of and the
+ # composition both use, so a rename never moves one.
+ #: () -> Array[Group]
+ def all_groups
+ load_groups(groups.select(*GROUP_COLUMNS, group_label.as(:label)).order(:id).all)
+ end
+
+ # One group, or nil for an id nobody has: the admin's 404 path,
+ # #contact's own shape.
+ #: (String id) -> Group?
+ def group(id)
+ load_groups([groups.select(*GROUP_COLUMNS, group_label.as(:label)).where(id:).sole]).fetch(0)
+ rescue Sequel::NoMatchingRow
+ nil
+ end
+
+ # A group's name, or none — NULL being the one spelling of
+ # nameless (db/migrations/005_group_identity.rb), so a blank is
+ # stored as that rather than refused by the schema. A name is on no
+ # card, so no member's bytes move and nothing is logged.
+ #: (String id, name: String?) -> void
+ def rename_group(id, name:)
+ groups.where(id:).update(name: name.to_s.strip.empty? ? nil : name)
+ end
+
+ # Replaces what a group lends, wholesale, at positions from zero.
+ # Every member serves the new lines from here on, so every member
+ # whose served card moved is logged (#fan_out). A line outside what
+ # a group may hold is refused by the CHECK, which is a caller's bug
+ # — the editor only builds ADR and NOTE lines — and raises.
+ #: (String id, Array[String] lines) -> void
+ def set_group_lines(id, lines)
+ @database.transaction do
+ fan_out(member_ids([id])) do
+ group_properties.where(group_id: id).delete
+ lines.each.with_index do |line, position|
+ group_properties.insert(group_id: id, position:, line:)
+ end
+ end
+ end
+ end
+
+ # The editor's whole save, as one transaction: a failure part-way
+ # leaves the group as it was rather than half renamed. Leavers go
+ # first and joiners last, so no card is logged for lines it was
+ # about to stop or had yet to start serving.
+ #: (String id, name: String?, lines: Array[String], members: Array[String]) -> void
+ def edit_group(id, name:, lines:, members:)
+ @database.transaction do
+ current = member_ids([id])
+ (current - members).each { remove_member(id, it) }
+ rename_group(id, name:)
+ set_group_lines(id, lines)
+ (members - current).each { add_member(id, it) }
+ end
+ end
+
+ # A card joins a group, and starts serving what the group lends.
+ # Joining twice is joining once.
+ #: (String group_id, String card_id) -> void
+ def add_member(group_id, card_id)
+ @database.transaction do
+ fan_out([card_id]) { group_members.insert_conflict.insert(group_id:, card_id:) }
+ end
+ end
+
+ # A card leaves a group, and stops serving what the group lends —
+ # the one lever the model has for "everyone but this member"
+ # (docs/plans/2026-08-24-vcard-storage-and-groups.md, "Edits
+ # propagate to the group").
+ #: (String group_id, String card_id) -> void
+ def remove_member(group_id, card_id)
+ @database.transaction do
+ fan_out([card_id]) { group_members.where(group_id:, card_id:).delete }
+ end
+ end
+
# The change log from a sequence number on, oldest first: the window
# a sync-collection report answers from (RFC 6578 section 3.2) — the
# entries after the client's token are the changes it has not seen.
@@ -597,6 +710,10 @@ module ProTacts
# writing, whose own entry already carries this composition
# (docs/plans/2026-09-09-group-edits-propagate.md, "The fan-out").
#
+ # Card ids rather than groups, because the cards a write can move
+ # are not always members yet: a card joining a group is read before
+ # the row that makes it one exists.
+ #
# Inside whatever transaction the caller has open: Sequel joins one
# rather than nesting, which is what lets this run from #put.
#: (Array[String] cards, ?except: String?) { () -> void } -> void
@@ -628,6 +745,27 @@ module ProTacts
end
end
+ # Groups rows read whole: the lines and members of every group in
+ # `rows`, two reads however many groups there are.
+ #: (Array[Hash[Symbol, untyped]] rows) -> Array[Group]
+ def load_groups(rows)
+ ids = rows.map { it.fetch(:id).to_s }
+ lines = group_properties.where(group_id: ids).order(:group_id, :position).all
+ .group_by { it.fetch(:group_id).to_s }
+ members = group_members.where(group_id: ids).order(:card_id).all
+ .group_by { it.fetch(:group_id).to_s }
+ rows.map { |row|
+ id = row.fetch(:id).to_s
+ Group.new(
+ id:,
+ name: row.fetch(:name)&.to_s,
+ label: row.fetch(:label).to_s,
+ lines: lines.fetch(id, []).map { it.fetch(:line).to_s },
+ members: members.fetch(id, []).map { it.fetch(:card_id).to_s },
+ )
+ }
+ end
+
# The cards belonging to any of these groups, each once — the
# members a write to those groups can move.
#: (Array[String] group_ids) -> Array[String]
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 2adc8ea..525bca3 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -14,6 +14,9 @@ require "pro_tacts/admin/contacts_edit"
require "pro_tacts/admin/contacts_index"
require "pro_tacts/admin/contacts_show"
require "pro_tacts/admin/device_setup"
+require "pro_tacts/admin/groups_edit"
+require "pro_tacts/admin/groups_index"
+require "pro_tacts/admin/groups_show"
require "pro_tacts/debug_logger"
require "pro_tacts/contact"
require "pro_tacts/profile"
@@ -190,6 +193,47 @@ module ProTacts
end
end
+ # 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
+ # 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.
+ r.on "groups" do
+ r.is do
+ r.get do
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::GroupsIndex.call(groups: store.all_groups)
+ end
+
+ r.post do
+ name = r.params["name"].to_s.strip
+ id = store.create_group(name: name.empty? ? nil : name)
+ r.redirect "/groups/#{id}/edit", 303
+ end
+ end
+
+ r.on String do |id|
+ r.get "edit" do
+ group = store.group(id)
+ group_edit_screen(group) if group
+ end
+
+ r.post do
+ apply_group_edit(r, id)
+ end
+
+ r.get do
+ group = store.group(id)
+
+ if group
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::GroupsShow.call(group:, members: members_of(group))
+ end
+ end
+ end
+ end
+
# The configuration profile, served so the device reading this can
# provision itself: until this route existed the only renderer was
# the Rakefile, so putting the account on a phone meant running
@@ -586,6 +630,7 @@ module ProTacts
recent: store.contacts_by_recency,
upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
query:,
+ groups: store.all_groups,
notice:,
)
end
@@ -778,6 +823,61 @@ module ProTacts
r.redirect "/contacts/#{id}", 303
end
+ # The group editor's POST, #apply_edit's shape over a group: the
+ # snapshot guard, then the contact editor's own address splice and
+ # note replace over the group's lines read as a card (Store::Group#reading),
+ # and one store write for the lot.
+ #: (untyped r, String id) -> String?
+ def apply_group_edit(r, id)
+ group = store.group(id)
+ return if group.nil?
+
+ if r.params["version"].to_s != group.version
+ return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
+ end
+
+ reading = group.reading
+ card = reading.stored.replace("NOTE", text_lines("NOTE", r.params["note"].to_s.strip))
+ card = edited_addresses(reading, card, r.params)
+ # Only ids that name a card: a membership row is a foreign key,
+ # and a doctored id is ordinary bad input rather than a 500. A
+ # POST carrying no list keeps the membership it found, the
+ # phones' is-a-Hash posture (Admin::GroupsEdit).
+ submitted = r.params["members"]
+ members =
+ if submitted.is_a?(Array)
+ submitted.map(&:to_s) & store.contacts.map(&:id)
+ else
+ group.members
+ end #: Array[String]
+
+ store.edit_group(id, name: r.params["name"].to_s, lines: group_lines(card), members:)
+ r.redirect "/groups/#{id}", 303
+ end
+
+ # The card a group save spliced, back into the lines a group holds:
+ # each unfolded and shorn of its terminator, the unit CardDiff
+ # records a write in and group_properties stores.
+ #: (VCard card) -> Array[String]
+ def group_lines(card)
+ card.lines.filter_map {
+ line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+ line unless line.empty?
+ }
+ 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:, contacts: store.contacts, notice:)
+ end
+
+ # A group's members as contacts, in the listing's own order.
+ #: (Store::Group group) -> Array[Contact]
+ def members_of(group)
+ store.contacts.select { group.members.include?(it.id) }
+ end
+
#: (Contact contact, ?notice: String) -> String
def edit_screen(contact, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index edacc9a..7eb6ff9 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -7,7 +7,7 @@
module ProTacts
module Admin
class ContactsShow
- def self.call: (contact: Contact, groups: Array[String], changes: Array[Store::Change]) -> String
+ def self.call: (contact: Contact, groups: Array[Store::Group], changes: Array[Store::Change]) -> String
end
class ContactsEdit
@@ -15,7 +15,19 @@ module ProTacts
end
class ContactsIndex
- def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> String
+ def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, groups: Array[Store::Group], ?notice: String?) -> String
+ end
+
+ class GroupsIndex
+ def self.call: (groups: Array[Store::Group], ?notice: String?) -> String
+ end
+
+ class GroupsShow
+ def self.call: (group: Store::Group, members: Array[Contact]) -> String
+ end
+
+ class GroupsEdit
+ def self.call: (group: Store::Group, contacts: Array[Contact], ?notice: String?) -> String
end
class DeviceSetup
diff --git a/sig/pro_tacts/store.rbs b/sig/pro_tacts/store.rbs
index 7c53412..2d61a9f 100644
--- a/sig/pro_tacts/store.rbs
+++ b/sig/pro_tacts/store.rbs
@@ -37,6 +37,16 @@ module ProTacts
def self.new: (group_id: String, position: Integer, label: String, line: String) -> instance
end
+ class Group
+ attr_reader id: String
+ attr_reader name: String?
+ attr_reader label: String
+ attr_reader lines: Array[String]
+ attr_reader members: Array[String]
+
+ def self.new: (id: String, name: String?, label: String, lines: Array[String], members: Array[String]) -> instance
+ end
+
class GroupEdit
attr_reader group_id: String
attr_reader position: Integer
diff --git a/test/fixture_data.rb b/test/fixture_data.rb
index 541316a..53c1761 100644
--- a/test/fixture_data.rb
+++ b/test/fixture_data.rb
@@ -70,9 +70,12 @@ module FixtureData
# only evidence-shaped fixtures — a group is rows across three
# tables with no file format of its own. The group itself is
# Store#create_group's, which mints the id no caller gets to choose;
- # its properties and members have no write path yet — authoring is
- # the admin UI's task — so those rows land the way backdate's do,
- # through the store's own database.
+ # its properties and members land the way backdate's rows do, through
+ # the store's own database rather than Store#set_group_lines and
+ # #add_member. Those log a `group` entry for every member they move,
+ # and the replay's sync tokens are read off that log — seeding
+ # through them would hand the recorded session a history it never
+ # saw.
MEMBERS = %w[household-george household-mary household-alicia].freeze #: Array[String]
# The seed's birthday shapes, read off the card ids rather than
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 45baab0..13e92cc 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -50,7 +50,8 @@ class AdminContactsPagesTest < Minitest::Test
# A household's lines, and the seeding a group takes: a created
# group for the row that mints an id, and its properties and members
# through the store's own database the way FixtureData's do
- # (test/fixture_data.rb), authoring being still the admin UI's task.
+ # (test/fixture_data.rb), so the change log holds only what the test
+ # itself wrote.
HOUSEHOLD = [
"ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom",
"NOTE:Gate code 1854.",
@@ -333,16 +334,18 @@ class AdminContactsPagesTest < Minitest::Test
# The card says what the contact belongs to, as tags: the group
# lending it two rows and the group lending it nothing both appear,
- # because the row is membership rather than what came of it.
+ # because the row is membership rather than what came of it. Every
+ # tag opens the group it names (docs/DESIGN.md, "Relationships are
+ # navigable").
def test_show_tags_the_groups_a_contact_belongs_to
with_contacts({"ada" => ADA}) do |store|
named = add_group(store, name: "Booles", members: ["ada"], lines: HOUSEHOLD)
nameless = add_group(store, members: ["ada"], lines: [])
get "/contacts/ada"
- tags = last_response.body.scan(%r{<span class="tag">([^<]*)</span>}).flatten
+ tags = last_response.body.scan(%r{<a href="/groups/([^"]*)" class="tag">([^<]*)</a>})
- assert_equal({named => "Booles", nameless => nameless}.sort.map { it.last }, tags)
+ assert_equal({named => "Booles", nameless => nameless}.sort, tags)
# The grid leads with what the contact belongs to, so the values
# under it are read already knowing whose they might be.
assert_equal "groups", last_response.body[%r{<dt class="type-label">([^<]*)<}, 1]
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
new file mode 100644
index 0000000..6dae39b
--- /dev/null
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -0,0 +1,248 @@
+require_relative "../../test_helper"
+
+require "pathname"
+require "rack/test"
+require "tmpdir"
+
+require "pro_tacts/store"
+require "pro_tacts/vcard"
+require "pro_tacts/web"
+
+# The group screens, exercised the way AdminContactsPagesTest exercises
+# a contact's: real requests through the Roda app, against a throwaway
+# store.
+class AdminGroupsPagesTest < Minitest::Test
+ include Rack::Test::Methods
+
+ def app
+ ProTacts::Web
+ end
+
+ def setup
+ header "Tailscale-User-Login", "test@example.com"
+ end
+
+ def card(id, name)
+ "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:#{name}\r\nUID:#{id}\r\nEND:VCARD\r\n"
+ end
+
+ ADDRESS = "ADR;TYPE=home:;;7 Calculus Close;London;England;NW1 1AB;United Kingdom" #: String
+ NOTE = "NOTE:Gate code 1854." #: String
+
+ def with_contacts(names)
+ Dir.mktmpdir do |dir|
+ original = ProTacts::Web.store
+
+ ProTacts::Store.connect(Pathname.new(dir) / "contacts.db") do |store|
+ ProTacts::Web.store = store
+ names.each { |id, name| store.put(id, ProTacts::VCard.new(card(id, name))) }
+ yield store
+ ensure
+ ProTacts::Web.store = original
+ end
+ end
+ end
+
+ def household(store, members: %w[george mary])
+ id = store.create_group(name: "Booles")
+ store.set_group_lines(id, [ADDRESS, NOTE])
+ members.each { store.add_member(id, it) }
+ id
+ end
+
+ BOOLES = {"george" => "George Boole", "mary" => "Mary Boole", "ada" => "Ada Lovelace"}.freeze
+
+ ## The list
+
+ def test_the_list_names_every_group_and_its_size
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+ nameless = store.create_group
+
+ get "/groups"
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, %(<a href="/groups/#{id}">)
+ assert_includes last_response.body, "Booles"
+ assert_includes last_response.body, "2 members"
+ assert_includes last_response.body, nameless
+ end
+ end
+
+ def test_an_empty_list_says_so
+ with_contacts({}) do
+ get "/groups"
+
+ assert_includes last_response.body, "No groups yet."
+ end
+ end
+
+ def test_the_footer_leads_to_the_groups
+ with_contacts({}) do
+ get "/"
+
+ assert_includes last_response.body, %(<a href="/groups" class="type-label">groups</a>)
+ end
+ end
+
+ ## Creating
+
+ # A new group is nothing until something is added to it, so the
+ # create lands on its editor.
+ def test_a_create_lands_on_the_new_groups_editor
+ with_contacts({}) do |store|
+ post "/groups", name: "Booles"
+
+ id = store.all_groups.fetch(0).id
+ assert_equal 303, last_response.status
+ assert_equal "/groups/#{id}/edit", URI(last_response["Location"]).path
+ assert_equal "Booles", store.group(id).name
+ end
+ end
+
+ def test_a_blank_create_makes_a_nameless_group
+ with_contacts({}) do |store|
+ post "/groups", name: " "
+
+ assert_nil store.all_groups.fetch(0).name
+ end
+ end
+
+ ## The card
+
+ def test_the_card_shows_what_the_group_lends_and_to_whom
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ get "/groups/#{id}"
+
+ body = last_response.body
+ assert_equal 200, last_response.status
+ assert_includes body, "7 Calculus Close"
+ assert_includes body, "Gate code 1854."
+ assert_includes body, %(<a href="/contacts/george" class="tag">George Boole</a>)
+ assert_includes body, %(<a href="/contacts/mary" class="tag">Mary Boole</a>)
+ refute_includes body, "Ada Lovelace"
+ end
+ end
+
+ def test_an_unknown_group_is_a_404
+ with_contacts({}) do
+ get "/groups/zzzz"
+ assert_equal 404, last_response.status
+
+ get "/groups/zzzz/edit"
+ assert_equal 404, last_response.status
+ end
+ end
+
+ ## The editor
+
+ def test_the_editor_checks_the_members_and_offers_everyone_else
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ get "/groups/#{id}/edit"
+
+ body = last_response.body
+ assert_includes body, %(<input type="hidden" name="version" value="#{store.group(id).version}">)
+ assert_includes body, %(<input type="checkbox" name="members[]" value="george" checked>)
+ assert_includes body, %(<input type="checkbox" name="members[]" value="ada" form="group-form">)
+ assert_includes body, %(name="address[)
+ end
+ end
+
+ # The whole save: the name, a line, and the membership in one POST,
+ # and every card whose served bytes moved told so in the change log.
+ def test_a_save_writes_the_name_the_lines_and_the_members
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+ group = store.group(id)
+
+ post "/groups/#{id}", version: group.version, name: "The Booles", note: "Gate code 1912.",
+ members: ["", "george", "ada"]
+
+ assert_equal 303, last_response.status
+ saved = store.group(id)
+ assert_equal "The Booles", saved.name
+ assert_equal [ADDRESS, "NOTE:Gate code 1912."], saved.lines
+ assert_equal %w[ada george], saved.members
+ assert_includes store.contact("ada").vcard.to_s, ADDRESS
+ refute_includes store.contact("mary").vcard.to_s, ADDRESS
+ assert_equal "group", store.changes_of("mary").first.action
+ end
+ end
+
+ def test_a_save_edits_and_adds_an_address
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+ group = store.group(id)
+ digest = group.reading.addresses.fetch(0).line.digest
+
+ post "/groups/#{id}", version: group.version, note: "Gate code 1854.", members: ["", "george", "mary"],
+ address: {digest => {street: "8 Calculus Close", locality: "London",
+ region: "England", postal_code: "NW1 1AB",
+ country: "United Kingdom"}},
+ new_address: {"0" => {street: "1 Long Road"}}
+
+ assert_equal [ADDRESS.sub("7 Calculus", "8 Calculus"), NOTE, "ADR:;;1 Long Road;;;;"],
+ store.group(id).lines
+ end
+ end
+
+ # The snapshot guard, the contact editor's: a group changed since the
+ # page loaded refuses the stale save rather than reverting the change.
+ def test_a_stale_save_is_refused_and_changes_nothing
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+ stale = store.group(id).version
+ store.rename_group(id, name: "The Booles")
+
+ post "/groups/#{id}", version: stale, name: "Booles", members: [""]
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "This group changed since the page loaded; nothing was saved."
+ assert_equal "The Booles", store.group(id).name
+ assert_equal %w[george mary], store.group(id).members
+ end
+ end
+
+ # No list at all is a POST that says nothing about membership; a
+ # doctored id names no card and is dropped rather than a 500.
+ def test_membership_moves_only_for_a_list_of_real_cards
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ post "/groups/#{id}", version: store.group(id).version, note: "Gate code 1854."
+ assert_equal %w[george mary], store.group(id).members
+
+ post "/groups/#{id}", version: store.group(id).version, note: "Gate code 1854.",
+ members: ["", "george", "nobody"]
+ assert_equal 303, last_response.status
+ assert_equal %w[george], store.group(id).members
+ end
+ end
+
+ ## Search
+
+ def test_search_finds_a_group_by_its_name
+ with_contacts(BOOLES) do |store|
+ id = household(store)
+
+ get "/", q: "boole"
+
+ assert_includes last_response.body, %(<a href="/groups/#{id}">)
+ end
+ end
+
+ def test_search_finds_a_contact_by_its_groups_name
+ with_contacts({"george" => "George", "ada" => "Ada Lovelace"}) do |store|
+ household(store, members: ["george"])
+
+ get "/", q: "boole"
+
+ assert_includes last_response.body, %(<a href="/contacts/george">)
+ refute_includes last_response.body, %(<a href="/contacts/ada">)
+ end
+ end
+end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 580c4e3..adb106e 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -996,9 +996,9 @@ class StoreTest < Minitest::Test
HOUSEHOLD_NOTE = "NOTE:Gate code 1854." #: String
# A group's own row is Store#create_group's, which is what mints the
- # id; its properties and its members have no write path yet —
- # authoring is the admin UI's task — so those land the way the
- # fixture seeder's do, straight through the store's own database.
+ # id; its properties and its members land the way the fixture
+ # seeder's do, straight through the store's own database, so the
+ # change log holds only what the test itself wrote.
# Hands back the id, which is the only way a caller learns it.
def add_group(store, members:, lines:, name: nil)
db = database(store)
@@ -1161,7 +1161,7 @@ class StoreTest < Minitest::Test
add_group(store, name: "Neighbours", members: [], lines: [HOUSEHOLD_NOTE])
expected = {named => "Booles", nameless => nameless}.sort.map { it.last }
- assert_equal expected, store.groups_of("aiden")
+ assert_equal expected, store.groups_of("aiden").map(&:label)
assert_empty store.groups_of("znorth")
end
end
@@ -1241,6 +1241,89 @@ class StoreTest < Minitest::Test
end
end
+ ## Authoring a group
+
+ def test_a_group_reads_whole
+ with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+ id = add_group(store, name: "Household", members: %w[znorth aiden], lines: [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE])
+
+ group = store.group(id)
+ assert_equal "Household", group.label
+ assert_equal [HOUSEHOLD_ADDRESS, HOUSEHOLD_NOTE], group.lines
+ assert_equal %w[aiden znorth], group.members
+ assert_nil store.group("zzzz")
+ end
+ end
+
+ def test_every_group_is_listed_by_id_under_its_label
+ with_store do |store|
+ named = store.create_group(name: "Household")
+ nameless = store.create_group
+
+ assert_equal({named => "Household", nameless => nameless}.sort.to_h,
+ store.all_groups.to_h { [it.id, it.label] })
+ end
+ end
+
+ # What a group lends is what every member serves, so setting it logs
+ # a `group` entry on each (docs/plans/2026-09-09-group-edits-propagate.md,
+ # "The fan-out").
+ def test_setting_a_groups_lines_reaches_every_member
+ with_store({"aiden" => AIDEN, "znorth" => ZED}) do |store|
+ id = add_group(store, members: %w[aiden znorth], lines: [HOUSEHOLD_ADDRESS])
+
+ store.set_group_lines(id, [EDITED_ADDRESS, HOUSEHOLD_NOTE])
+
+ %w[aiden znorth].each do |member|
+ assert_includes store.contact(member).vcard.to_s, EDITED_ADDRESS
+ change = store.changes_of(member).first
+ assert_equal "group", change.action
+ assert_equal store.contact(member).etag, change.etag
+ assert_equal [EDITED_ADDRESS, HOUSEHOLD_NOTE], change.diff.added
+ end
+ end
+ end
+
+ def test_a_joining_member_serves_the_group_and_is_logged
+ with_store({"aiden" => AIDEN}) do |store|
+ id = add_group(store, members: [], lines: [HOUSEHOLD_ADDRESS])
+
+ store.add_member(id, "aiden")
+ store.add_member(id, "aiden")
+
+ assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
+ assert_equal %w[group put], store.changes_of("aiden").map(&:action)
+ assert_equal [HOUSEHOLD_ADDRESS], store.changes_of("aiden").first.diff.added
+ end
+ end
+
+ def test_a_leaving_member_stops_serving_the_group_and_is_logged
+ with_store({"aiden" => AIDEN}) do |store|
+ id = add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+
+ store.remove_member(id, "aiden")
+
+ assert_equal AIDEN, store.contact("aiden").vcard.to_s
+ assert_equal [HOUSEHOLD_ADDRESS], store.changes_of("aiden").first.diff.removed
+ end
+ end
+
+ # Nothing a client downloads moved, so nothing is logged: a group
+ # that lends nothing composes nothing into a new member, and a name
+ # is on no card.
+ def test_writes_that_move_no_served_card_log_nothing
+ with_store({"aiden" => AIDEN}) do |store|
+ id = add_group(store, members: [], lines: [])
+
+ store.add_member(id, "aiden")
+ store.rename_group(id, name: "Household")
+ store.rename_group(id, name: " ")
+
+ assert_equal ["put"], store.changes_of("aiden").map(&:action)
+ assert_equal id, store.group(id).label
+ end
+ end
+
## Subtracting what a group lends
# The member's own address, deliberately unlike the group's so a
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index b77b66b..f1e5aee 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -759,10 +759,10 @@ class WebTest < Minitest::Test
end
end
- # Puts a card in a group of its own that lends it one line. A group's
- # properties and members have no write path yet — authoring is the
- # admin UI's task — so they land through the store's own database,
- # the way the fixture seeder's do (test/fixture_data.rb).
+ # Puts a card in a group of its own that lends it one line, through
+ # the store's own database the way the fixture seeder's do
+ # (test/fixture_data.rb), so the change log holds only what the test
+ # itself wrote.
def lend(store, id, line)
database = store.instance_variable_get(:@database)
group = store.create_group(name: "Booles")