admin: create contacts from a popover dialog
Assisted-by: GLM-5.3 via pi
change qvnnvptrvmkzsvvttktkzwknuxsknxrx
commit 3b979804970cb6bb68cdace10db604b4be9ca278
author Alpha Chen <alpha@kejadlen.dev>
date
parent sxqvktzz
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