contacts edit: a row edits its label in the type column
Every repeatable row renders its label as a field in the type column,
with its kind's words in a datalist, and posts one shape:
<kind>[<digest>][label|<field>], and new_<kind>[<i>][...] for an
added one. Contact::Phone, Email, Address, and Handle each answer
#fields and #edit and write an added row with .add, so CardForm walks
every kind alike. A typed row writes a word Contacts models as TYPE
and any other as an X-ABLabel under the line's group or the card's
lowest free one (VCard#free_group). The handle's service moves back
into the type column, and .handle-row goes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138CM3cNKnfJZ8LbDsCPRaf
diff --git a/docs/plans/2026-10-22-a-row-edits-its-label.md b/docs/plans/2026-10-22-a-row-edits-its-label.md
index a520073..b9ba6a0 100644
--- a/docs/plans/2026-10-22-a-row-edits-its-label.md
+++ b/docs/plans/2026-10-22-a-row-edits-its-label.md
@@ -58,14 +58,16 @@ three things:
- `#fields`, the row's values by form field name: `{"value" => ...}`
for a phone, an email, and a handle, the six components for an
address (`Contact::ADDRESS_COMPONENTS`).
-- `#lines(label, fields, card)`, the lines that replace the row's own,
- none for a blank value. A class method of the same name writes an
- added row. `card` is there for the one thing a label can need from
- the rest of the card: a free property group.
-
-Phone, email, and address share their label rule through one module
-the three Data classes include, each naming its own vocabulary; the
-handle keeps `Contact.handle_line` as its rule. The address's value
+- `#edit(card, label, fields)`, the card with the row's own lines
+ rewritten — removed, for a blank value. A class method `.add` of the
+ same shape writes an added row. The row takes the card rather than
+ handing back lines because a labeled row is two lines, and only the
+ row knows which; a label can also need a free property group from
+ the rest of the card.
+
+Phone, email, and address share their label rule through functions on
+`Contact` (`edited_row`, `added_row`, `removed_row`), each naming its
+own vocabulary; the handle keeps `Contact.handle_line` as its rule. The address's value
splice (`CardForm.address_line`) moves onto `Address` with it. This is
the precedent `Contact.handle_line` already set: the line's spelling
lives with the model that reads it.
@@ -137,8 +139,8 @@ handle's `service` field becomes `label`. `Web#card_form_params` becomes
one loop over the kinds, each kind's field names from its row class,
and `CardForm.edited_values`, `.edited_handles`, and `.edited_addresses`
become one walk: for each row, skip it where label and fields both
-submit what it read, and substitute its lines (`#lines`) otherwise;
-then insert each added row's. The three walks differ today only in
+submit what it read, and hand it the card (`#edit`) otherwise; then
+add each added row. The three walks differ today only in
what this plan moves onto the rows.
### The type column's field
@@ -198,11 +200,11 @@ One PR, in commits that each pass:
labeled phone removes its label. The orphan fix stands alone.
2. `#label` widens and emails and addresses read theirs; the record
card shows them.
-3. The rows answer `#fields` and `#lines`; the form takes its one
- shape and `CardForm` its one walk, with no visible change.
-4. The type column's field, and the handle row's second field goes.
-
-The hegel properties in `test/pro_tacts/admin/test_card_form.rb` get
-one per kind: a
-relabel reads back as the label typed, and a save that changes no
-field leaves the card's bytes alone.
+3. The rows answer `#fields` and `#edit`, the form takes its one
+ shape and `CardForm` its one walk, and the type column's field
+ replaces the handle row's second field. One commit: the form's new
+ shape and the field that posts it cannot pass apart.
+
+The hegel properties in `test/pro_tacts/admin/test_card_form.rb` grow
+two ways: a relabel reads back as the label typed, with no label left
+naming nothing, and the untouched save posts every kind's label too.
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index f027eaf..571a52f 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -14,6 +14,18 @@ module ProTacts
#
# Not a view, unlike its neighbours, so the Steepfile checks it.
module CardForm
+ # The repeatable rows by the form's name for each kind: the field
+ # a standing row posts under and, prefixed `new_`, the one an
+ # added row does, each reading back into the row class that
+ # writes it (#edited_rows). Public for the route's shape checks
+ # (Web#card_form_params) and the editor's rows.
+ ROWS = {
+ "phone" => Contact::Phone,
+ "email" => Contact::Email,
+ "handle" => Contact::Handle,
+ "address" => Contact::Address,
+ }.freeze #: Hash[String, Contact::row_class]
+
# A created contact's card: the envelope RFC 2426 section 4
# requires — BEGIN, VERSION, and the N and FN that section makes
# mandatory — plus the UID this server's id model is (see
@@ -69,10 +81,10 @@ module ProTacts
end
vcard = edited_text(vcard, "NICKNAME", params["nickname"], own.nickname)
vcard = edited_text(vcard, "NOTE", params["note"], note_of(own))
- vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
- vcard = edited_values(own.emails, "email", "EMAIL", vcard, params)
- vcard = edited_handles(own, vcard, params)
- edited_addresses(own, vcard, params)
+ vcard = edited_rows(own.phones, "phone", vcard, params)
+ vcard = edited_rows(own.emails, "email", vcard, params)
+ vcard = edited_rows(own.handles, "handle", vcard, params)
+ edited_rows(own.addresses, "address", vcard, params)
end
# The group editor's save: the note replaced and the contact
@@ -84,7 +96,7 @@ module ProTacts
def self.group_lines(group, params)
reading = group.reading
card = edited_text(reading.stored, "NOTE", params["note"], note_of(reading))
- card = edited_addresses(reading, card, params)
+ card = edited_rows(reading.addresses, "address", card, params)
card.lines.filter_map {
line = it.content
line unless line.empty?
@@ -147,121 +159,53 @@ module ProTacts
vcard.replace(name, text_lines(name, value))
end
- # The single-value rows' half of the surgical save, walked once
- # for the phones and once for the emails: each row names its line
- # by digest, so a row the request leaves out is a line the save
- # never mentions, and an unchanged row is skipped — the line keeps
- # its own bytes by construction, not by careful re-rendering. A
- # blank row removes its line; a changed row swaps the value under
- # the line's own header (VCard.header_of), where the TYPE
- # parameters no field models ride. The rows come from the
- # contact, never the request, so a doctored digest names nothing
- # and a missing one touches nothing.
+ # The repeatable rows' half of the surgical save, walked once per
+ # kind: each row names its line by digest, so a row the request
+ # leaves out is a line the save never mentions, and a row whose
+ # label and fields all submit what it read is skipped — the line
+ # keeps its own bytes by construction, not by careful
+ # re-rendering. Any other is the row's to rewrite (Contact::Phone#edit
+ # and kin), which is where the kinds differ: what a blank removes
+ # and where a label is written
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md). A row posting
+ # no label leaves its label be, as the group editor's addresses
+ # do. The rows come from the contact, never the request, so a
+ # doctored digest names nothing and a missing one touches nothing.
#
- # `field` is the form's name for the kind and `name` the property
- # an added row is written as. The rows the add dialog reveals
- # (Admin::ContactsEdit) land as bare lines before END:VCARD, and a
- # blank one inserts nothing — inserting absence is a no-op, the
- # plan's rule for new rows, and the reason the rows nobody typed
- # in cost nothing whether they were on screen or not.
- #: (Array[Contact::Phone | Contact::Email] rows, String field, String name, VCard vcard, Hash[String, untyped] params) -> VCard
- def self.edited_values(rows, field, name, vcard, params)
+ # `field` is the form's name for the kind. The rows the add
+ # dialog reveals (Admin::ContactsEdit) are named by their add
+ # index (`new_phone[i][value]`) rather than a bare [], because
+ # Rack refuses a key after an empty one — so they arrive as a
+ # hash of hashes and read out by their values, landing as bare
+ # lines before END:VCARD. A blank one inserts nothing.
+ #: (Array[Contact::Phone | Contact::Email | Contact::Handle | Contact::Address] rows, String field, VCard vcard, Hash[String, untyped] params) -> VCard
+ def self.edited_rows(rows, field, vcard, params)
+ kind = ROWS.fetch(field)
edits = hash_field(params, field)
rows.each do |row|
submitted = edits[row.line.digest]
- next if submitted.nil? || submitted.strip == row.value.strip
-
- value = submitted.strip
- vcard = value.empty? ? removed(vcard, row) : vcard.substitute(
- row.line.digest, ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
- )
- end
-
- none = [] #: Array[String]
- submitted = params.fetch("new_#{field}", none) #: Array[String]
- added = submitted.filter_map {
- value = it.strip
- "#{name}:#{VCard.escape(value)}" unless value.empty?
- } #: Array[String]
- vcard.insert(added)
- end
-
- # #edited_values' walk over a handle, a row of two fields: the
- # service and the handle itself
- # (docs/plans/2026-10-18-signal-handles.md, "The service is the
- # row's"). A blank handle removes the line, a service alone being
- # nothing to reach anyone at. A changed handle under an unchanged
- # service swaps under the line's own header and scheme, the
- # phones' rule; a changed service rewrites the header around the
- # line's other parameters and moves the handle under `x-apple:`,
- # the line's old scheme naming the old service's protocol. The
- # rows the add dialog reveals are named by their add index, the
- # address's reason (Rack refuses a key after a bare []).
- #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
- def self.edited_handles(contact, vcard, params)
- rows = hash_field(params, "handle")
- contact.handles.each do |handle|
- submitted = rows[handle.line.digest]
next if submitted.nil?
- service = submitted["service"].to_s.strip
- value = submitted["value"].to_s.strip
- same_service = service == handle.service.to_s.strip
- next if same_service && value == handle.value.strip
-
- line =
- if value.empty?
- nil
- elsif same_service
- "#{VCard.header_of(handle.property)}#{handle.scheme}:#{VCard.escape(value)}"
- else
- Contact.handle_line(service, value, handle.property)
- end
- vcard = vcard.substitute(handle.line.digest, line ? [line] : [])
- end
+ label = submitted["label"]&.strip
+ fields = submitted_fields(kind, submitted)
+ next if (label.nil? || label == row.label.to_s.strip) && fields.all? { |name, value| value == row.fields.fetch(name).strip }
- added = hash_field(params, "new_handle").values #: Array[Hash[String, untyped]]
- vcard.insert(added.filter_map { |row|
- value = row["value"].to_s.strip
- Contact.handle_line(row["service"].to_s.strip, value) unless value.empty?
- })
- end
-
- # #edited_values' walk, over a row that is six fields rather than
- # one (Admin::ContactsEdit). Removal is the reader's own rule
- # (Contact#address_of): a row blank throughout, po box included,
- # removes the line, where a partially blanked one keeps it —
- # partial blanks are legal empty components.
- #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
- def self.edited_addresses(contact, vcard, params)
- rows = hash_field(params, "address")
- contact.addresses.each do |address|
- submitted = rows[address.line.digest]
- next if submitted.nil? || address_unchanged?(address, submitted)
-
- line = address_line(address, submitted)
- vcard = line ? vcard.substitute(address.line.digest, [line]) : removed(vcard, address)
+ vcard = row.edit(vcard, label, fields)
end
- # The add dialog names an added address by its add index
- # (`new_address[i][street]`) rather than the bare [] every
- # single-valued kind appends to, because Rack refuses a key
- # after an empty one — so the rows arrive as a hash of hashes
- # and read out by their values, several adds in one pass
- # included.
- added = hash_field(params, "new_address").values #: Array[Hash[String, untyped]]
- vcard.insert(added.filter_map { address_line(nil, it) })
+ added = hash_field(params, "new_#{field}").values #: Array[Hash[String, untyped]]
+ added.each do |row|
+ vcard = kind.add(vcard, row["label"].to_s.strip, submitted_fields(kind, row))
+ end
+ vcard
end
- # A row's lines gone from the card: its own, and the `X-ABLabel`
- # sharing its group, which would otherwise be left naming nothing
- # (docs/plans/2026-10-22-a-row-edits-its-label.md, "Removing a row
- # removes its label").
- #: (VCard vcard, Contact::Phone | Contact::Email | Contact::Address row) -> VCard
- def self.removed(vcard, row)
- vcard = vcard.substitute(row.line.digest, [])
- label = row.label_line
- label ? vcard.substitute(label.digest, []) : vcard
+ #: (Contact::row_class kind, Hash[String, untyped] submitted) -> Hash[String, String]
+ def self.submitted_fields(kind, submitted)
+ kind.field_names.to_h {
+ field = [it, submitted[it].to_s.strip] #: [String, String]
+ field
+ }
end
# A field the editors post as a hash keyed by row: none where the
@@ -277,45 +221,6 @@ module ProTacts
value
end
- #: (Contact::Address address, Hash[String, untyped] submitted) -> bool
- def self.address_unchanged?(address, submitted)
- Contact::ADDRESS_COMPONENTS.all? { |name|
- submitted[name].to_s.strip == address.public_send(name).to_s.strip
- }
- end
-
- # One address row's replacement line: the raw components spliced
- # and re-joined under the line's own header, or nil when the result
- # is blank throughout (blank is absent, the reader's own rule for
- # an ADR — the caller removes the line). A component that submits
- # its current reading keeps its own bytes — the po box always,
- # having no field, and any other the form left alone — where a
- # moved one re-escapes from the form; the splice over
- # still-escaped components is n_line's own rule,
- # unescape-then-re-escape not being byte-stable. A nil address is
- # an added row: seven fresh positions, po box empty, a bare ADR
- # header.
- #: (Contact::Address? address, Hash[String, untyped] submitted) -> String?
- def self.address_line(address, submitted)
- property = address&.property
- if property.nil?
- components = [""]
- else
- components = VCard.split_raw_components(property.value)
- end
- components << "" while components.length < 7
- Contact::ADDRESS_COMPONENTS.each_with_index do |name, index|
- position = index + 1
- value = submitted[name].to_s.strip
- current = address && address.public_send(name).to_s.strip
- components[position] = value == current ? components.fetch(position) : VCard.escape(value)
- end
- return if components.all?(&:empty?)
-
- header = property ? VCard.header_of(property) : "ADR:"
- "#{header}#{components.join(";")}"
- end
-
# One component off the wire: nil for blank, the integer it names
# otherwise. Digits only — a month select and ranged number inputs
# are the browser's own constraint, and this is the server's.
@@ -434,7 +339,7 @@ module ProTacts
# what the splice over the still-escaped value buys
# (docs/plans/2026-09-05-web-card-editor.md). A component that
# submits its current reading keeps its own bytes too, the rule
- # address_line already writes under: unescape-then-re-escape is
+ # Contact.address_value already writes under: unescape-then-re-escape is
# not byte-stable (VCard.split_raw_components), so a save that
# moved a phone number must not rewrite the name it left alone. A
# card with no N a form can read splices into a bare
@@ -463,8 +368,7 @@ module ProTacts
"N:#{components.join(";")}"
end
- private_class_method :edited_values, :edited_handles, :edited_addresses, :removed, :hash_field, :address_unchanged?,
- :address_line, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
+ private_class_method :edited_rows, :submitted_fields, :hash_field, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
:n_line, :company_name, :person_name, :edited_organization
end
end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 0aac558..9306cd0 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -40,11 +40,9 @@ module ProTacts
# lists them — exactly the rows the save can insert
# (docs/plans/2026-09-05-web-card-editor.md), because a type
# listed here that the save cannot write would be a row that
- # silently does nothing. Each name is the radio's value;
- # `new_<type>[]` is the single-value kinds' field name and
- # `new_address[<i>][<component>]` the structured one's, the
- # bare [] unable to carry a component set (Rack refuses a key
- # after an empty one). The birthday is the exception: its
+ # silently does nothing. Each name is the radio's value and
+ # `new_<type>[<i>][<field>]` its added row's fields
+ # (CardForm.edited_rows). The birthday is the exception: its
# added row names the model's own `birthday[]` group, there
# being no card line to insert and room for but one — see
# #addable_types for when the dialog offers it.
@@ -71,16 +69,11 @@ module ProTacts
["country", "country"],
].freeze #: Array[[String, String]]
- # A handle row's two fields, the service first because it says
- # what the handle is (docs/plans/2026-10-18-signal-handles.md,
- # "The service is the row's"), side by side on one line as a
- # birthday's are (admin.css). Field name to placeholder, the
- # address fields' shape.
- HANDLE_FIELDS = [
- ["service", "service"],
- ["value", "handle"],
- ].freeze #: Array[[String, String]]
- private_constant :HANDLE_FIELDS
+ # The input type of each single-value kind's value field: the
+ # keyboard a phone number or an email address wants, on the one
+ # client that has one.
+ VALUE_TYPES = {"phone" => "tel", "email" => "email", "handle" => "text"}.freeze #: Hash[String, String]
+ private_constant :VALUE_TYPES
# @rbs @contact: Contact
# @rbs @login: String
@@ -249,17 +242,9 @@ module ProTacts
input(type: "text", name: "nickname", value: @own.nickname,
placeholder: @own.nickname ? "removed on save" : nil)
end
- @own.phones.each do |phone|
- value_row(phone.label || "phone",
- type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
- end
- @own.emails.each do |email|
- value_row(email.label || "email",
- type: "email", name: "email[#{email.line.digest}]", value: email.value)
- end
- @own.handles.each do
- handle_row(it)
- end
+ @own.phones.each { value_row("phone", it) }
+ @own.emails.each { value_row("email", it) }
+ @own.handles.each { value_row("handle", it) }
@own.addresses.each do
address_row(it)
end
@@ -280,6 +265,7 @@ module ProTacts
end
end
add_property_dialog
+ label_words
end
end
end
@@ -308,31 +294,35 @@ module ProTacts
# Layout).
#
# The row is a div rather than the standing rows' label because
- # one template serves every kind and the address kind holds six
- # controls a label cannot name — so the single-value input
- # carries its own aria-label, and the address inputs their
- # placeholders.
+ # one template serves every kind, and each kind's row is the
+ # standing row's own shape: its label field in the type column
+ # and its value beside it, named by the add index rather than a
+ # digest — there is no line yet to digest, and Rack refuses a key
+ # after a bare [] (CardForm.edited_rows). Each `x-if` holds the
+ # one root Alpine allows it, so the two columns are two of them,
+ # every one a direct child of the row's grid.
#
- # `x-init` on the input rather than autofocus: it runs when the
+ # `x-init` on the value rather than autofocus: it runs when the
# element is created, which is exactly when the row is added,
# and there is nothing to focus on page load because `added`
- # starts empty.
+ # starts empty. The value rather than the label, the value being
+ # what the row is added for.
def added_rows
template(x_for: "(kind, i) in added", ":key": "i") do
div(class: "field") do
- span("x-text": "kind")
- # The single-value kinds: one input, its type and name
- # bound to the kind — the keyboard a phone number or an
- # email address wants, on the one client that has one.
- template("x-if": "kind === 'phone' || kind === 'email'") do
- input(":type": "kind === 'phone' ? 'tel' : kind", ":name": "`new_${kind}[]`",
+ template("x-if": "kind === 'birthday'") do
+ span("x-text": "kind")
+ end
+ template("x-if": "kind !== 'birthday'") do
+ input(type: "text", class: "row-label", autocomplete: "off",
+ ":name": "`new_${kind}[${i}][label]`", ":list": "`${kind}-labels`",
+ ":placeholder": "kind", ":aria-label": "`${kind} label`")
+ end
+ value_types = "{ #{VALUE_TYPES.map { |kind, type| "#{kind}: '#{type}'" }.join(", ")} }[kind]"
+ template("x-if": value_types) do
+ input(":type": value_types, ":name": "`new_${kind}[${i}][value]`",
":aria-label": "kind", "x-init": "$el.focus()")
end
- # The address kind: the same six component fields a
- # standing row gets, named by the add index rather than a
- # digest — there is no line yet to digest, and the
- # []-appended name the other kinds use cannot carry a
- # component set (Rack refuses a key after a bare []).
template("x-if": "kind === 'address'") do
div(class: "field-stack") do
ADDRESS_FIELDS.each_with_index do |(component, label), index|
@@ -342,17 +332,6 @@ module ProTacts
end
end
end
- # The handle kind: the standing row's two fields, named by
- # the add index for the address's reason.
- template("x-if": "kind === 'handle'") do
- div(class: "handle-row") do
- HANDLE_FIELDS.each_with_index do |(field, label), index|
- input(type: "text", ":name": "`new_handle[${i}][#{field}]`",
- placeholder: label, aria_label: label,
- "x-init": index.zero? ? "$el.focus()" : nil)
- end
- end
- end
# The birthday kind: the standing row's own three
# controls with nothing prefilled, naming the model's
# birthday[] group rather than a new_* name — no card
@@ -373,23 +352,52 @@ module ProTacts
end
end
- # A standing single-value row — a phone, an email — editing its
- # value and nothing else: the TYPE parameters ride in the line's
- # own header, which the save keeps (VCard.header_of), where a
- # header rebuilt from form fields would drop the parameters no
- # field models, and macOS writes three TYPE parameters on one
- # TEL. The digest in the field's name is the row's address, and
- # a blank value removes the line — the state is unconditional,
- # a row standing only over a line there is to lose. Identical
- # duplicate lines share a digest and therefore a field name, and
- # Rack keeps the last value of a duplicated name — editing one of
- # a pair of byte-identical rows means blanking one, saving, then
- # editing the other.
- #: (String caption, type: String, name: String, value: String) -> void
- def value_row(caption, type:, name:, value:)
- label(class: "field", data: {blank_removes: true}) do
- span { caption }
- input(type:, name:, value:, placeholder: "removed on save")
+ # A row's label in the type column, where the record card shows it
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md, "The type
+ # column's field"): a free word, its kind's usual ones offered
+ # beside it (#label_words), and the kind as its placeholder, so an
+ # unlabeled row reads as it always has. Blanking it is a relabel to
+ # nothing, never a removal, so admin.css's removal state asks after
+ # the value beside it and only strikes this. `autocomplete` off so
+ # the browser's memory of past entries does not crowd the list out.
+ #: (String field, String name, String? value) -> void
+ def label_field(field, name, value)
+ input(type: "text", class: "row-label", name:, value:, autocomplete: "off",
+ list: "#{field}-labels", placeholder: field, aria_label: "#{field} label")
+ end
+
+ # Each kind's suggested labels, one list per kind for every row of
+ # it, standing and added, to name by id.
+ #: () -> void
+ def label_words
+ CardForm::ROWS.each do |field, kind|
+ datalist(id: "#{field}-labels") do
+ kind.label_words.each { option(value: it) }
+ end
+ end
+ end
+
+ # A standing single-value row — a phone, an email, a handle —
+ # its label beside its value. Each field is the row's digest's,
+ # the address the save substitutes; the line's other parameters
+ # ride in its own header, which the save keeps (VCard.header_of),
+ # where a header rebuilt from form fields would drop the ones no
+ # field models — macOS writes three TYPE parameters on one TEL. A
+ # blank value removes the line, so the state is unconditional, a
+ # row standing only over a line there is to lose. A div rather
+ # than a label, as the address row is: it holds two controls, and
+ # each carries its own aria-label. Identical duplicate lines share
+ # a digest and therefore a field name, and Rack keeps the last
+ # value of a duplicated name — editing one of a pair of
+ # byte-identical rows means blanking one, saving, then editing the
+ # other.
+ #: (String field, Contact::Phone | Contact::Email | Contact::Handle row) -> void
+ def value_row(field, row)
+ name = "#{field}[#{row.line.digest}]"
+ div(class: "field", data: {blank_removes: true}) do
+ label_field(field, "#{name}[label]", row.label)
+ input(type: VALUE_TYPES.fetch(field), name: "#{name}[value]", value: row.value,
+ placeholder: "removed on save", aria_label: field)
end
end
@@ -511,24 +519,22 @@ module ProTacts
end
end
- # An address row: the type's caption and a stack of component
+ # An address row: the label field and a stack of component
# fields in the value column — the multi-line value's own lines
# (docs/DESIGN.md: a multi-line value breaks inside the value
- # column and stays in it). A div rather than the single-value
- # rows' label, because the row holds six controls and a label
- # names one — each input carries its placeholder as its
- # aria-label instead, and the caption is the type's. The digest
- # in each field's name is the row's address; the po box has no
- # field, and the save preserves its bytes. The removal state
- # rides only on a line with no po box: the save's removal is
- # blank throughout, po box included (CardForm.address_line), so
+ # column and stays in it), each input carrying its placeholder as
+ # its aria-label. The digest in each field's name is the row's
+ # address; the po box has no field, and the save preserves its
+ # bytes. The removal state rides only on a line with no po box:
+ # the save's removal is blank throughout, po box included
+ # (Contact::Address#edit), so
# a line surviving by its po box alone cannot be removed from
# this form at all — its row wears no state however blank it
# renders.
#: (Contact::Address address) -> void
def address_row(address)
div(class: "field", data: {blank_removes: address.po_box.nil?}) do
- span { address.label || "address" }
+ label_field("address", "address[#{address.line.digest}][label]", address.label)
div(class: "field-stack") do
ADDRESS_FIELDS.each do |component, label|
input(type: "text", name: "address[#{address.line.digest}][#{component}]",
@@ -538,23 +544,6 @@ module ProTacts
end
end
- # A standing handle row: the service and the handle, each a field
- # of the row's digest, and a blank handle removes the line
- # (CardForm.edited_handles), so the removal state is
- # unconditional, the single-value rows' own.
- #: (Contact::Handle handle) -> void
- def handle_row(handle)
- div(class: "field", data: {blank_removes: true}) do
- span { "handle" }
- div(class: "handle-row") do
- HANDLE_FIELDS.each do |field, label|
- input(type: "text", name: "handle[#{handle.line.digest}][#{field}]",
- value: handle.public_send(field), placeholder: label, aria_label: label)
- end
- end
- end
- end
-
# Where a property a contact does not have yet gets named
# (docs/DESIGN.md, "Adding is explicit"). It holds no value,
# so there is nothing staged out of sight and Cancel is a plain
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 483a128..ae83a16 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -137,28 +137,271 @@ module ProTacts
types.join(", ") unless types.empty?
end
+ # The words Contacts models as a TYPE value, by the property they
+ # type, each to its spelling there: what a client has been seen
+ # writing (docs/apple-contacts.md, "The client rewrites every card
+ # it touches", and the fixtures), uppercase as Contacts writes
+ # them. Any other word is a custom label, the safe direction to be
+ # wrong in (docs/plans/2026-10-22-a-row-edits-its-label.md, "The
+ # typed kinds' rule"). A word joins when a recorded session shows
+ # Contacts spelling it as a type.
+ TYPE_WORDS = {
+ "TEL" => {"cell" => "CELL", "home" => "HOME", "work" => "WORK"},
+ "EMAIL" => {"home" => "HOME", "work" => "WORK"},
+ "ADR" => {"home" => "HOME", "work" => "WORK"},
+ }.freeze #: Hash[String, Hash[String, String]]
+
+ # Apple's own word for a row of no particular kind, written in its
+ # wrapped spelling because that is how the 31 emails labeled Other
+ # in the macOS data carry it (docs/plans/2026-09-18-phone-labels.md,
+ # "What the data has").
+ OTHER = "_$!<Other>!$_" #: String
+
+ # The words the editor offers in a typed row's label field: the
+ # type words and Apple's Other.
+ #: (String name) -> Array[String]
+ def self.typed_words(name) = [*TYPE_WORDS.fetch(name).keys, "other"]
+
+ # A typed row — a phone, an email, an address — rewritten into the
+ # card under `value`, its value already in the line's spelling.
+ # A label that is nil or says what the row read leaves the header
+ # alone, byte for byte; any other takes off what the reader showed
+ # and writes the new word (#labeled_lines).
+ #: (VCard card, Phone | Email | Address row, String value, String? label) -> VCard
+ def self.edited_row(card, row, value, label)
+ if label.nil? || label == row.label.to_s.strip
+ return card.substitute(row.line.digest, ["#{VCard.header_of(row.property)}#{value}"])
+ end
+
+ label_line = row.label_line
+ card = card.substitute(label_line.digest, []) if label_line
+ card.substitute(row.line.digest, labeled_lines(card, row.property, row.types, value, label))
+ end
+
+ # An added typed row's lines into the card, a bare line of `name`
+ # under its label.
+ #: (VCard card, String name, String value, String? label) -> VCard
+ def self.added_row(card, name, value, label)
+ property = VCard::Parser::Property.new(group: nil, name:, parameters: [], value: "")
+ card.insert(labeled_lines(card, property, [], value, label.to_s))
+ end
+
+ # A phone's or an email's edit, one text value: a blank removes
+ # the row, a changed one re-escapes from the form, and an unchanged
+ # one under a new label keeps its bytes.
+ #: (VCard card, Phone | Email row, String? label, Hash[String, String] fields) -> VCard
+ def self.edited_value(card, row, label, fields)
+ submitted = fields.fetch("value")
+ return removed_row(card, row) if submitted.empty?
+
+ edited_row(card, row, submitted == row.value.strip ? row.property.value : VCard.escape(submitted), label)
+ end
+
+ # An added phone or email, nothing for a blank one: inserting
+ # absence is a no-op (docs/plans/2026-09-05-web-card-editor.md).
+ #: (VCard card, String name, String? label, Hash[String, String] fields) -> VCard
+ def self.added_value(card, name, label, fields)
+ value = fields.fetch("value")
+ value.empty? ? card : added_row(card, name, VCard.escape(value), label)
+ end
+
+ # A typed row's lines gone from the card: its own, and the
+ # `X-ABLabel` sharing its group, which would otherwise be left
+ # naming nothing (docs/plans/2026-10-22-a-row-edits-its-label.md,
+ # "Removing a row removes its label").
+ #: (VCard card, Phone | Email | Address row) -> VCard
+ def self.removed_row(card, row)
+ card = card.substitute(row.line.digest, [])
+ label_line = row.label_line
+ label_line ? card.substitute(label_line.digest, []) : card
+ end
+
+ # The line of `property` under `value` and the label `label`, and
+ # the X-ABLabel beside it where the label needs one. The types the
+ # reader showed (`shown`) come off and every other parameter stays
+ # — `pref`, the defaults the reader hides — so a relabel removes
+ # exactly what the field rendered. A label every comma-separated
+ # word of which is a type word goes in as TYPE values; any other
+ # goes in an X-ABLabel under the line's group, or the card's
+ # lowest free one (VCard#free_group). Blank writes neither.
+ #: (VCard card, VCard::Parser::Property property, Array[String] shown, String value, String label) -> Array[String]
+ def self.labeled_lines(card, property, shown, value, label)
+ kept = property.parameters.reject { |name, type| name.casecmp?("TYPE") && shown.include?(type.downcase) }
+ words = label.split(",").map(&:strip).reject(&:empty?)
+ none = {} #: Hash[String, String]
+ types = TYPE_WORDS.fetch(property.name.upcase, none)
+ if words.all? { types.key?(it.downcase) }
+ typed = words.map { ["TYPE", types.fetch(it.downcase)] } #: Array[[String, String]]
+ line = VCard::Parser::Property.new(group: property.group, name: property.name, parameters: [*kept, *typed], value:)
+ return ["#{VCard.header_of(line)}#{value}"]
+ end
+
+ group = property.group || card.free_group
+ text = label.casecmp?("other") ? OTHER : VCard.escape(label)
+ line = VCard::Parser::Property.new(group:, name: property.name, parameters: kept, value:)
+ ["#{VCard.header_of(line)}#{value}", "#{group}.X-ABLabel:#{text}"]
+ end
+
+ # An address's value, components spliced from the form's fields:
+ # the raw components re-joined, or nil when the result is blank
+ # throughout (blank is absent, the reader's own rule for an ADR —
+ # the caller removes the line). A component that submits its
+ # current reading keeps its own bytes — the po box always, having
+ # no field, and any other the form left alone — where a moved one
+ # re-escapes from the form; the splice over still-escaped
+ # components is n_line's own rule (Admin::CardForm),
+ # unescape-then-re-escape not being byte-stable. A nil address is
+ # an added row: seven fresh positions, po box empty.
+ #: (Address? address, Hash[String, String] fields) -> String?
+ def self.address_value(address, fields)
+ components = address ? VCard.split_raw_components(address.property.value) : [""]
+ components << "" while components.length < 7
+ ADDRESS_COMPONENTS.each_with_index do |name, index|
+ position = index + 1
+ value = fields.fetch(name)
+ current = address && address.public_send(name).to_s.strip
+ components[position] = value == current ? components.fetch(position) : VCard.escape(value)
+ end
+ components.join(";") unless components.all?(&:empty?)
+ end
+
+ private_class_method :labeled_lines
+
# Reopened rather than defined in the blocks above, for the reason
- # VCard::Parser::Property is.
+ # VCard::Parser::Property is. Each row answers the editor the same
+ # three things — its label, its fields by form name, and itself
+ # rewritten into a card — and each class writes an added one, so
+ # the save walks every kind alike and the difference between
+ # them is where each writes its label
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md, "The row writes
+ # its own label").
class Phone
+ #: () -> Array[String]
+ def self.label_words = Contact.typed_words("TEL")
+
+ #: () -> Array[String]
+ def self.field_names = %w[value]
+
#: () -> String?
def label = Contact.label_of(label_line, types)
+
+ #: () -> Hash[String, String]
+ def fields = {"value" => value}
+
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def edit(card, label, fields)
+ Contact.edited_value(card, self, label, fields)
+ end
+
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def self.add(card, label, fields)
+ Contact.added_value(card, "TEL", label, fields)
+ end
end
class Email
+ #: () -> Array[String]
+ def self.label_words = Contact.typed_words("EMAIL")
+
+ #: () -> Array[String]
+ def self.field_names = %w[value]
+
#: () -> String?
def label = Contact.label_of(label_line, types)
+
+ #: () -> Hash[String, String]
+ def fields = {"value" => value}
+
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def edit(card, label, fields)
+ Contact.edited_value(card, self, label, fields)
+ end
+
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def self.add(card, label, fields)
+ Contact.added_value(card, "EMAIL", label, fields)
+ end
end
class Address
+ #: () -> Array[String]
+ def self.label_words = Contact.typed_words("ADR")
+
+ #: () -> Array[String]
+ def self.field_names = ADDRESS_COMPONENTS
+
#: () -> String?
def label = Contact.label_of(label_line, types)
+
+ #: () -> Hash[String, String]
+ def fields
+ ADDRESS_COMPONENTS.to_h {
+ field = [it, public_send(it).to_s] #: [String, String]
+ field
+ }
+ end
+
+ # Removal is the reader's own rule (Contact#address_of): a row
+ # blank throughout, po box included, removes the line, where a
+ # partially blanked one keeps it — partial blanks are legal empty
+ # components.
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def edit(card, label, fields)
+ value = Contact.address_value(self, fields)
+ value ? Contact.edited_row(card, self, value, label) : Contact.removed_row(card, self)
+ end
+
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def self.add(card, label, fields)
+ value = Contact.address_value(nil, fields)
+ value ? Contact.added_row(card, "ADR", value, label) : card
+ end
end
class Handle
+ # Signal, the one service this server writes
+ # (docs/plans/2026-10-18-signal-handles.md); any other is typed.
+ #: () -> Array[String]
+ def self.label_words = %w[signal]
+
+ #: () -> Array[String]
+ def self.field_names = %w[value]
+
# A handle's word is its service, the column the record card has
# always shown it in.
#: () -> String?
def label = service
+
+ #: () -> Hash[String, String]
+ def fields = {"value" => value}
+
+ # A blank handle removes the line, a service alone being nothing
+ # to reach anyone at. A changed handle under an unchanged service
+ # swaps under the line's own header and scheme, the phones' rule;
+ # a changed service rewrites the header around the line's other
+ # parameters and moves the handle under `x-apple:`, the line's old
+ # scheme naming the old service's protocol
+ # (docs/plans/2026-10-18-signal-handles.md, "The service is the
+ # row's").
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def edit(card, label, fields)
+ submitted = fields.fetch("value")
+ return card.substitute(line.digest, []) if submitted.empty?
+
+ written =
+ if label.nil? || label == service.to_s.strip
+ "#{VCard.header_of(property)}#{scheme}:#{VCard.escape(submitted)}"
+ else
+ Contact.handle_line(label, submitted, property)
+ end
+ card.substitute(line.digest, [written])
+ end
+
+ #: (VCard card, String? label, Hash[String, String] fields) -> VCard
+ def self.add(card, label, fields)
+ value = fields.fetch("value")
+ value.empty? ? card : card.insert([Contact.handle_line(label.to_s, value)])
+ end
end
# A handle's line in Contacts.app's spelling, the service in
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index b65479a..c18edb2 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -165,6 +165,19 @@ module ProTacts
@properties = lines.filter_map { it.property }
end
+ # The lowest `itemN` property group no line of the card names, the
+ # spelling Contacts gives its own (docs/apple-contacts.md, "An
+ # annotation survives only on its own line"), for a line that
+ # needs a group to carry a label beside it. A group name compares
+ # without case, as a property name does.
+ #: () -> String
+ def free_group
+ taken = properties.filter_map { it.group&.downcase }
+ number = 1
+ number += 1 while taken.include?("item#{number}")
+ "item#{number}"
+ end
+
# Whether the parsed properties carry the envelope RFC 2426 section
# 4 requires of a card: BEGIN:VCARD first, END:VCARD last, and a
# VERSION in between. Deciding that is deliberately not the
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index d362581..df0031d 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -175,10 +175,11 @@ module ProTacts
end
# The params Admin::CardForm reads, held to the shapes the contact
- # and group editors post: each text field a string, each phone and
- # email field a hash of strings keyed by its row's digest, each
- # added phone or email a list of strings, and every address row,
- # added address and the birthday row a hash of component strings.
+ # and group editors post: each text field a string, every
+ # repeatable row — standing, keyed by its digest, or added, by its
+ # add index — a hash of its label and its kind's field strings
+ # (CardForm::ROWS), and the birthday row a hash of component
+ # strings.
# Any other shape is a POST made by hand, refused here as a 400
# (the error handler) rather than read around in CardForm. An
# absent field is a form that rendered no such rows — or a name
@@ -186,18 +187,12 @@ module ProTacts
#: () -> Hash[String, untyped]
def card_form_params
typecast_params.str(%w[first middle last organization nickname note])
- typecast_params.array(:str, %w[new_phone new_email])
- %w[phone email].each do |key|
- rows = typecast_params.Hash(key)
- rows&.each_key { typecast_params[key].str!(it) }
- end
- %w[address new_address].each do |key|
- rows = typecast_params.Hash(key)
- rows&.each_key { typecast_params[key][it].str(Contact::ADDRESS_COMPONENTS) }
- end
- %w[handle new_handle].each do |key|
- rows = typecast_params.Hash(key)
- rows&.each_key { typecast_params[key][it].str(%w[service value]) }
+ Admin::CardForm::ROWS.each do |field, kind|
+ fields = ["label", *kind.field_names]
+ [field, "new_#{field}"].each do |key|
+ rows = typecast_params.Hash(key)
+ rows&.each_key { typecast_params[key][it].str(fields) }
+ end
end
typecast_params["birthday"].str(%w[year month day]) if typecast_params.Hash("birthday")
request.params
diff --git a/public/admin.css b/public/admin.css
index 281be61..00ef738 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -250,36 +250,20 @@
color: var(--gl-color-danger);
}
-/* A handle row's two fields — service and handle — on one line in
- the value column: the service names what the handle is, so the two
- read as one value, the birthday row's reading. The service is the
- shorter word and takes the narrower share. On a phone the value
- column held each field to a stub that clipped its text, so below
- the date row's own threshold (27rem of row, asked of the row for
- that rule's reason, below) the pair stacks, the service over the
- handle, each the column's full width. */
-.card-body form.field-stack .field > .handle-row {
- display: flex;
- gap: var(--gl-space-2xs);
-}
-.card-body form.field-stack .field > .handle-row > input {
- flex: 1 1 0;
- min-width: 0;
+/* A row's label field (Admin::ContactsEdit#label_field) sits in the
+ type column the caption held and the record card's key still holds,
+ so it reads in the caption's register — Gloss's .field type, mono
+ uppercase at the step below the value — inside the box that says it
+ can be edited (docs/plans/2026-10-22-a-row-edits-its-label.md). The
+ word is stored as typed; only its showing is uppercase. */
+.card-body form.field-stack > .field > .row-label {
padding-block: var(--gl-space-3xs);
-}
-.card-body form.field-stack .field > .handle-row > input:last-child {
- flex-grow: 2;
-}
-.card-body form.field-stack > .field:has(> .handle-row) {
- container-type: inline-size;
-}
-@container (width < 27rem) {
- .card-body form.field-stack .field > .handle-row {
- flex-direction: column;
- }
- .card-body form.field-stack .field > .handle-row > input {
- flex: none;
- }
+ font-family: var(--gl-font-mono);
+ font-size: var(--gl-step--2);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--gl-color-text-secondary);
}
/* The date line breaks in two when its row cannot hold a whole
@@ -401,17 +385,25 @@
refused one. One gap, noted rather than scripted: a
whitespace-only value hides the placeholder and the strike with
it, while the save strips before it compares. */
-.field[data-blank-removes]:has(> input:placeholder-shown) > span,
+.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > :is(span, .row-label),
.field[data-blank-removes]:has(> textarea:placeholder-shown) > span,
-.field[data-blank-removes]:has(> .field-stack):not(:has(input:not(:placeholder-shown))) > span,
+.field[data-blank-removes]:has(> .field-stack):not(:has(> .field-stack > input:not(:placeholder-shown))) > :is(span, .row-label),
.field[data-blank-removes]:has(> .date-row):has(option[value=""]:checked):not(:has(input[type="number"]:not(:placeholder-shown))) > span {
text-decoration: line-through;
color: var(--gl-color-danger);
}
-.field[data-blank-removes]:has(> input:placeholder-shown) > input::placeholder,
+.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > input:not(.row-label)::placeholder,
.field[data-blank-removes]:has(> textarea:placeholder-shown) > textarea::placeholder {
color: var(--gl-color-danger);
}
+/* A label field's own placeholder is the kind it stands in for, and
+ is struck with the row. A pseudo-element answers no :is() above, so
+ it takes its own rule. */
+.field[data-blank-removes]:has(> input:not(.row-label):placeholder-shown) > .row-label::placeholder,
+.field[data-blank-removes]:has(> .field-stack):not(:has(> .field-stack > input:not(:placeholder-shown))) > .row-label::placeholder {
+ text-decoration: line-through;
+ color: var(--gl-color-danger);
+}
/* Gloss's field contract styles input and select but not textarea
(base.css only resets it), and the editor's note field is one —
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 8b71b91..f55e8fd 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -5,6 +5,11 @@
# carries its signature inline.
module ProTacts
class Contact
+ # The repeatable rows' classes, which the editor walks alike
+ # (Admin::CardForm.edited_rows). An alias the inline syntax has no
+ # spelling for.
+ type row_class = singleton(Phone) | singleton(Email) | singleton(Handle) | singleton(Address)
+
class Phone
attr_reader value: String
attr_reader label_line: VCard::Parser::Line?
diff --git a/test/pro_tacts/admin/test_card_form.rb b/test/pro_tacts/admin/test_card_form.rb
index 01c7bf8..a8426da 100644
--- a/test/pro_tacts/admin/test_card_form.rb
+++ b/test/pro_tacts/admin/test_card_form.rb
@@ -44,17 +44,20 @@ class CardFormTest < Minitest::Test
"first" => given.to_s, "middle" => additional.to_s, "last" => family.to_s,
"nickname" => own.nickname.to_s,
"note" => ProTacts::Admin::CardForm.note_of(own).to_s.gsub("\n", "\r\n"),
- "phone" => own.phones.to_h { [it.line.digest, it.value] },
- "email" => own.emails.to_h { [it.line.digest, it.value] },
- "address" => own.addresses.to_h { |address|
- [address.line.digest, ProTacts::Contact::ADDRESS_COMPONENTS.to_h { [it, address.public_send(it).to_s] }]
- },
+ "phone" => rows(own.phones),
+ "email" => rows(own.emails),
+ "handle" => rows(own.handles),
+ "address" => rows(own.addresses),
}
organization = ProTacts::Admin::CardForm.organization_of(contact.stored).to_s
params["organization"] = organization unless organization.empty?
params
end
+ # Each row's fields as it renders them: its label, empty for none,
+ # and its fields.
+ def rows(rows) = rows.to_h { [it.line.digest, {"label" => it.label.to_s, **it.fields}] }
+
# A save that changed nothing changes no byte, however the card spells
# what the form shows: parameters and groups the form never modeled,
# folds, LF terminators, escapes the writer spells another way,
@@ -93,6 +96,42 @@ class CardFormTest < Minitest::Test
end
end
+ ## Labels
+
+ # Whatever a row is relabelled to reads back as that word, whichever
+ # spelling the save picked for it — a type, an X-ABLabel, Apple's
+ # wrapped Other — over lines grouped, labeled, and typed every way a
+ # client writes them, the other rows untouched beside it
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md). A type word reads
+ # in the reader's lowercase, and blank reads as no label.
+ def test_a_relabelled_row_reads_back_as_its_label
+ Hegel.test(test_cases: 500) do |tc|
+ lines = Array.new(tc.draw(integers(min_value: 1, max_value: 3))) { |index|
+ group = tc.draw(optional(sampled_from(["item#{index + 1}", "ITEM#{index + 7}"])))
+ header = tc.draw(sampled_from(["TEL", "TEL;TYPE=cell", "TEL;type=HOME;type=VOICE;type=pref", "EMAIL;TYPE=INTERNET"]))
+ label = group && tc.draw(optional(sampled_from(["Google Voice", "_$!<Other>!$_"])))
+ ["#{group && "#{group}."}#{header}:+1-555-010#{index}\r\n", *(label ? ["#{group}.X-ABLabel:#{label}\r\n"] : [])]
+ }.flatten
+ stored = ["BEGIN:VCARD\r\n", "VERSION:3.0\r\n", "N:Lovelace;Ada;;;\r\n", "FN:Ada Lovelace\r\n", *lines, "UID:aiden\r\n", "END:VCARD\r\n"].join
+ contact = ProTacts::Contact.new(id: "aiden", stored: ProTacts::VCard.new(stored), birthday: nil, inherited: [])
+ field, rows = tc.draw(sampled_from([["phone", :phones], ["email", :emails]].select { contact.public_send(it[1]).any? }))
+ row = tc.draw(sampled_from(contact.public_send(rows)))
+ label = tc.draw(sampled_from(["", "home", "Work", "cell", "other", "Google Voice", "home, work", "lab, too"]))
+ params = untouched(contact)
+ params[field] = params.fetch(field).merge(row.line.digest => {"label" => label, "value" => row.value})
+
+ saved = ProTacts::Admin::CardForm.contact_card(contact, false, "Ada", "", "Lovelace", params)
+ after = ProTacts::Contact.new(id: "aiden", stored: saved, birthday: nil, inherited: [])
+ relabelled = after.public_send(rows).find { it.value == row.value }
+
+ expected = {"" => nil, "Work" => "work", "other" => "Other"}.fetch(label, label)
+ expected.nil? ? assert_nil(relabelled.label) : assert_equal(expected, relabelled.label)
+ assert_equal contact.public_send(rows).length, after.public_send(rows).length
+ # No label is left naming nothing, and none is lost.
+ assert_equal [*after.phones, *after.emails].count(&:label_line), saved.properties.count { it.name.casecmp?("X-ABLabel") }
+ end
+ end
+
## The birthday row
# Whatever the birthday row saves in a shape it serves — a month and
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7df8edd..063bcdf 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1805,7 +1805,7 @@ class AdminContactsPagesTest < Minitest::Test
end
# A po box has no field, and the save's removal is blank throughout,
- # po box included (Admin::CardForm.address_line) — so a line
+ # po box included (Contact::Address#edit) — so a line
# surviving by its po box alone cannot be removed from this form, and
# its row, blank as it renders, wears no removal state.
def test_a_po_box_only_address_row_wears_no_removal_state
@@ -1814,11 +1814,12 @@ class AdminContactsPagesTest < Minitest::Test
"ADR;TYPE=home:PO Box 1;;;;;",
)
- with_contacts({"ada" => po}) do
+ with_contacts({"ada" => po}) do |store|
get "/contacts/ada/edit"
+ digest = store.contact("ada").addresses.first.line.digest
assert_includes last_response.body,
- '<div class="field"><span>home</span><div class="field-stack">'
+ '<div class="field"><input type="text" class="row-label" name="address[' + digest + '][label]" value="home"'
end
end
@@ -1875,7 +1876,7 @@ class AdminContactsPagesTest < Minitest::Test
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", middle: "B.", last: "Lovelace",
note: "Countess of Lovelace.",
- company: "0", etag: store.contact("ada").etag, phone: {digest => "+1-555-0199"}
+ company: "0", etag: store.contact("ada").etag, phone: {digest => {"value" => "+1-555-0199"}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -1984,7 +1985,7 @@ class AdminContactsPagesTest < Minitest::Test
# component's own bytes, rather than rounding through unescape and
# re-escape — which is not byte-stable: `\x` is no escape the
# reader knows, so it survives the read and the writer would
- # double its backslash. The rule address_line already writes under
+ # double its backslash. The rule Contact.address_value already writes under
# (CardForm.n_line), at the name's grain.
def test_a_name_component_returned_unchanged_keeps_its_own_bytes
odd = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron\\x;;")
@@ -2046,10 +2047,11 @@ class AdminContactsPagesTest < Minitest::Test
## Editing phones
- # The phone rows: one value field per line, named by the line's
- # digest — the address the save substitutes — beside the type's own
- # caption. The blank row a new phone goes in is in the add dialog,
- # not the card (see below).
+ # The phone rows: a label field in the type column and a value field
+ # beside it, both named by the line's digest — the address the save
+ # substitutes. The label offers the kind's words from a list by id,
+ # and wears the kind as its placeholder. The blank row a new phone
+ # goes in is in the add dialog, not the card (see below).
def test_the_edit_screen_renders_a_digest_named_field_per_phone
with_contacts({"ada" => ADA}) do |store|
get "/contacts/ada/edit"
@@ -2057,14 +2059,19 @@ class AdminContactsPagesTest < Minitest::Test
digest = store.contact("ada").phones.first.line.digest
body = last_response.body
assert_includes body,
- '<label class="field" data-blank-removes><span>mobile</span>' \
- '<input type="tel" name="phone[' + digest + ']" value="+1-555-0100" placeholder="removed on save"></label>'
+ '<div class="field" data-blank-removes>' \
+ '<input type="text" class="row-label" name="phone[' + digest + '][label]" value="mobile" autocomplete="off" ' \
+ 'list="phone-labels" placeholder="phone" aria-label="phone label">' \
+ '<input type="tel" name="phone[' + digest + '][value]" value="+1-555-0100" placeholder="removed on save" ' \
+ 'aria-label="phone"></div>'
+ assert_includes body,
+ '<datalist id="phone-labels"><option value="cell"></option><option value="home"></option>' \
+ '<option value="work"></option><option value="other"></option></datalist>'
end
end
- # The edit screen's phone row captions render a label the same way
- # the show screen's keys do — both read Contact::Phone#label — while
- # the field keeps editing the number alone.
+ # The edit screen's phone row labels render the same word the show
+ # screen's keys do — both read Contact::Phone#label.
def test_the_edit_screen_captions_a_labeled_phone_with_its_label
labeled = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n",
"item1.TEL;type=CELL:+1-555-0100\r\nitem1.X-ABLabel:Google Voice\r\n")
@@ -2074,8 +2081,7 @@ class AdminContactsPagesTest < Minitest::Test
digest = store.contact("ada").phones.first.line.digest
assert_includes last_response.body,
- '<label class="field" data-blank-removes><span>Google Voice</span>' \
- '<input type="tel" name="phone[' + digest + ']" value="+1-555-0100" placeholder="removed on save"></label>'
+ '<input type="text" class="row-label" name="phone[' + digest + '][label]" value="Google Voice"'
end
end
@@ -2089,7 +2095,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => labeled}) do |store|
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- phone: {digest => ""}
+ phone: {digest => {"value" => ""}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2098,19 +2104,117 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ ## Editing labels
+
+ # A relabel posts the row's label and value both, the value as it
+ # rendered: the save writes the new word and leaves the value's bytes.
+ def relabel(store, field, label)
+ contact = store.contact("ada")
+ row = contact.own.public_send({"phone" => :phones, "email" => :emails, "address" => :addresses}.fetch(field)).first
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: contact.etag,
+ field => {row.line.digest => {"label" => label, **row.fields}}
+ assert_equal 303, last_response.status
+ store.contact("ada").vcard.to_s
+ end
+
+ # A word Contacts models goes in as a TYPE, in its spelling, in place
+ # of the types the label field showed
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md, "The typed kinds'
+ # rule").
+ def test_a_relabel_to_a_type_word_writes_the_type
+ with_contacts({"ada" => ADA}) do |store|
+ card = relabel(store, "phone", "Work")
+
+ assert_includes card, "TEL;TYPE=WORK:+1-555-0100\r\n"
+ end
+ end
+
+ # What the label never showed stays: `pref` and the default `voice`
+ # ride through a relabel, with the line's other parameters.
+ def test_a_relabel_keeps_the_types_the_label_did_not_show
+ apple = ADA.sub("TEL;TYPE=mobile:", "TEL;type=CELL;type=VOICE;type=pref:")
+
+ with_contacts({"ada" => apple}) do |store|
+ card = relabel(store, "phone", "home")
+
+ assert_includes card, "TEL;type=VOICE;type=pref;TYPE=HOME:+1-555-0100\r\n"
+ end
+ end
+
+ # Any other word is a custom label: an X-ABLabel under the card's
+ # lowest free group, straight after its row — the pair Contacts keeps
+ # bound (docs/apple-contacts.md, "An annotation survives only on its
+ # own line").
+ def test_a_relabel_to_any_other_word_writes_an_x_ablabel_in_a_free_group
+ taken = ADA.sub("EMAIL;", "item1.EMAIL;")
+
+ with_contacts({"ada" => taken}) do |store|
+ card = relabel(store, "phone", "Google Voice")
+
+ assert_includes card, "item2.TEL:+1-555-0100\r\nitem2.X-ABLabel:Google Voice\r\n"
+ assert_equal "Google Voice", store.contact("ada").phones.first.label
+ end
+ end
+
+ # A labeled row keeps its group: relabelled, the old label goes and
+ # the new one takes its place; relabelled to a type word, the label
+ # goes and the type replaces the types it hid behind.
+ def test_a_relabel_of_a_labeled_row_replaces_its_label
+ labeled = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n",
+ "item1.TEL;type=CELL:+1-555-0100\r\nURL:https://example.com\r\nitem1.X-ABLabel:Google Voice\r\n")
+
+ with_contacts({"ada" => labeled}) do |store|
+ card = relabel(store, "phone", "Signal number")
+ assert_includes card, "item1.TEL:+1-555-0100\r\nitem1.X-ABLabel:Signal number\r\nURL:https://example.com\r\n"
+ refute_includes card, "Google Voice"
+
+ card = relabel(store, "phone", "home")
+ assert_includes card, "item1.TEL;TYPE=HOME:+1-555-0100\r\nURL:https://example.com\r\n"
+ refute_includes card, "X-ABLabel"
+ end
+ end
+
+ # Other is Apple's own word and goes in wrapped, as the macOS data
+ # carries it; a blank label writes no label at all.
+ def test_other_writes_apples_wrapped_label_and_blank_writes_none
+ with_contacts({"ada" => ADA}) do |store|
+ card = relabel(store, "email", "other")
+ assert_includes card, "item1.EMAIL:ada@example.com\r\nitem1.X-ABLabel:_$!<Other>!$_\r\n"
+ assert_equal "Other", store.contact("ada").emails.first.label
+
+ card = relabel(store, "address", "")
+ assert_includes card, "ADR:;;12 Analytical Way;"
+ end
+ end
+
+ # An added row takes its label as a standing one does.
+ def test_an_added_row_lands_under_its_label
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
+ new_email: {"0" => {"label" => "work", "value" => "countess@example.com"}},
+ new_address: {"0" => {"label" => "lab", "street" => "1 Engine Row"}}
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "EMAIL;TYPE=WORK:countess@example.com\r\n"
+ assert_includes card, "item1.ADR:;;1 Engine Row;;;;\r\nitem1.X-ABLabel:lab\r\n"
+ end
+ end
+
## Editing emails
- # The email rows: the phone row's own shape over EMAIL — one value
- # field per line, named by the line's digest, beside the type's own
- # caption.
+ # The email rows: the phone row's own shape over EMAIL.
def test_the_edit_screen_renders_a_digest_named_field_per_email
with_contacts({"ada" => ADA}) do |store|
get "/contacts/ada/edit"
digest = store.contact("ada").emails.first.line.digest
assert_includes last_response.body,
- '<label class="field" data-blank-removes><span>home</span>' \
- '<input type="email" name="email[' + digest + ']" value="ada@example.com" placeholder="removed on save"></label>'
+ '<div class="field" data-blank-removes>' \
+ '<input type="text" class="row-label" name="email[' + digest + '][label]" value="home" autocomplete="off" ' \
+ 'list="email-labels" placeholder="email" aria-label="email label">' \
+ '<input type="email" name="email[' + digest + '][value]" value="ada@example.com" placeholder="removed on save" ' \
+ 'aria-label="email"></div>'
end
end
@@ -2125,7 +2229,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => two}) do |store|
digest = store.contact("ada").emails.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- email: {digest => "lovelace@example.com"}
+ email: {digest => {"value" => "lovelace@example.com"}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2143,7 +2247,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => apple}) do |store|
digest = store.contact("ada").emails.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- email: {digest => "lovelace@example.com"}
+ email: {digest => {"value" => "lovelace@example.com"}}
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s,
@@ -2157,7 +2261,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").emails.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- email: {digest => " "}
+ email: {digest => {"value" => " "}}
assert_equal 303, last_response.status
refute_includes store.contact("ada").vcard.to_s, "EMAIL"
@@ -2172,8 +2276,8 @@ class AdminContactsPagesTest < Minitest::Test
email_digest = store.contact("ada").emails.first.line.digest
address_digest = store.contact("ada").addresses.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
- company: "0", etag: store.contact("ada").etag, new_email: [""],
- email: {email_digest => "ada@example.com"},
+ company: "0", etag: store.contact("ada").etag, new_email: {"0" => {"value" => ""}},
+ email: {email_digest => {"value" => "ada@example.com"}},
address: {address_digest => {
"street" => "12 Analytical Way", "locality" => "London",
"region" => "England", "postal_code" => "NW1 1AA",
@@ -2190,7 +2294,8 @@ class AdminContactsPagesTest < Minitest::Test
def test_an_added_email_lands_as_a_bare_email_before_the_end
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- new_email: ["lovelace@example.com", "", "countess@example.com"]
+ new_email: {"0" => {"value" => "lovelace@example.com"}, "1" => {"value" => ""},
+ "2" => {"value" => "countess@example.com"}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2214,19 +2319,20 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # Two fields per handle, the service and the handle, each named by
- # the line's digest; the scheme is the line's, like its parameters,
+ # A handle row is the phone row's shape, its service the label in
+ # the type column; the scheme is the line's, like its parameters,
# and no field models it.
- def test_the_edit_screen_renders_a_service_and_a_handle_field_per_handle
+ def test_the_edit_screen_renders_a_handle_under_its_service
with_contacts({"ada" => SIGNAL}) do |store|
get "/contacts/ada/edit"
digest = store.contact("ada").handles.first.line.digest
assert_includes last_response.body,
- '<div class="field" data-blank-removes><span>handle</span><div class="handle-row">' \
- '<input type="text" name="handle[' + digest + '][service]" value="signal" placeholder="service" aria-label="service">' \
- '<input type="text" name="handle[' + digest + '][value]" value="ada.42" placeholder="handle" aria-label="handle">' \
- '</div></div>'
+ '<div class="field" data-blank-removes>' \
+ '<input type="text" class="row-label" name="handle[' + digest + '][label]" value="signal" autocomplete="off" ' \
+ 'list="handle-labels" placeholder="handle" aria-label="handle label">' \
+ '<input type="text" name="handle[' + digest + '][value]" value="ada.42" placeholder="removed on save" ' \
+ 'aria-label="handle"></div>'
end
end
@@ -2234,7 +2340,7 @@ class AdminContactsPagesTest < Minitest::Test
digest = store.contact("ada").handles.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", note: "Countess of Lovelace.",
etag: store.contact("ada").etag,
- handle: {digest => {"service" => service, "value" => value}}
+ handle: {digest => {"label" => service, "value" => value}}
assert_equal 303, last_response.status
store.contact("ada").vcard.to_s
end
@@ -2290,15 +2396,17 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # Each handle row posts as a hash of its two fields; any other shape
- # is a 400, the address rows' own rule.
- def test_a_handle_field_in_a_shape_the_form_never_sends_is_a_400
+ # Each repeatable row posts as a hash of its label and its fields;
+ # any other shape is a 400, the address rows' own rule.
+ def test_a_row_field_in_a_shape_the_form_never_sends_is_a_400
with_contacts({"ada" => SIGNAL}) do |store|
contact = store.contact("ada")
digest = contact.handles.first.line.digest
[{handle: {digest => "ada.42"}},
{handle: "ada.42"},
- {new_handle: ["ada.42"]}].each do |field|
+ {new_handle: ["ada.42"]},
+ {phone: {"0" * 64 => "+1-555-0199"}},
+ {new_email: ["ada@example.com"]}].each do |field|
post "/contacts/ada", {first: "Ada", last: "Lovelace", company: "0", etag: contact.etag}.merge(field)
assert_equal 400, last_response.status, field
@@ -2313,9 +2421,9 @@ class AdminContactsPagesTest < Minitest::Test
def test_added_handles_land_as_impp_lines_before_the_end
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- new_handle: {"0" => {"service" => "signal", "value" => "ada.42"},
- "1" => {"service" => "signal", "value" => ""},
- "2" => {"service" => "WhatsApp", "value" => "+1-555-0100"}}
+ new_handle: {"0" => {"label" => "signal", "value" => "ada.42"},
+ "1" => {"label" => "signal", "value" => ""},
+ "2" => {"label" => "WhatsApp", "value" => "+1-555-0100"}}
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s,
@@ -2331,7 +2439,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_an_added_service_drops_what_a_parameter_cannot_carry
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- new_handle: {"0" => {"service" => %(Sig"nal), "value" => "ada.42"}}
+ new_handle: {"0" => {"label" => %(Sig"nal), "value" => "ada.42"}}
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s, "IMPP;X-SERVICE-TYPE=Signal:x-apple:ada.42\r\n"
@@ -2340,7 +2448,7 @@ class AdminContactsPagesTest < Minitest::Test
## Editing addresses
- # An address row: the type's caption and a stack of component
+ # An address row: the label field and a stack of component
# fields in the value column, each named by the row's digest and
# its component — and no po box field, which the save preserves
# raw rather than letting a form near it.
@@ -2351,7 +2459,10 @@ class AdminContactsPagesTest < Minitest::Test
digest = store.contact("ada").addresses.first.line.digest
body = last_response.body
assert_includes body,
- '<div class="field" data-blank-removes><span>home</span><div class="field-stack">' \
+ '<div class="field" data-blank-removes>' \
+ '<input type="text" class="row-label" name="address[' + digest + '][label]" value="home" autocomplete="off" ' \
+ 'list="address-labels" placeholder="address" aria-label="address label">' \
+ '<div class="field-stack">' \
'<input type="text" name="address[' + digest + '][street]" value="12 Analytical Way" placeholder="street" aria-label="street">' \
'<input type="text" name="address[' + digest + '][extended]" placeholder="street 2" aria-label="street 2">' \
'<input type="text" name="address[' + digest + '][locality]" value="London" placeholder="city" aria-label="city">'
@@ -2812,15 +2923,20 @@ class AdminContactsPagesTest < Minitest::Test
body = (get("/contacts/ada/edit") && last_response.body)
assert_includes body, %(<div x-data="{ added: [], type: 'phone', company: false, firstBlank: false, lastBlank: false }">)
- # One template serves every kind: the single-value kinds get an
- # input whose type and name bind to the kind, the address kind
- # the same six component fields a standing row gets — named by
- # the add index, there being no line yet to digest.
+ # One template serves every kind, each the standing row's shape: a
+ # label field bound to the kind, then the single-value kinds an
+ # input whose type and name bind to it, the address kind the same
+ # six component fields a standing row gets — named by the add
+ # index, there being no line yet to digest.
assert_includes body,
'<template x-for="(kind, i) in added" :key="i"><div class="field">' \
- '<span x-text="kind"></span>' \
- '<template x-if="kind === \'phone\' || kind === \'email\'">' \
- '<input :type="kind === \'phone\' ? \'tel\' : kind" :name="`new_${kind}[]`" :aria-label="kind" x-init="$el.focus()">' \
+ '<template x-if="kind === \'birthday\'"><span x-text="kind"></span></template>' \
+ '<template x-if="kind !== \'birthday\'">' \
+ '<input type="text" class="row-label" autocomplete="off" :name="`new_${kind}[${i}][label]`" ' \
+ ':list="`${kind}-labels`" :placeholder="kind" :aria-label="`${kind} label`"></template>' \
+ '<template x-if="{ phone: \'tel\', email: \'email\', handle: \'text\' }[kind]">' \
+ '<input :type="{ phone: \'tel\', email: \'email\', handle: \'text\' }[kind]" ' \
+ ':name="`new_${kind}[${i}][value]`" :aria-label="kind" x-init="$el.focus()">' \
'</template>' \
'<template x-if="kind === \'address\'">' \
'<div class="field-stack">' \
@@ -2831,13 +2947,6 @@ class AdminContactsPagesTest < Minitest::Test
'<input type="text" :name="`new_address[${i}][postal_code]`" placeholder="postal code" aria-label="postal code">' \
'<input type="text" :name="`new_address[${i}][country]`" placeholder="country" aria-label="country">' \
'</div></template>'
- # The handle kind: the standing row's two fields, named by the
- # add index as the address's are.
- assert_includes body,
- '<template x-if="kind === \'handle\'"><div class="handle-row">' \
- '<input type="text" :name="`new_handle[${i}][service]`" placeholder="service" aria-label="service" x-init="$el.focus()">' \
- '<input type="text" :name="`new_handle[${i}][value]`" placeholder="handle" aria-label="handle">' \
- '</div></template>'
# The birthday kind: the standing row's own controls with
# nothing prefilled, naming the model's birthday[] group — no
# digest, a contact holding at most one, and no removal state,
@@ -2871,7 +2980,8 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_single_save_adds_every_phone_that_was_typed
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- new_phone: ["+1-555-0177", "+1-555-0188", "", ""]
+ new_phone: {"0" => {"value" => "+1-555-0177"}, "1" => {"value" => "+1-555-0188"},
+ "2" => {"value" => ""}, "3" => {"value" => ""}}
assert_equal 303, last_response.status
assert_equal ["+1-555-0100", "+1-555-0177", "+1-555-0188"],
@@ -2889,7 +2999,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => two}) do |store|
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- phone: {digest => "+1-555-0150"}
+ phone: {digest => {"value" => "+1-555-0150"}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2914,7 +3024,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => labeled}) do |store|
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- phone: {digest => "+1-555-0150"}
+ phone: {digest => {"value" => "+1-555-0150"}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2933,7 +3043,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => apple}) do |store|
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- phone: {digest => "+1-555-0150"}
+ phone: {digest => {"value" => "+1-555-0150"}}
assert_equal 303, last_response.status
assert_includes store.contact("ada").vcard.to_s,
@@ -2948,7 +3058,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => ADA}) do |store|
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- phone: {digest => " "}
+ phone: {digest => {"value" => " "}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2965,8 +3075,8 @@ class AdminContactsPagesTest < Minitest::Test
before = store.contact("ada").stored.to_s
digest = store.contact("ada").phones.first.line.digest
post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
- company: "0", etag: store.contact("ada").etag, new_phone: [""],
- phone: {digest => " +1-555-0100 "}
+ company: "0", etag: store.contact("ada").etag, new_phone: {"0" => {"value" => ""}},
+ phone: {digest => {"value" => " +1-555-0100 "}}
assert_equal 303, last_response.status
assert_equal before, store.contact("ada").stored.to_s
@@ -2979,7 +3089,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_an_added_phone_lands_as_a_bare_tel_before_the_end
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- new_phone: ["+1 (555) 020-0700, x9"]
+ new_phone: {"0" => {"value" => "+1 (555) 020-0700, x9"}}
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
@@ -2991,7 +3101,7 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_blank_add_row_inserts_nothing
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- new_phone: [" "]
+ new_phone: {"0" => {"value" => " "}}
assert_equal 303, last_response.status
assert_equal 1, store.contact("ada").phones.length
@@ -3004,8 +3114,8 @@ class AdminContactsPagesTest < Minitest::Test
def test_a_digest_the_card_never_had_is_ignored
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", company: "0", etag: store.contact("ada").etag,
- phone: {"0" * 64 => "+1-555-9999"},
- email: {"1" * 64 => "x@example.com"},
+ phone: {"0" * 64 => {"value" => "+1-555-9999"}},
+ email: {"1" * 64 => {"value" => "x@example.com"}},
address: {"2" * 64 => {"street" => "nowhere"}}
assert_equal 303, last_response.status