admin: email and address rows on the edit screen, saved by digest
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index fa3eb02..182af58 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -9,9 +9,9 @@ module ProTacts
# rather than an always-editable page, one form over the
# properties the save knows how to address. The cardinality-1 set
# — name, nickname, note — saves through VCard#replace under each
- # field; the phone rows save through VCard#substitute, each named
- # by its line's digest. Emails and addresses arrive with their
- # own stage, birthday last.
+ # field; the phone, email, and address rows save through
+ # VCard#substitute, each named by its line's digest. Birthday
+ # arrives last, with its own doc.
#
# The fields prefill from the accessors' unescaped readings, and
# blank equals absent on the way back (Web#edited_card), so write
@@ -21,16 +21,34 @@ module ProTacts
# entry point is the name, not the header search.
class ContactsEdit < Phlex::HTML
# The property types the add dialog offers, in the order it
- # lists them. One for now: phones are the only rows the save can
- # insert (docs/plans/2026-09-05-web-card-editor.md, stage 2),
- # and a type listed here that the save cannot write would be a
- # row that silently does nothing. Each name is both the radio's
- # value and half the field name the row posts under
- # (`new_<type>[]`), so adding a type here and a reader in
- # Web#edited_card is the whole of adding one.
- ADDABLE_TYPES = %w[phone].freeze #: Array[String]
+ # 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 both the radio's value
+ # and the row's field name: `new_<type>[]` for the single-value
+ # kinds, `new_address[<i>][<component>]` for the structured
+ # one, the bare [] unable to carry a component set (Rack
+ # refuses a key after an empty one).
+ ADDABLE_TYPES = %w[phone email address].freeze #: Array[String]
private_constant :ADDABLE_TYPES
+ # The address row's component fields, stacked in the value
+ # column in the order an address form reads — RFC 2426 section
+ # 3.2.1's own order minus the po box, which no screen shows and
+ # 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.
+ ADDRESS_FIELDS = [
+ ["street", "street"],
+ ["extended", "street 2"],
+ ["locality", "city"],
+ ["region", "region"],
+ ["postal_code", "postal code"],
+ ["country", "country"],
+ ].freeze #: Array[[String, String]]
+ private_constant :ADDRESS_FIELDS
+
# @rbs @contact: Contact
# @rbs @notice: String?
# @rbs @first: String?
@@ -111,6 +129,14 @@ module ProTacts
input(type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
end
end
+ # An email row, the phone row's own shape over EMAIL.
+ @contact.emails.each do |email|
+ label(class: "field") do
+ span { email.type || "email" }
+ input(type: "email", name: "email[#{email.line.digest}]", value: email.value)
+ end
+ end
+ @contact.addresses.each { address_row(it) }
added_rows
label(class: "field") do
span { "Note" }
@@ -143,12 +169,11 @@ module ProTacts
# That verb — make a row, now, without a round trip — is what
# CSS could not do and what Alpine is here for (see Layout).
#
- # The caption is the untyped fallback ContactsShow uses for a
- # property with no TYPE, which is what these lines are until a
- # client gives them one. The name is the type's own list
- # parameter, so the save reads `new_phone[]` today and
- # `new_email[]` the day the dialog offers it, with no new
- # wiring between here and Web#edited_phones.
+ # 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.
#
# `x-init` on the input rather than autofocus: it runs when the
# element is created, which is exactly when the row is added,
@@ -156,10 +181,51 @@ module ProTacts
# starts empty.
def added_rows
template(x_for: "(kind, i) in added", ":key": "i") do
- label(class: "field") do
+ div(class: "field") do
span("x-text": "kind")
- input(type: "tel", ":name": "`new_${kind}[]`",
- "x-init": "$el.focus()")
+ # 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 !== 'address'") do
+ input(":type": "kind === 'phone' ? 'tel' : kind", ":name": "`new_${kind}[]`",
+ ":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|
+ input(type: "text", ":name": "`new_address[${i}][#{component}]`",
+ placeholder: label, aria_label: label,
+ "x-init": index.zero? ? "$el.focus()" : nil)
+ end
+ end
+ end
+ end
+ end
+ end
+
+ # An address row: the type's caption 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.
+ #: (Contact::Address address) -> void
+ def address_row(address)
+ div(class: "field") do
+ span { address.type || "address" }
+ div(class: "field-stack") do
+ 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
@@ -180,11 +246,8 @@ module ProTacts
# the form, and what it changes is visible in the card the
# moment the popover closes.
#
- # A radio group with one option reads oddly today and is the
- # point: ADDABLE_TYPES is the list, emails and addresses join it
- # when the save can insert them (docs/plans/2026-09-05-web-card-editor.md,
- # stage 3), and neither this method nor the row template changes
- # when they do.
+ # The three options are the three kinds of row the save can
+ # insert; a fourth, birthday, arrives with its own doc.
#
# The radios share a `name` so they are one native group —
# arrow-key navigation and "1 of n" come from that, not from
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 888029c..860a417 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -34,6 +34,21 @@ module ProTacts
__dir__ #: String
).parent.parent / "public" #: Pathname
+ # ADR's editable components: field name to position in the value
+ # (RFC 2426 section 3.2.1, minus the leading po box at position 0
+ # — no screen shows one, and the save preserves its bytes rather
+ # than letting a form field near them). In the value's own order,
+ # which is the order a rebuilt line's components join in.
+ ADDRESS_COMPONENTS = {
+ "extended" => 1,
+ "street" => 2,
+ "locality" => 3,
+ "region" => 4,
+ "postal_code" => 5,
+ "country" => 6,
+ }.freeze #: Hash[String, Integer]
+ private_constant :ADDRESS_COMPONENTS
+
# The store this app serves from. config.ru builds it and hands it in;
# nothing here reaches for a global to find one, which is what lets a
# test point the app at a throwaway database. Kept in Roda's own opts
@@ -706,7 +721,9 @@ module ProTacts
card = card.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
card = card.replace("NICKNAME", text_lines("NICKNAME", nickname))
card = card.replace("NOTE", text_lines("NOTE", note))
- edited_phones(contact, card, params)
+ card = edited_phones(contact, card, params)
+ card = edited_emails(contact, card, params)
+ edited_addresses(contact, card, params)
end
# The phones' half of the surgical save: each row names its line
@@ -755,6 +772,118 @@ module ProTacts
card.insert(added)
end
+ # The emails' half of the surgical save: the phones' own walk over
+ # EMAIL — each row names its line by digest, an unchanged row is
+ # skipped, a changed one swaps the value under the line's own
+ # header, a blank removes the line, and the add dialog's rows land
+ # as bare EMAIL lines before END:VCARD.
+ #: (Contact contact, VCard card, Hash[String, untyped] params) -> VCard
+ def edited_emails(contact, card, params)
+ rows = params["email"]
+ if rows.is_a?(Hash)
+ contact.emails.each do |email|
+ property = email.line.property
+ next if property.nil?
+
+ submitted = rows[email.line.digest]
+ next if submitted.nil? || submitted.to_s.strip == email.value
+
+ value = submitted.to_s.strip
+ card = card.substitute(
+ email.line.digest,
+ value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
+ )
+ end
+ end
+
+ submitted = Array(params["new_email"]) #: Array[untyped]
+ added = submitted.filter_map do
+ value = it.to_s.strip
+ "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
+ end #: Array[String]
+ card.insert(added)
+ end
+
+ # The addresses' half of the surgical save: the same
+ # digest-addressed walk, over a row that is six fields rather
+ # than one (Admin::ContactsEdit). A row unchanged throughout is
+ # skipped whole — the phones' rule, keeping an untouched line's
+ # bytes its own — and a changed one is rebuilt by the splice
+ # below. 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 card, Hash[String, untyped] params) -> VCard
+ def edited_addresses(contact, card, params)
+ rows = params["address"]
+ if rows.is_a?(Hash)
+ contact.addresses.each do |address|
+ # The property is nil only for a line that would not read —
+ # the phones' own guard.
+ property = address.line.property
+ next if property.nil?
+
+ submitted = rows[address.line.digest]
+ next if !submitted.is_a?(Hash) || address_unchanged?(address, submitted)
+
+ line = address_line(address, submitted)
+ card = card.substitute(address.line.digest, line ? [line] : [])
+ end
+ 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.
+ submitted = params["new_address"]
+ added = submitted.is_a?(Hash) ? submitted.values : Array(submitted) #: Array[untyped]
+ card.insert(added.filter_map { address_line(nil, it) })
+ end
+
+ # A row is unchanged when every field submits its current
+ # reading — the phones' skip at the component grain.
+ #: (Contact::Address address, untyped submitted) -> bool
+ def address_unchanged?(address, submitted)
+ ADDRESS_COMPONENTS.keys.all? do |name|
+ submitted[name].to_s.strip == address.public_send(name).to_s
+ end
+ 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, untyped submitted) -> String?
+ def address_line(address, submitted)
+ return unless submitted.is_a?(Hash)
+
+ property = address && address.line.property
+ if property.nil?
+ components = [""]
+ else
+ components = VCard.split_raw_components(property.value)
+ end
+ components << "" while components.length < 7
+ ADDRESS_COMPONENTS.each do |name, position|
+ value = submitted[name].to_s.strip
+ current = address && address.public_send(name).to_s
+ 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(";")}\r\n"
+ end
+
# A text property's replacement lines: the escaped value (RFC 2426
# section 2.4.2 — the value is text, the line is structure), or none
# at all — blank equals absent, the whole-property rule.
diff --git a/public/admin.css b/public/admin.css
index 3f1e39a..072966f 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -77,8 +77,11 @@
popover is not a record, and the two should not diverge just
because one of them opens over this screen. */
.card-body form.field-stack { gap: var(--gl-space-2xs); }
-.card-body form.field-stack > label.field > input,
-.card-body form.field-stack > label.field > textarea { padding-block: var(--gl-space-2xs); }
+.card-body form.field-stack > .field > input,
+.card-body form.field-stack > .field > textarea,
+.card-body form.field-stack > .field > .field-stack > input {
+ padding-block: var(--gl-space-2xs);
+}
/* An editor row is a detail row that happens to be editable: caption
in the type column, control in the value column, on .detail-grid's
@@ -90,13 +93,27 @@
eight rows deep before a contact has an email. Baseline for the
reason .detail-grid gives — caption and control carry different
type sizes, so lining their boxes up leaves their text on
- different lines. */
-.card-body form.field-stack > label.field {
+ different lines. The class rather than the element: a row is a
+ label when it holds one control and a div when it holds a stack
+ of them (an address — see the nested-stack rule below), and both
+ are the record's rows. */
+.card-body form.field-stack > .field {
display: grid;
grid-template-columns: var(--type-column) 1fr;
column-gap: var(--gl-space-s);
align-items: baseline;
}
+/* A row whose control is a stack of inputs — an address's component
+ fields — holds the multi-line value's own lines in the value
+ column (docs/DESIGN.md), so the stack closes to the record's own
+ row gap and each input sheds the same block padding every other
+ row's control sheds (the selector above it). Reached by descent
+ from the record's row, which the nested stack is part of; the
+ dialogs' fields are no form.field-stack's children and keep
+ Gloss's own. */
+.card-body form.field-stack .field > .field-stack {
+ gap: var(--gl-space-2xs);
+}
/* A multiline control has no single line to align a caption to, and
what a browser calls its baseline is not settled — a scroll
container's can be synthesized from its border box, which would
@@ -105,10 +122,10 @@
holds the textarea's first line off its own edge: the block
padding above plus the border. Derived, not eyeballed, so the two
move together if either changes. */
-.card-body form.field-stack > label.field:has(textarea) {
+.card-body form.field-stack > .field:has(textarea) {
align-items: start;
}
-.card-body form.field-stack > label.field:has(textarea) > span {
+.card-body form.field-stack > .field:has(textarea) > span {
padding-block-start: calc(var(--gl-space-2xs) + 1px);
}
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 60ca8c8..ac95caf 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -781,6 +781,284 @@ class AdminContactsPagesTest < Minitest::Test
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.
+ 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"><span>home</span><input type="email" name="email[' + digest + ']" value="ada@example.com"></label>'
+ end
+ end
+
+ # The addressed edit at work over EMAIL: the submitted row swaps its
+ # line's value under the line's own header, and every other byte of
+ # the stored card — the other EMAIL-able lines included — stands
+ # untouched.
+ def test_saving_a_changed_email_replaces_only_its_line
+ two = ADA.sub("EMAIL;TYPE=home:ada@example.com\r\n",
+ "EMAIL;TYPE=home:ada@example.com\r\nEMAIL;TYPE=work:countess@example.com\r\n")
+
+ with_contacts({"ada" => two}) do |store|
+ digest = store.contact("ada").emails.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ email: {digest => "lovelace@example.com"}
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "EMAIL;TYPE=home:lovelace@example.com\r\n"
+ assert_includes card, "EMAIL;TYPE=work:countess@example.com\r\n"
+ refute_includes card, "ada@example.com"
+ end
+ end
+
+ # The header is the line's own, kept byte for byte — the phones'
+ # own rule, over the parameters an EMAIL line can carry.
+ def test_a_saved_email_keeps_its_lines_own_parameters
+ apple = ADA.sub("EMAIL;TYPE=home:ada@example.com", "EMAIL;type=INTERNET;type=HOME;type=pref:ada@example.com")
+
+ with_contacts({"ada" => apple}) do |store|
+ digest = store.contact("ada").emails.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ email: {digest => "lovelace@example.com"}
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s,
+ "EMAIL;type=INTERNET;type=HOME;type=pref:lovelace@example.com\r\n"
+ end
+ end
+
+ # Blank equals absent for a row with a digest: the blank removes
+ # the line, the phones' own rule.
+ def test_a_blank_email_row_removes_the_line
+ with_contacts({"ada" => ADA}) do |store|
+ digest = store.contact("ada").emails.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ email: {digest => " "}
+
+ assert_equal 303, last_response.status
+ refute_includes store.contact("ada").vcard.to_s, "EMAIL"
+ end
+ end
+
+ # An unchanged row never rewrites its line, and the whole save
+ # leaves the stored card byte for byte as it lay.
+ def test_an_unchanged_email_and_address_leave_the_stored_card_byte_identical
+ with_contacts({"ada" => ADA}) do |store|
+ before = store.contact("ada").stored.to_s
+ 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.",
+ etag: store.contact("ada").etag, new_email: "",
+ email: {email_digest => "ada@example.com"},
+ address: {address_digest => {
+ "street" => "12 Analytical Way", "locality" => "London",
+ "region" => "England", "postal_code" => "NW1 1AA",
+ "country" => "United Kingdom",
+ }}
+
+ assert_equal 303, last_response.status
+ assert_equal before, store.contact("ada").stored.to_s
+ end
+ end
+
+ # The revealed row: a value lands as a bare EMAIL before END:VCARD,
+ # and a blank one inserts nothing — the phones' own rules.
+ 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", etag: store.contact("ada").etag,
+ new_email: ["lovelace@example.com", "", "countess@example.com"]
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_equal ["ada@example.com", "lovelace@example.com", "countess@example.com"],
+ store.contact("ada").emails.map(&:value)
+ assert_includes card, "EMAIL:countess@example.com\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n"
+ end
+ end
+
+ ## Editing addresses
+
+ # An address row: the type's caption 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.
+ def test_the_edit_screen_renders_component_fields_per_address
+ with_contacts({"ada" => ADA}) do |store|
+ get "/contacts/ada/edit"
+
+ digest = store.contact("ada").addresses.first.line.digest
+ body = last_response.body
+ assert_includes body,
+ '<div class="field"><span>home</span><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">'
+ assert_includes body,
+ 'name="address[' + digest + '][postal_code]" value="NW1 1AA" placeholder="postal code"'
+ assert_includes body,
+ 'name="address[' + digest + '][country]" value="United Kingdom" placeholder="country"'
+ refute_includes body, "po_box"
+ end
+ end
+
+ # The addressed edit at work over ADR: the submitted components
+ # swap under the line's own header, and every other byte of the
+ # stored card stands untouched.
+ def test_saving_a_changed_address_replaces_only_its_line
+ with_contacts({"ada" => ADA}) do |store|
+ digest = store.contact("ada").addresses.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ address: {digest => {
+ "street" => "5 Analytical Way", "locality" => "London",
+ "region" => "England", "postal_code" => "NW1 1AA",
+ "country" => "United Kingdom",
+ }}
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "ADR;TYPE=home:;;5 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
+ assert_includes card, "TEL;TYPE=mobile:+1-555-0100\r\n"
+ assert_includes card, "EMAIL;TYPE=home:ada@example.com\r\n"
+
+ follow_redirect!
+ assert_includes last_response.body, "5 Analytical Way"
+ end
+ end
+
+ # The header is the line's own, kept byte for byte — the phones'
+ # own rule, over the parameters an ADR line can carry.
+ def test_a_saved_address_keeps_its_lines_own_parameters
+ apple = ADA.sub(
+ "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+ "ADR;type=HOME;type=pref:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+ )
+
+ with_contacts({"ada" => apple}) do |store|
+ digest = store.contact("ada").addresses.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ address: {digest => {
+ "street" => "5 Analytical Way", "locality" => "London",
+ "region" => "England", "postal_code" => "NW1 1AA",
+ "country" => "United Kingdom",
+ }}
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s,
+ "ADR;type=HOME;type=pref:;;5 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
+ end
+ end
+
+ # Removal is the reader's own rule: a row blank throughout, po box
+ # included, removes the line.
+ def test_an_address_row_blank_throughout_removes_the_line
+ with_contacts({"ada" => ADA}) do |store|
+ digest = store.contact("ada").addresses.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ address: {digest => {
+ "street" => " ", "extended" => "", "locality" => "",
+ "region" => "", "postal_code" => "", "country" => "",
+ }}
+
+ assert_equal 303, last_response.status
+ refute_includes store.contact("ada").vcard.to_s, "ADR"
+ end
+ end
+
+ # A partially blanked row keeps its line — partial blanks are legal
+ # empty components, the reader's own rule in the other direction.
+ def test_a_partially_blanked_address_keeps_the_line
+ with_contacts({"ada" => ADA}) do |store|
+ digest = store.contact("ada").addresses.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ address: {digest => {
+ "street" => "12 Analytical Way", "locality" => "London",
+ "region" => "", "postal_code" => "", "country" => "",
+ }}
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s,
+ "ADR;TYPE=home:;;12 Analytical Way;London;;;\r\n"
+ end
+ end
+
+ # The po box and any component the form left alone keep their own
+ # bytes — n_line's splice rule at the component grain, where a
+ # rebuild through unescape and re-escape is not byte-stable (the
+ # unrecognized `\\q` escape would come back doubled).
+ def test_the_addresss_untouched_components_are_preserved_byte_for_byte
+ po_box = ADA.sub(
+ "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",
+ "ADR;TYPE=home:P.O. Box 5;Apt \\q;12 Way\\, West;London;England;NW1 1AA;United Kingdom",
+ )
+
+ with_contacts({"ada" => po_box}) do |store|
+ digest = store.contact("ada").addresses.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ address: {digest => {
+ "street" => "12 Way, West", "extended" => "Apt \\q",
+ "locality" => "Manchester", "region" => "England",
+ "postal_code" => "NW1 1AA", "country" => "United Kingdom",
+ }}
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s,
+ "ADR;TYPE=home:P.O. Box 5;Apt \\q;12 Way\\, West;Manchester;England;NW1 1AA;United Kingdom\r\n"
+ end
+ end
+
+ # A component is text and the value is structure: punctuation
+ # escapes in the written line (RFC 2426 section 2.4.2).
+ def test_address_punctuation_escapes_in_the_written_line
+ with_contacts({"ada" => ADA}) do |store|
+ digest = store.contact("ada").addresses.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ address: {digest => {
+ "street" => "12 Way, East; Annexe", "locality" => "London",
+ "region" => "England", "postal_code" => "NW1 1AA",
+ "country" => "United Kingdom",
+ }}
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s,
+ "ADR;TYPE=home:;;12 Way\\, East\\; Annexe;London;England;NW1 1AA;United Kingdom\r\n"
+ end
+ end
+
+ # The revealed address row: the components land in the value's own
+ # order — po box empty, extended before street (RFC 2426 section
+ # 3.2.1) — whatever order the form stacks them in, and a row blank
+ # throughout inserts nothing, the new-row rule.
+ def test_an_added_address_lands_in_component_order_before_the_end
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ new_address: {"1" => {
+ "street" => "5 Analytical Way", "extended" => "Apt 9",
+ "locality" => "London", "country" => "United Kingdom",
+ }}
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "ADR:;Apt 9;5 Analytical Way;London;;;United Kingdom\r\nBDAY:1985-12-10\r\nEND:VCARD\r\n"
+ assert_includes card, "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
+ end
+ end
+
+ def test_a_blank_address_add_row_inserts_nothing
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ new_address: {"0" => {"street" => " ", "country" => ""}}
+
+ assert_equal 303, last_response.status
+ assert_equal 1, store.contact("ada").addresses.length
+ end
+ end
+
## Adding a property
# The add affordance: a trigger on the back link's line — the place
@@ -795,13 +1073,14 @@ class AdminContactsPagesTest < Minitest::Test
'<a href="/contacts/ada" class="type-label">‹ Ada Lovelace</a>' \
'<button type="button" data-size="sm" popovertarget="add-property">add property</button></div>'
assert_includes body, '<dialog id="add-property" popover="auto">'
- # A radio group of one today: ADDABLE_TYPES is what grows, and
- # neither the dialog nor the row template changes when it does.
- # The shared `name` is what makes them a native group; it is
- # never submitted, the dialog having no form owner.
+ # A radio group of every kind the save can insert: ADDABLE_TYPES
+ # is the list. The shared `name` is what makes them a native
+ # group; it is never submitted, the dialog having no form owner.
assert_includes body,
'<div class="field-stack" role="radiogroup" aria-label="Type">' \
- '<label><input type="radio" name="add-type" value="phone" x-model="type">phone</label></div>'
+ '<label><input type="radio" name="add-type" value="phone" x-model="type">phone</label>' \
+ '<label><input type="radio" name="add-type" value="email" x-model="type">email</label>' \
+ '<label><input type="radio" name="add-type" value="address" x-model="type">address</label></div>'
assert_includes body,
'<button type="button" popovertarget="add-property" popovertargetaction="hide">Cancel</button>'
# Add makes the row and then hides the popover, in that order —
@@ -819,10 +1098,25 @@ class AdminContactsPagesTest < Minitest::Test
body = (get("/contacts/ada/edit") && last_response.body)
assert_includes body, %(<div x-data="{ added: [], type: 'phone' }">)
+ # 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.
assert_includes body,
- '<template x-for="(kind, i) in added" :key="i"><label class="field">' \
+ '<template x-for="(kind, i) in added" :key="i"><div class="field">' \
'<span x-text="kind"></span>' \
- '<input type="tel" :name="`new_${kind}[]`" x-init="$el.focus()"></label></template>'
+ '<template x-if="kind !== \'address\'">' \
+ '<input :type="kind === \'phone\' ? \'tel\' : kind" :name="`new_${kind}[]`" :aria-label="kind" x-init="$el.focus()">' \
+ '</template>' \
+ '<template x-if="kind === \'address\'">' \
+ '<div class="field-stack">' \
+ '<input type="text" :name="`new_address[${i}][street]`" placeholder="street" aria-label="street" x-init="$el.focus()">' \
+ '<input type="text" :name="`new_address[${i}][extended]`" placeholder="street 2" aria-label="street 2">' \
+ '<input type="text" :name="`new_address[${i}][locality]`" placeholder="city" aria-label="city">' \
+ '<input type="text" :name="`new_address[${i}][region]`" placeholder="region" aria-label="region">' \
+ '<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></div></template>'
# The template is inside the edit form, so one Save writes every
# row it made along with everything else. Indexed from the
# form's start, the header's search form closing before both.
@@ -945,16 +1239,21 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # The addresses come from the contact, never the request: a
- # doctored digest names no row and touches nothing, where a
- # doctored index into a list would have rewritten some other line.
+ # The addresses and emails come from the contact, never the
+ # request: a doctored digest names no row and touches nothing, where
+ # a doctored index into a list would have rewritten some other line.
def test_a_digest_the_card_never_had_is_ignored
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
- phone: {"0" * 64 => "+1-555-9999"}
+ phone: {"0" * 64 => "+1-555-9999"},
+ email: {"1" * 64 => "x@example.com"},
+ address: {"2" * 64 => {"street" => "nowhere"}}
assert_equal 303, last_response.status
- assert_includes store.contact("ada").vcard.to_s, "TEL;TYPE=mobile:+1-555-0100\r\n"
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "TEL;TYPE=mobile:+1-555-0100\r\n"
+ assert_includes card, "EMAIL;TYPE=home:ada@example.com\r\n"
+ assert_includes card, "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
end
end