admin: add the birthday from the dialog over an empty row
An always-rendered row was a scaffolded empty attribute — the shape
docs/DESIGN.md refuses, and every other property had already shed it.
The birthday is the dialog's first conditional type, a card holding
at most one: its radio leaves the list once the row stands, and Add
guards the push.
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 3b98550..ff85bde 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -29,17 +29,18 @@ module ProTacts
# destructive is what that color means there, carried by the
# caption rather than the boxes' borders, a blank row being a
# valid save and Gloss's danger border the aria-invalid
- # contract. The birthday row earns a remove control of its own
- # over a held birthday, three separate blanks being a rule
- # nobody can guess: one click empties the row (Alpine's own
- # verb, a mutation markup cannot do) and the blank rule does the
- # rest. The state itself is CSS over :placeholder-shown, not
+ # contract. The birthday row renders only over a held birthday —
+ # absence is added by the dialog like any other property — and
+ # earns a remove control of its own, three separate blanks
+ # being a rule nobody can guess: one click empties the row
+ # (Alpine's own verb, a mutation markup cannot do) and the blank
+ # rule does the rest. The state itself is CSS over :placeholder-shown, not
# Alpine — revealing a state over standing elements is markup's
# job (see Layout). The rows the add dialog reveals wear none of
- # it, their blank a no-op rather than a removal; the rows that
- # render whether or not the property exists wear it only over
- # something to lose (each such row's comment says which). The
- # etag rides
+ # it, their blank a no-op rather than a removal; the nickname
+ # and note rows, which render whether or not the card carries
+ # the property, wear it only over something to lose (each row's
+ # own comment says which). The etag rides
# along hidden — the snapshot guard's half, the POST's refusal
# being the other. `autofocus` on the first field: this screen's
# entry point is the name, not the header search.
@@ -48,12 +49,15 @@ 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 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]
+ # 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
+ # 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.
+ ADDABLE_TYPES = %w[phone email address birthday].freeze #: Array[String]
private_constant :ADDABLE_TYPES
# The address row's component fields, stacked in the value
@@ -223,7 +227,7 @@ module ProTacts
# 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
+ template("x-if": "kind !== 'address' && kind !== 'birthday'") do
input(":type": "kind === 'phone' ? 'tel' : kind", ":name": "`new_${kind}[]`",
":aria-label": "kind", "x-init": "$el.focus()")
end
@@ -241,6 +245,22 @@ module ProTacts
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
+ # line exists to insert, the save parses the group into
+ # the model, and no digest is needed when a contact can
+ # hold but one. No removal state, the added rows'
+ # exemption: three blanks over a contact with no
+ # birthday is nothing, not a removal. A second one
+ # cannot arrive — the dialog stops offering the type
+ # once this row stands, and Add's handler guards the
+ # push anyway.
+ template("x-if": "kind === 'birthday'") do
+ div(class: "date-row") do
+ birthday_controls(nil, focus: true)
+ end
+ end
end
end
end
@@ -249,55 +269,68 @@ module ProTacts
# than the card's bytes (docs/plans/2026-09-07-web-birthday-editor.md)
# — a partial date has no vCard 3.0 spelling, so there is no
# line to address and the prefill is the model
- # (Contact#birthday), not a reading off the card. Three controls
- # in the value column, month-day-year to match the prose
- # Birthday#to_s renders; a select for the month because a name
- # is the friendlier read and constrains the value by
- # construction, number inputs with native ranges for the day and
- # year. A blank component is an absence and three blanks remove
- # the birthday, the form's blank-equals-absent; a day without
- # its month is refused at the save, the grammar's own rule.
- # Every shape the grammar admits is editable, including the ones
- # no client renders — they live here and go nowhere, their
- # documented fate. The removal state rides only on a held
- # birthday — the row renders empty without one, and three blanks
- # over no birthday delete nothing.
+ # (Contact#birthday), not a reading off the card. It renders
+ # only over a held birthday: an empty row was an attribute
+ # rendering nothing (docs/DESIGN.md), and the way a birthday
+ # arrives is the add dialog's, like every other property. A
+ # blank component is an absence and three blanks remove the
+ # birthday, the form's blank-equals-absent; a day without its
+ # month is refused at the save, the grammar's own rule. Every
+ # shape the grammar admits is editable, including the ones no
+ # client renders — they live here and go nowhere, their
+ # documented fate. Every standing row holds a birthday, so
+ # the removal state rides on it unconditionally.
#: () -> void
def birthday_row
birthday = @contact.birthday
- div(class: "field", data: {blank_removes: !!birthday}) do
+ return unless birthday
+
+ div(class: "field", data: {blank_removes: true}) do
span { "birthday" }
div(class: "date-row") do
- select(name: "birthday[month]", aria_label: "month") do
- option(value: "", selected: birthday&.month.nil?) { "month" }
- Date::MONTHNAMES.compact.each_with_index do |name, index|
- option(value: index + 1, selected: birthday&.month == index + 1) { name }
- end
- end
- input(type: "number", name: "birthday[day]", value: birthday&.day,
- min: 1, max: 31, placeholder: "day", aria_label: "day")
- input(type: "number", name: "birthday[year]", value: birthday&.year,
- min: 1, max: 9999, placeholder: "year", aria_label: "year")
- # The remove control, over a held birthday only: three
- # blanks is the rule that removes and this is its
- # one-click spelling — @click empties the row's controls,
- # the blank rule and the struck caption do the rest, and
- # retyping the date is the undo. Gloss's IconButton (the
- # one sanctioned class) carrying the Lucide x
- # (Admin::Icon), its aria-label the name the glyph cannot
- # show; the card UI's removal register — low-contrast at
- # rest, danger on hover — is admin.css's.
- if birthday
- clear = "$el.closest('.date-row').querySelectorAll('select, input').forEach(el => el.value = '')"
- button(type: "button", class: "icon-button", data_size: "sm",
- aria_label: "Remove birthday", "@click": clear) do
- render Icon.new(:x)
- end
+ birthday_controls(birthday)
+ # The remove control: three blanks is the rule that
+ # removes and this is its one-click spelling — @click
+ # empties the row's controls, the blank rule and the
+ # struck caption do the rest, and retyping the date is
+ # the undo. Gloss's IconButton (the one sanctioned
+ # class) carrying the Lucide x (Admin::Icon), its
+ # aria-label the name the glyph cannot show; the card
+ # UI's removal register — low-contrast at rest, danger
+ # on hover — is admin.css's.
+ clear = "$el.closest('.date-row').querySelectorAll('select, input').forEach(el => el.value = '')"
+ button(type: "button", class: "icon-button", data_size: "sm",
+ aria_label: "Remove birthday", "@click": clear) do
+ render Icon.new(:x)
end
end
end
end
+ # The birthday row's three controls, shared by the standing
+ # row and the add dialog's — month-day-year to match the prose
+ # Birthday#to_s renders; a select for the month because a name
+ # is the friendlier read and constrains the value by
+ # construction, number inputs with native ranges for the day
+ # and year. The same names for both callers is the point: a
+ # birthday needs no digest, there being at most one, and the
+ # save reads either caller's row identically. A nil birthday
+ # is the added row — nothing selected, nothing prefilled.
+ #: (Birthday? birthday, ?focus: bool) -> void
+ def birthday_controls(birthday, focus: false)
+ select(name: "birthday[month]", aria_label: "month",
+ "x-init": focus ? "$el.focus()" : nil) do
+ option(value: "", selected: birthday&.month.nil?) { "month" }
+ Date::MONTHNAMES.compact.each_with_index do |name, index|
+ option(value: index + 1, selected: birthday&.month == index + 1) { name }
+ end
+ end
+ input(type: "number", name: "birthday[day]", value: birthday&.day,
+ min: 1, max: 31, placeholder: "day", aria_label: "day")
+ input(type: "number", name: "birthday[year]", value: birthday&.year,
+ min: 1, max: 9999, placeholder: "year", aria_label: "year")
+ 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
@@ -341,8 +374,10 @@ module ProTacts
# the form, and what it changes is visible in the card the
# moment the popover closes.
#
- # The three options are the three kinds of row the save can
- # insert; a fourth, birthday, arrives with its own doc.
+ # The options are the kinds of row the save can insert, the
+ # birthday only over a contact that holds none (#addable_types)
+ # — and its radio leaves the list the moment its row stands,
+ # so the dialog never names what cannot be added again.
#
# The radios share a `name` so they are one native group —
# arrow-key navigation and "1 of n" come from that, not from
@@ -360,10 +395,14 @@ module ProTacts
dialog(id: "add-property", popover: "auto") do
header { "Add a property" }
div(class: "field-stack", role: "radiogroup", aria_label: "Type") do
- ADDABLE_TYPES.each do |kind|
+ addable_types.each do |kind|
# A label wrapping its own radio is Gloss's Radio, told
# apart from Field structurally rather than by class.
- label do
+ # The birthday's radio leaves the list once its row
+ # stands — x-show rather than a template x-if, so the
+ # native group keeps its shape and the option only
+ # disappears.
+ label("x-show": kind == "birthday" ? "!added.includes('birthday')" : nil) do
input(type: "radio", name: "add-type", value: kind, "x-model": "type")
plain kind
end
@@ -376,12 +415,28 @@ module ProTacts
# `popovertargetaction`: a button cannot both run this
# handler and carry the declarative hide, and the row has
# to exist before the dialog goes away or `x-init`'s focus
- # lands on an element nobody can see.
+ # lands on an element nobody can see. The guard on the
+ # push is the birthday's: a hidden radio can stay the
+ # model's pick (x-model holds its last value), so Add
+ # itself refuses a second one — the row that cannot usefully
+ # repeat, its fields a group Rack would collapse to the
+ # last copy.
button(type: "button", data: {variant: "primary"},
- "@click": "added.push(type); $el.closest('dialog').hidePopover()") { "Add" }
+ "@click": "(type !== 'birthday' || !added.includes('birthday')) && added.push(type); " \
+ "$el.closest('dialog').hidePopover()") { "Add" }
end
end
end
+
+ # The dialog's list: everything the save can insert, minus a
+ # birthday the contact already holds — one being the most a
+ # card carries, and the dialog naming only what can still be
+ # added. The row is the same guard from the other side,
+ # rendering only over a held birthday.
+ #: () -> Array[String]
+ def addable_types
+ @contact.birthday ? ADDABLE_TYPES - ["birthday"] : ADDABLE_TYPES
+ end
end
end
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index c1634a5..6c57447 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -665,10 +665,13 @@ module ProTacts
# The birthday row's parse — request validity, standing with the
# name check rather than the stored-state conditionals below
- # (write_card's ordering rule). A POST from anything but this
- # form carries no birthday group and keeps the model, the
- # phones' is-a-Hash posture; a group of three blanks is the
- # row's blank-equals-absent, a removal. The browser's number
+ # (write_card's ordering rule). A POST that carries no birthday
+ # group keeps the model, the phones' is-a-Hash posture — this
+ # form's own save does when it rendered no birthday row, no
+ # birthday held and none added from the dialog
+ # (Admin::ContactsEdit), and anything else never carried one; a
+ # group of three blanks is the row's blank-equals-absent, a
+ # removal. The browser's number
# inputs make garbage a hand-crafted POST's own, so the refusal
# is the name toast's backstop sibling: the constructor's
# ArgumentError is the grammar's own refusal, caught at the one
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7ea385a..a1a8fe3 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -649,10 +649,13 @@ class AdminContactsPagesTest < Minitest::Test
assert_equal 404, last_response.status
end
- # The removal state rides only where blanking deletes: nickname,
- # note, and birthday render whether or not the contact carries
- # one, and a box that rendered empty cannot lose anything — no
- # attribute, no "removed on save" in it, no remove button.
+ # The removal state rides only where blanking deletes: nickname
+ # and note render whether or not the contact carries one, and a
+ # box that rendered empty cannot lose anything — no attribute, no
+ # "removed on save" in it, no remove button. The birthday row
+ # does not render at all over a birthday unheld: absence is added
+ # by the dialog like any other property, not scaffolded as an
+ # empty row — the radio leaves the list the moment its row stands.
def test_rows_without_a_property_to_lose_state_no_removal
bare = ADA.sub("NOTE:Countess of Lovelace.\r\n", "").sub("BDAY:1985-12-10\r\n", "")
@@ -662,7 +665,9 @@ class AdminContactsPagesTest < Minitest::Test
body = last_response.body
assert_includes body, '<label class="field"><span>Nickname</span><input type="text" name="nickname"></label>'
assert_includes body, '<textarea name="note" rows="4"></textarea>'
- assert_includes body, '<div class="field"><span>birthday</span><div class="date-row">'
+ refute_includes body, '<span>birthday</span>'
+ assert_includes body,
+ '<label x-show="!added.includes(\'birthday\')"><input type="radio" name="add-type" value="birthday" x-model="type">birthday</label>'
refute_includes body, "icon-button"
end
end
@@ -1126,6 +1131,9 @@ class AdminContactsPagesTest < Minitest::Test
# glyph carries the label.
assert_includes body,
%(<button type="button" class="icon-button" data-size="sm" aria-label="Remove birthday" @click="$el.closest('.date-row').querySelectorAll('select, input').forEach(el => el.value = '')"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg></button>)
+ # A held birthday is not addable again: the dialog's list drops
+ # the type.
+ refute_includes body, 'value="birthday"'
end
end
@@ -1197,9 +1205,32 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # A POST from anything but this form carries no birthday group and
- # keeps the model, the phones' is-a-Hash posture — a group the
- # request never carried touches nothing.
+ # A birthday added from the dialog saves like one edited: the
+ # added row names the same birthday[] group the standing row
+ # would, the group parses into the model, and the served card
+ # composes the line over the card's untouched bytes.
+ def test_a_birthday_added_from_the_dialog_saves
+ bare = ADA.sub("BDAY:1985-12-10\r\n", "")
+
+ with_contacts({"ada" => bare}) do |store|
+ assert_nil store.contact("ada").birthday
+
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ birthday: {"month" => "3", "day" => "14", "year" => "1879"}
+
+ assert_equal 303, last_response.status
+ contact = store.contact("ada")
+ assert_equal ProTacts::Birthday.new(year: 1879, month: 3, day: 14), contact.birthday
+ assert_includes contact.vcard.to_s, "BDAY:1879-03-14\r\n"
+
+ follow_redirect!
+ assert_includes last_response.body, "March 14, 1879"
+ end
+ end
+
+ # A POST that carries no birthday group keeps the model, the
+ # phones' is-a-Hash posture — a group the request never carried
+ # touches nothing, this form's own no-row save included.
def test_a_save_without_birthday_fields_leaves_the_model_alone
with_contacts({"ada" => ADA}) do |store|
post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag
@@ -1278,9 +1309,10 @@ 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 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.
+ # A radio group of the kinds the save can insert, minus the
+ # birthday this contact holds (#addable_types). 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>' \
@@ -1290,7 +1322,11 @@ class AdminContactsPagesTest < Minitest::Test
'<button type="button" popovertarget="add-property" popovertargetaction="hide">Cancel</button>'
# Add makes the row and then hides the popover, in that order —
# the row has to exist before x-init's focus can land on it.
- assert_includes body, %(@click="added.push(type); $el.closest('dialog').hidePopover()")
+ # The guard refuses a second birthday: a hidden radio can stay
+ # x-model's pick, and the row's fields are one group Rack
+ # would collapse to the last copy.
+ assert_includes body,
+ %(@click="(type !== 'birthday' || !added.includes('birthday')) && added.push(type); $el.closest('dialog').hidePopover()")
end
end
@@ -1310,7 +1346,7 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes body,
'<template x-for="(kind, i) in added" :key="i"><div class="field">' \
'<span x-text="kind"></span>' \
- '<template x-if="kind !== \'address\'">' \
+ '<template x-if="kind !== \'address\' && kind !== \'birthday\'">' \
'<input :type="kind === \'phone\' ? \'tel\' : kind" :name="`new_${kind}[]`" :aria-label="kind" x-init="$el.focus()">' \
'</template>' \
'<template x-if="kind === \'address\'">' \
@@ -1321,6 +1357,19 @@ class AdminContactsPagesTest < Minitest::Test
'<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>'
+ # The birthday kind: the standing row's own three controls with
+ # nothing prefilled, naming the model's birthday[] group — no
+ # digest, a contact holding at most one, and no removal state,
+ # the added rows' exemption. Asserted in two contiguous pieces:
+ # the month names sit between the empty option and December.
+ assert_includes body,
+ '<template x-if="kind === \'birthday\'"><div class="date-row">' \
+ '<select name="birthday[month]" aria-label="month" x-init="$el.focus()"><option value="" selected>month</option>'
+ assert_includes body,
+ '<option value="12">December</option></select>' \
+ '<input type="number" name="birthday[day]" min="1" max="31" placeholder="day" aria-label="day">' \
+ '<input type="number" name="birthday[year]" min="1" max="9999" placeholder="year" aria-label="year">' \
'</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