admin: require either name, not always the first
First was always required, so a family name alone was barred;
now each box is required only while the other one is bare.
Alpine keeps the two in step with every key that is pressed,
and the server still refuses both blank, the backstop for the rest.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index 2ddf5b2..dfa9c60 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -1,5 +1,7 @@
require "pro_tacts/admin/phlex"
+require "pro_tacts/admin/name_pair"
+
module ProTacts
module Admin
# The new-contact dialog — the dashboard's quiet add affordance,
@@ -30,10 +32,11 @@ module ProTacts
# the fixture sessions show macOS writing.
#
# A popover cannot be declared open in markup, so the empty-name
- # refusal is not a re-opened dialog: the first field is `required`
- # — native validation, which no browser submit gets past — and the
- # server's backstop for a hand-crafted empty POST answers with a
- # toast on the dashboard instead (see Web's POST handler).
+ # refusal is not a re-opened dialog: each name box is `required`
+ # while the other is blank (NamePair) — native validation, which no
+ # browser submit gets past — and the server's backstop for a
+ # hand-crafted empty POST answers with a toast on the dashboard
+ # instead (see Web's POST handler).
# `autofocus` fires when the popover is shown (it is scoped to the
# popover, so the header search's own page-load autofocus stands).
class ContactDialog < Phlex::HTML
@@ -47,14 +50,15 @@ module ProTacts
def view_template
dialog(id: "new-contact", popover: "auto") do
header { "New contact" }
- form(id: "new-contact-form", action: "/contacts", method: "post") do
+ form(id: "new-contact-form", action: "/contacts", method: "post",
+ x_data: "{ #{NamePair.state(nil, nil)} }") do
label(class: "field") do
plain "First"
- input(type: "text", name: "first", required: true, autofocus: true)
+ input(**NamePair.first(nil, nil), autofocus: true)
end
label(class: "field") do
plain "Last"
- input(type: "text", name: "last")
+ input(**NamePair.last(nil, nil))
end
# The search the dialog opened over — opening a popover
# loads no page, so this survives only the failed-create
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index ebe7e84..c32d1e3 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -5,6 +5,7 @@ require "pro_tacts/admin/phlex"
require "pro_tacts/admin/format"
require "pro_tacts/admin/icons"
require "pro_tacts/admin/layout"
+require "pro_tacts/admin/name_pair"
module ProTacts
module Admin
@@ -108,8 +109,9 @@ module ProTacts
# dialog both: the dialog names a type and the form grows a
# row for it, so the two have to share state, and Alpine
# scopes by ancestry. `added` is the list of types added
- # this pass, `type` is the radio's binding.
- div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}' }") do
+ # this pass, `type` is the radio's binding, and the rest is
+ # the name boxes' (NamePair).
+ div(x_data: "{ added: [], type: '#{ADDABLE_TYPES.first}', #{NamePair.state(@first, @last)} }") do
# The caption-to-card block the details page uses (.record
# in admin.css): the back link is this card's caption row,
# and the row's one action sits at its right edge — the
@@ -135,12 +137,11 @@ module ProTacts
# it, and the note's caption needs reaching.
label(class: "field") do
span { "First" }
- input(type: "text", name: "first", value: @first,
- required: true, autofocus: true)
+ input(**NamePair.first(@first, @last), autofocus: true)
end
label(class: "field") do
span { "Last" }
- input(type: "text", name: "last", value: @last)
+ input(**NamePair.last(@first, @last))
end
# The whole-property rows wear the removal state only
# over a property the card carries: both render empty
diff --git a/lib/pro_tacts/admin/name_pair.rb b/lib/pro_tacts/admin/name_pair.rb
new file mode 100644
index 0000000..2cf5ac7
--- /dev/null
+++ b/lib/pro_tacts/admin/name_pair.rb
@@ -0,0 +1,43 @@
+module ProTacts
+ module Admin
+ # The name fields' one rule, for the create dialog and the editor
+ # both: a contact needs a name, and either half alone is one — a
+ # mononym is a family name alone as often as a given one (macOS
+ # writes `N:Prince;;;;`). No attribute says "one of these two", so
+ # each box is required exactly while the other is blank: rendered
+ # so from the values the form starts with, and kept so by Alpine as
+ # they change. The server's refusal of a save blank throughout
+ # (Web's contacts routes) is the backstop behind it.
+ #
+ # Not a view, so the Steepfile checks it.
+ module NamePair
+ # The Alpine state the two boxes share, as properties for the
+ # enclosing x-data — booleans, so no name is spliced into script.
+ #: (String? first, String? last) -> String
+ def self.state(first, last)
+ "firstBlank: #{blank?(first)}, lastBlank: #{blank?(last)}"
+ end
+
+ #: (String? first, String? last) -> Hash[Symbol, untyped]
+ def self.first(first, last)
+ box("first", first, required: blank?(last), own: "firstBlank", other: "lastBlank")
+ end
+
+ #: (String? first, String? last) -> Hash[Symbol, untyped]
+ def self.last(first, last)
+ box("last", last, required: blank?(first), own: "lastBlank", other: "firstBlank")
+ end
+
+ #: (String name, String? value, required: bool, own: String, other: String) -> Hash[Symbol, untyped]
+ def self.box(name, value, required:, own:, other:)
+ {type: "text", name:, value:, required:, ":required": other, "@input": "#{own} = !$el.value.trim()"}
+ end
+
+ # The server's own test of a blank half (Web's contacts routes).
+ #: (String? value) -> bool
+ def self.blank?(value)
+ value.to_s.strip.empty?
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index a321dd7..06e1d42 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -22,9 +22,10 @@ module ProTacts
# names. Stored through Store#put like any client write, so
# the change log a sync token counts on lands with the card.
# A nameless create is a dashboard re-render with a toast: the
- # browser cannot produce one (the dialog's first field is
- # required), so this is the backstop, and a popover cannot be
- # declared open in markup — the toast is the refusal the
+ # browser cannot produce one (the dialog requires one name box
+ # or the other, Admin::NamePair), so this is the backstop, and
+ # a popover cannot be declared open in markup — the toast is
+ # the refusal the
# re-rendered page can actually show. `r.is` because a bare
# verb block matches any remaining path in Roda — without it,
# the collection's create would swallow the record's apply,
@@ -125,7 +126,8 @@ module ProTacts
# N and FN are mandatory (RFC 2426 section 4), so a save blank
# throughout is refused — the toast is the backstop, the form's
- # required field being the browser's own refusal of the same.
+ # name pair (Admin::NamePair) being the browser's own refusal of
+ # the same.
first = r.params["first"].to_s.strip
last = r.params["last"].to_s.strip
return edit_screen(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 16a6d65..3e8bfe3 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -693,12 +693,15 @@ class AdminContactsPagesTest < Minitest::Test
body = last_response.body
assert_includes body, '<button data-size="sm" popovertarget="new-contact">add contact</button>'
assert_includes body, '<dialog id="new-contact" popover="auto">'
- assert_includes body, '<form id="new-contact-form" action="/contacts" method="post">'
+ assert_includes body, '<form id="new-contact-form" action="/contacts" method="post" ' \
+ 'x-data="{ firstBlank: true, lastBlank: true }">'
# The captions render — a bare string mid-block is void in
# Phlex, so the labels carry their text through plain (the
# assertion keeps a regression from rendering a silent label).
- assert_includes body, '<label class="field">First<input type="text" name="first" required autofocus></label>'
- assert_includes body, '<label class="field">Last<input type="text" name="last"></label>'
+ assert_includes body, '<label class="field">First<input type="text" name="first" required ' \
+ ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus></label>'
+ assert_includes body, '<label class="field">Last<input type="text" name="last" required ' \
+ ':required="firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
assert_includes body, 'popovertargetaction="hide"'
end
end
@@ -757,9 +760,9 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # The backstop for the one request no browser can send (the first
- # field is required): a plain dashboard re-render with the refusal
- # in a toast, and nothing stored.
+ # The backstop for the one request no browser can send (one name
+ # box or the other is required): a plain dashboard re-render with
+ # the refusal in a toast, and nothing stored.
def test_a_nameless_create_is_refused_with_a_toast
with_contacts({}) do |store|
post "/contacts", first: " ", last: ""
@@ -785,8 +788,10 @@ class AdminContactsPagesTest < Minitest::Test
assert_equal 200, last_response.status
body = last_response.body
assert_includes body, '<form action="/contacts/red" method="post" class="field-stack">'
- assert_includes body, '<input type="text" name="first" value="Ada" required autofocus>'
- assert_includes body, '<input type="text" name="last" value="Lovelace">'
+ assert_includes body, '<input type="text" name="first" value="Ada" ' \
+ ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
+ assert_includes body, '<input type="text" name="last" value="Lovelace" ' \
+ ':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
assert_includes body,
'<label class="field" data-blank-removes><span>Nickname</span>' \
'<input type="text" name="nickname" value="Red" placeholder="removed on save"></label>'
@@ -797,6 +802,28 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # A family name alone is a name (`N:Prince;;;;`): the blank first box
+ # is not required over it, or the browser would refuse every save.
+ def test_a_family_name_alone_is_a_name_the_editor_can_save
+ prince = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Prince\r\nN:Prince;;;;\r\nUID:prince\r\nEND:VCARD\r\n"
+
+ with_contacts({"prince" => prince}) do |store|
+ get "/contacts/prince/edit"
+
+ body = last_response.body
+ assert_includes body, "firstBlank: true, lastBlank: false"
+ assert_includes body, '<input type="text" name="first" ' \
+ ':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
+ assert_includes body, '<input type="text" name="last" value="Prince" required ' \
+ ':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
+
+ post "/contacts/prince", first: "", last: "Prince", etag: store.contact("prince").etag
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("prince").vcard.to_s, "N:Prince;;;;\r\n"
+ end
+ end
+
# The editor renders the contact's own rows and none a group lends:
# a row here is a row the save may rewrite or remove, and neither is
# this screen's to do to a value the household owns. The details
@@ -1598,7 +1625,7 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => ADA}) do
body = (get("/contacts/ada/edit") && last_response.body)
- assert_includes body, %(<div x-data="{ added: [], type: 'phone' }">)
+ assert_includes body, %(<div x-data="{ added: [], type: 'phone', 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