admin: create contacts from a popover dialog
Assisted-by: GLM-5.3 via pi
diff --git a/Steepfile b/Steepfile
index d268208..ad199e6 100644
--- a/Steepfile
+++ b/Steepfile
@@ -21,5 +21,5 @@ target :lib do
ignore "lib/pro_tacts/admin", inline: true
signature "sig"
- library "date", "digest", "fileutils", "logger", "pathname", "strscan"
+ library "date", "digest", "fileutils", "logger", "pathname", "securerandom", "strscan"
end
diff --git a/config/puma.rb b/config/puma.rb
index bbcf25c..724ecab 100644
--- a/config/puma.rb
+++ b/config/puma.rb
@@ -1,8 +1,8 @@
-
# Puma replaces its default method list with this one rather than adding to
# it, so every method the app answers has to be named here or Puma rejects
# the request with a 501 before Rack sees it — and UnhandledRequests never
-# gets to capture it. Keep this list matching the privileges advertised in
+# gets to capture it. The DAV verbs match the privileges advertised in
# current-user-privilege-set: DAV:write and DAV:bind imply PUT, DAV:unbind
-# implies DELETE (RFC 3744 sections 3.2, 3.9, and 3.10).
-supported_http_methods %w[GET HEAD OPTIONS PROPFIND REPORT PUT DELETE]
+# implies DELETE (RFC 3744 sections 3.2, 3.9, and 3.10). POST is the web
+# UI's alone — the browser's create, no DAV meaning.
+supported_http_methods %w[GET HEAD OPTIONS PROPFIND REPORT PUT DELETE POST]
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
new file mode 100644
index 0000000..2003c7e
--- /dev/null
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -0,0 +1,73 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+ module Admin
+ # The new-contact dialog — the dashboard's quiet add affordance,
+ # opened and closed by the Popover API rather than by script or a
+ # server round trip: the add button carries `popovertarget`, the
+ # element carries `popover`, and the browser does the rest — top
+ # layer, a painted ::backdrop (Gloss's own dialog::backdrop rule),
+ # light dismiss, Esc. That is the whole reason this dialog is a
+ # popover and not a `showModal()` dialog: the admin UI is
+ # script-free (see Layout), and a popover is the one declarative
+ # way to get modal behavior out of markup alone. Values survive a
+ # light dismiss — the element stays in the page, only hidden — so
+ # an accidental outside click costs nothing.
+ #
+ # Gloss's Dialog contract styles the element: header, body, footer
+ # as direct children. The submit button in the footer reaches the
+ # form above it by id, since a footer sibling is not inside the
+ # form.
+ #
+ # Name only, by task uw's first cut: a created card has no prior
+ # octets to preserve, so a form is safe for it, and everything else
+ # a contact carries arrives by editing the record — which belongs
+ # on the details page, where the surgical mutator will live. N and
+ # FN both written because RFC 2426 section 4 requires them of every
+ # card; the two name fields are N's first two components
+ # (family;given), and FN is derived from them in the western order
+ # 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).
+ # `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
+ # @rbs @query: String
+
+ #: (query: String) -> void
+ def initialize(query:)
+ @query = query
+ end
+
+ def view_template
+ dialog(id: "new-contact", popover: "auto") do
+ header { "New contact" }
+ form(id: "new-contact-form", action: "/contacts", method: "post") do
+ label(class: "field") do
+ plain "First"
+ input(type: "text", name: "first", required: true, autofocus: true)
+ end
+ label(class: "field") do
+ plain "Last"
+ input(type: "text", name: "last")
+ end
+ # The search the dialog opened over — opening a popover
+ # loads no page, so this survives only the failed-create
+ # re-render, keeping the results the user was looking at.
+ input(type: "hidden", name: "q", value: @query)
+ end
+ footer do
+ button(type: "button", popovertarget: "new-contact",
+ popovertargetaction: "hide") { "Cancel" }
+ button(type: "submit", form: "new-contact-form",
+ data: {variant: "primary"}) { "Create" }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index a68a80c..bdfc002 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -1,6 +1,7 @@
require "pro_tacts/admin/phlex"
require "pro_tacts/admin/avatar"
+require "pro_tacts/admin/contact_dialog"
require "pro_tacts/admin/format"
require "pro_tacts/admin/layout"
require "pro_tacts/admin/upcoming_birthdays"
@@ -11,8 +12,10 @@ module ProTacts
# header, recency in the primary column, the birthdays the year is
# about to bring in the ambient one beside it. A query narrows the
# contacts column to matches across every contact; an empty query
- # shows the ten most recently updated. Read-only: no add, no edit,
- # nothing to commit, so there is no state here beyond the query.
+ # shows the ten most recently updated. The create dialog rides
+ # along hidden on every render — a popover opens where it sits, so
+ # the add button needs nothing from the server but the page it is
+ # already on.
class ContactsIndex < Phlex::HTML
RECENT_LIMIT = 10
private_constant :RECENT_LIMIT
@@ -20,19 +23,31 @@ module ProTacts
Row = Data.define(:contact, :updated_at)
private_constant :Row
- #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?) -> void
- def initialize(recent:, upcoming:, query:)
+ # @rbs @query: String
+ # @rbs @upcoming: Array[Store::UpcomingBirthday]
+ # @rbs @rows: Array[Row]
+ # @rbs @notice: String?
+
+ #: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> void
+ def initialize(recent:, upcoming:, query:, notice: nil)
@query = query.to_s.strip
@upcoming = upcoming
+ @notice = notice
rows = recent.map { Row.new(contact: it.contact, updated_at: it.updated_at) }
@rows = @query.empty? ? rows.first(RECENT_LIMIT) : rows.select { matches?(it, @query) }
end
def view_template
- render Layout.new(title: "Contacts", wide: true, search: @query) do
+ render Layout.new(title: "Contacts", wide: true, search: @query, notice: @notice) do
div(class: "dashboard") do
section do
- h2(class: "type-label") { @query.empty? ? "recently updated" : "results" }
+ div(class: "section-head") do
+ h2(class: "type-label") { @query.empty? ? "recently updated" : "results" }
+ # A button, not a link: it changes the page's state
+ # rather than navigating, and the Popover API is what
+ # it invokes (see ContactDialog).
+ button(data_size: "sm", popovertarget: "new-contact") { "add contact" }
+ end
if @rows.empty?
p(class: "type-body-sm") { @query.empty? ? "No contacts yet." : "No contacts match." }
else
@@ -41,6 +56,9 @@ module ProTacts
end
render UpcomingBirthdays.new(upcoming: @upcoming)
end
+ # In the page but out of the columns: a popover opens from
+ # wherever it sits, and hidden is its resting state.
+ render ContactDialog.new(query: @query)
end
end
diff --git a/lib/pro_tacts/admin/layout.rb b/lib/pro_tacts/admin/layout.rb
index 29e6405..546e123 100644
--- a/lib/pro_tacts/admin/layout.rb
+++ b/lib/pro_tacts/admin/layout.rb
@@ -13,11 +13,12 @@ module ProTacts
# root is the one screen that asks. No JavaScript: nothing served
# here yet needs any.
class Layout < Phlex::HTML
- #: (title: String, ?wide: bool, ?search: String?) -> void
- def initialize(title:, wide: false, search: nil)
+ #: (title: String, ?wide: bool, ?search: String?, ?notice: String?) -> void
+ def initialize(title:, wide: false, search: nil, notice: nil)
@title = title
@wide = wide
@search = search
+ @notice = notice
end
def view_template
@@ -49,6 +50,15 @@ module ProTacts
end
end
main(class: "admin-main", **(@wide ? {data: {wide: true}} : {})) { yield }
+ # A server-rendered toast, Gloss's [role=status] contract:
+ # the one line a refused write leaves behind, fixed to the
+ # corner until the next navigation carries it off — no
+ # dismiss control, because with no script there is nothing
+ # to dismiss it with, and "leave" is the design doc's own
+ # word for what a toast does next.
+ if @notice
+ div(role: "status", data: {fixed: true}) { span { @notice } }
+ end
end
end
end
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 1f45d54..ebcbbff 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -1,5 +1,6 @@
require "pathname"
+require "securerandom"
require "pro_tacts"
require "sentry-ruby"
@@ -8,6 +9,7 @@ require "rack/rewindable_input"
require "nokogiri"
require "roda"
+require "pro_tacts/admin/contact_dialog"
require "pro_tacts/admin/contacts_index"
require "pro_tacts/admin/contacts_show"
require "pro_tacts/debug_logger"
@@ -89,6 +91,29 @@ module ProTacts
# trusted" is the whole access model this app has, see README's
# simplifying assumptions.
r.on "contacts" do
+ # The browser's create, from the dashboard's dialog: POST is
+ # the one method the admin UI adds to the DAV set (see
+ # config/puma.rb, whose list Puma replaces rather than
+ # extends), and the collection is the resource a create
+ # 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
+ # re-rendered page can actually show.
+ r.post do
+ first = r.params["first"].to_s.strip
+ last = r.params["last"].to_s.strip
+ if first.empty? && last.empty?
+ dashboard(query: r.params["q"], notice: "A contact needs a name.")
+ else
+ id = SecureRandom.uuid
+ store.put(id, new_contact_card(id, first, last))
+ r.redirect "/contacts/#{id}", 303
+ end
+ end
+
r.on String do |id|
# The picture the avatars render (Admin::Avatar): decoded
# bytes under their own content type, served from a route
@@ -126,14 +151,10 @@ module ProTacts
# The dashboard home: the one human-facing screen's home
# (docs/DESIGN.md), a GET alongside the PROPFIND below it —
# same path, disjoint verbs, and a browser's plain GET is no
- # DAV client's bootstrap.
+ # DAV client's bootstrap. The create dialog rides along hidden
+ # in the render (see Admin::ContactDialog).
r.get do
- response["Content-Type"] = "text/html; charset=utf-8"
- Admin::ContactsIndex.call(
- recent: store.contacts_by_recency,
- upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
- query: r.params["q"],
- )
+ dashboard(query: r.params["q"])
end
# DAV:current-user-principal (RFC 5397 section 3) — what a client
@@ -468,6 +489,20 @@ module ProTacts
@ctag ||= store.ctag
end
+ # The dashboard GET and a refused create render the same page,
+ # which is why the query travels both paths — a create refused
+ # under a search re-renders the results it was refused over.
+ #: (query: String?, ?notice: String?) -> String
+ def dashboard(query:, notice: nil)
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::ContactsIndex.call(
+ recent: store.contacts_by_recency,
+ upcoming: store.upcoming_birthdays(Admin::UpcomingBirthdays::LIMIT),
+ query:,
+ notice:,
+ )
+ end
+
# Sync tokens are opaque to the client (RFC 6578 section 3); the URI
# form is conventional. Built on the ctag so a client polling either
# one sees changes at the same points.
@@ -565,6 +600,23 @@ module ProTacts
nil
end
+ # 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
+ # Contact). Nothing else: a created card has no prior octets to
+ # preserve, and every other property arrives by editing the
+ # record later. The names escape, because a name is text — a
+ # comma or semicolon in one must not read as structure (RFC 2426
+ # section 2.4.2).
+ #: (String id, String first, String last) -> String
+ def new_contact_card(id, first, last)
+ "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
+ "N:#{VCard.escape(last)};#{VCard.escape(first)};;;\r\n" \
+ "FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n" \
+ "UID:#{id}\r\n" \
+ "END:VCARD\r\n"
+ end
+
# A card that breaks one of the parser's assumptions is not bad
# input, it is news that the assumption is wrong and every
# simplification resting on it is now suspect — an error, where a
diff --git a/public/admin.css b/public/admin.css
index d9121ab..8b0d8c9 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -76,6 +76,29 @@ section > h2 {
margin-bottom: var(--gl-space-s);
}
+/* A section heading that carries an action at its right edge — the
+ contacts column's quiet add button. The button is centered on the
+ heading's line rather than in flow: a Gloss button is half again
+ taller than a type-label line, and in flow it pushes the heading's
+ baseline below the sibling column's (baseline alignment against
+ the taller box) and grows the row, dropping the card under it —
+ the two columns' headings and cards would no longer sit on shared
+ lines. Centered on the line it carries the row's own geometry,
+ and its text lands on the heading's: both are near-identical mono
+ sizes, so center-on-line is baseline-on-line. The h2 is no longer
+ the section's direct child, so the row takes over the heading's
+ spacing to the list below. */
+.section-head {
+ position: relative;
+ margin-bottom: var(--gl-space-s);
+}
+.section-head button {
+ position: absolute;
+ inset-block-start: 50%;
+ inset-inline-end: 0;
+ translate: 0 -50%;
+}
+
/* base/typography.css's `li + li { margin-top: var(--gl-space-3xs); }`
is a prose-list rule (Markdown bullet lists) that also matches
Card's row list, adding a stray gap below every row but the last. */
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index dd70de8..f427b41 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -28,6 +28,7 @@ class Roda
def on: (*untyped matchers) { (*untyped) -> untyped } -> void
def get: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
def options: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+ def post: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
def put: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
# The :public plugin's file server: answers a GET that names a file
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index f12c894..6e9a762 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -11,7 +11,7 @@ module ProTacts
end
class ContactsIndex
- def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?) -> String
+ def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> String
end
# Rendered by ContactsIndex, not called from checked code; only
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 1fc558f..198b69f 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -462,4 +462,96 @@ class AdminContactsPagesTest < Minitest::Test
assert_equal "image/jpeg", last_response["Content-Type"]
end
end
+
+ ## Creating contacts
+
+ # The add affordance and the dialog it opens, both carried by the
+ # dashboard: a popovertarget button and a popover element, the
+ # Popover API's declarative pair (see Admin::ContactDialog).
+ # rack-test cannot open a popover, so this pins the wiring the same
+ # way the search-in-the-header test does — the attributes a browser
+ # acts on, asserted on the page a browser gets.
+ def test_the_dashboard_carries_the_new_contact_dialog
+ with_contacts({}) do
+ get "/"
+
+ 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">'
+ # 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, 'popovertargetaction="hide"'
+ end
+ end
+
+ # A create lands through Store#put — change log, index, and all —
+ # and the answer is a redirect to the record, the details page
+ # where further editing belongs.
+ def test_creating_a_contact_from_the_dialog
+ with_contacts({}) do |store|
+ post "/contacts", first: "Grace", last: "Hopper"
+
+ assert_equal 303, last_response.status
+ id = last_response["Location"].delete_prefix("/contacts/")
+ assert_match(/\A[\w-]+\z/, id)
+
+ follow_redirect!
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "Grace Hopper"
+
+ # N and FN both written, per RFC 2426 section 4's mandatory set,
+ # and the card's UID is the id it is served under.
+ card = store.contact(id).vcard.to_s
+ assert_includes card, "N:Hopper;Grace;;;"
+ assert_includes card, "FN:Grace Hopper"
+ assert_includes card, "UID:#{id}"
+ assert(store.changes.any? { it.action == "put" && it.card_id == id })
+ end
+ end
+
+ # A single name is a fine contact: the model carries either half
+ # alone, and the card says exactly what was given.
+ def test_creating_a_contact_with_only_a_first_name
+ with_contacts({}) do |store|
+ post "/contacts", first: "Cher"
+
+ assert_equal 303, last_response.status
+ id = last_response["Location"].delete_prefix("/contacts/")
+ card = store.contact(id).vcard.to_s
+ assert_includes card, "N:;Cher;;;"
+ assert_includes card, "FN:Cher"
+ end
+ end
+
+ # A name is text, and the card is structure: a comma or semicolon
+ # in a name must not read as a component separator (RFC 2426
+ # section 2.4.2).
+ def test_names_with_punctuation_are_escaped_in_the_card
+ with_contacts({}) do |store|
+ post "/contacts", first: "Ada, Jr.", last: "Lovelace; Countess"
+
+ assert_equal 303, last_response.status
+ id = last_response["Location"].delete_prefix("/contacts/")
+ card = store.contact(id).vcard.to_s
+ assert_includes card, "N:Lovelace\\; Countess;Ada\\, Jr.;;;"
+ assert_includes card, "FN:Ada\\, Jr. Lovelace\\; Countess"
+ 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.
+ def test_a_nameless_create_is_refused_with_a_toast
+ with_contacts({}) do |store|
+ post "/contacts", first: " ", last: ""
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, '<div role="status" data-fixed><span>A contact needs a name.</span></div>'
+ assert_empty store.contacts
+ end
+ end
end