Let each source render its own sync dialog
The list page's sync button opens a dialog holding the source's form, and
the sync route hands what it posts back to the source. Text's dialog
edits items by id: rename in place, clear a name to archive, add new
lines. A fetching kind will show its own progress and call Sync.call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LG4iASZnHb1yEM1JAASiWn
change
commit ad52f07085cb9e3152a167622aac6e1622711578
author Claude <noreply@anthropic.com>
date
parent 0b8d61af
diff --git a/AGENTS.md b/AGENTS.md
index 11f6d22..fefc29e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,7 +26,7 @@ lib/rank_king/     — app code
   models.rb        — Sequel models (User, List, Item, Axis, Result)
   sources.rb       — Sources.fetch: a list's source_kind names a class in Sources
   sources/         — one class per source kind
-  sync.rb          — reconciles a synced list's items with a fresh snapshot from its source
+  sync.rb          — reconciles a list's items with a fresh snapshot from a fetching source
   proxy_auth.rb    — reads the login from Remote-User
   stub_login.rb    — writes a fixed login where there is no proxy (demo.ru)
   web.rb           — Roda routes
@@ -45,7 +45,7 @@ fly.toml           — Fly config for the main demo and PR previews
 ## Domain model
 
 - **User** has many Lists; identified by the login on the `Remote-User` header
-- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names a class in `Sources`). `Text` is manual: items are added and removed by hand. A synced kind keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
+- **List** belongs to a User and has many Items and Axes, and one source (`source_kind` names a class in `Sources`). Each source renders its own sync dialog on the list page and applies what it posts: `Text`'s edits items by id, and a kind that fetches calls `Sync.call`. A fetching kind keeps its config in its own table keyed by `list_id` and its per-item details in a table keyed by `item_id`
 - **Item** belongs to a List; removed items, and items missing from the latest sync, are archived (`archived_at`) and hidden everywhere
 - **Axis** belongs to a List, has many Results
 - **Result** belongs to an Axis, a winner (Item), and a loser (Item)
diff --git a/lib/rank_king/sources/text.rb b/lib/rank_king/sources/text.rb
index 4d15369..7d85b84 100644
--- a/lib/rank_king/sources/text.rb
+++ b/lib/rank_king/sources/text.rb
@@ -1,12 +1,51 @@
 # frozen_string_literal: true
 
+require_relative "../views/sources/text"
+
 module RankKing
   module Sources
-    # A manual source: items are added and removed by hand on the list page,
-    # each by its own id, so there is nothing to sync.
+    # A source whose items are typed in by hand. Its sync is an edit dialog
+    # over the list's items, which matches them by id, so there is nothing
+    # to fetch and no table of its own.
     class Text
-      #: () -> bool
-      def self.manual? = true
+      #: (List list) -> Text
+      def self.for(list) = new(list)
+
+      #: (List list) -> void
+      def initialize(list)
+        @list = list
+      end
+
+      #: () -> String
+      def label = "Edit items"
+
+      #: (csrf: Method) -> _Component
+      def form(csrf:) = Views::Sources::Text.new(list: @list, csrf:)
+
+      # Applies the dialog: each submitted name renames the item with that
+      # id, a blank name archives it, and each line of new_items adds one.
+      #: (Hash[String, untyped] params, ?now: Time) -> void
+      def sync(params, now: Time.now)
+        items = params["items"]
+        names = items.is_a?(Hash) ? items : {} #: Hash[String, untyped]
+        added = params["new_items"].to_s.lines.map(&:strip).reject(&:empty?)
+
+        DB.transaction do
+          @list.items.each do |item|
+            name = names[item.id.to_s]
+            next unless name.is_a?(String)
+
+            name = name.strip
+            if name.empty?
+              item.update(archived_at: now)
+            elsif name != item.name
+              item.update(name:)
+            end
+          end
+
+          added.each {|name| Item.create(list_id: @list.id, name:) }
+        end
+      end
     end
   end
 end
diff --git a/lib/rank_king/sync.rb b/lib/rank_king/sync.rb
index ea6b9e5..b99ff10 100644
--- a/lib/rank_king/sync.rb
+++ b/lib/rank_king/sync.rb
@@ -6,14 +6,14 @@ module RankKing
   # Reconciles a list's items with a fresh snapshot of entries from its
   # source. Items missing from the snapshot are archived rather than deleted
   # so their results survive, and come back with their history if they
-  # reappear.
+  # reappear. A kind that fetches its items calls this from its own sync.
   module Sync
     # Signed in sig/rank_king/sync.rbs: a Data class.
     # @rbs skip
     Summary = Data.define(:added, :restored, :archived)
 
-    #: (List list, Array[Sources::Entry] entries, ?source: Sources::_Source, ?now: Time) -> Summary
-    def self.call(list, entries, source: list.source, now: Time.now)
+    #: (List list, Array[Sources::Entry] entries, source: Sources::_Synced, ?now: Time) -> Summary
+    def self.call(list, entries, source:, now: Time.now)
       DB.transaction do
         existing = source.items_by_key
         added = restored = 0
diff --git a/lib/rank_king/views/list/show.rb b/lib/rank_king/views/list/show.rb
index 9a65f09..e93f41c 100644
--- a/lib/rank_king/views/list/show.rb
+++ b/lib/rank_king/views/list/show.rb
@@ -18,7 +18,6 @@ module RankKing
             div(class: "list-show") do
               h1 { @list.name }
               render_items
-              render_source
               render_axes
               render_matchup
               render_ratings
@@ -29,53 +28,26 @@ module RankKing
         private
 
         def render_items
-          manual = Sources.fetch(@list.source_kind).manual?
-
           section(class: "section") do
-            h2 { "Items" }
+            div(class: "section-header") do
+              h2 { "Items" }
+              render_sync
+            end
             ul(class: "item-list") do
               @list.items.each do |item|
-                li do
-                  plain item.name
-                  render_archive(item) if manual
-                end
+                li { item.name }
               end
             end
-            render_add_item if manual
           end
         end
 
-        def render_archive(item)
-          action = "/lists/#{@list.id}/items/#{item.id}/archive"
-          form(method: "post", action:, class: "inline") do
-            input(type: "hidden", name: "_csrf", value: @csrf.call(action))
-            button(type: "submit", class: "btn-link") { "Remove" }
-          end
-        end
-
-        def render_add_item
-          action = "/lists/#{@list.id}/items"
-          form(method: "post", action:, class: "form") do
-            input(type: "hidden", name: "_csrf", value: @csrf.call(action))
-            div(class: "field") do
-              label(for: "item-name") { "Add item" }
-              input(type: "text", name: "name", id: "item-name", required: true)
-            end
-            button(type: "submit", class: "btn-primary") { "Add" }
-          end
-        end
-
-        # Synced sources get a Sync button; manual ones are edited above.
-        def render_source
-          return if Sources.fetch(@list.source_kind).manual?
-
-          action = "/lists/#{@list.id}/sync"
-          section(class: "section") do
-            h2 { "Source" }
-            form(method: "post", action:, class: "form") do
-              input(type: "hidden", name: "_csrf", value: @csrf.call(action))
-              button(type: "submit", class: "btn-primary") { "Sync" }
-            end
+        # The source decides what syncing looks like: the button opens a
+        # dialog holding the source's own form.
+        def render_sync
+          source = @list.source
+          button(type: "button", class: "btn-link", commandfor: "sync-dialog", command: "show-modal") { source.label }
+          dialog(id: "sync-dialog", class: "dialog") do
+            render source.form(csrf: @csrf)
           end
         end
 
diff --git a/lib/rank_king/views/sources/text.rb b/lib/rank_king/views/sources/text.rb
new file mode 100644
index 0000000..4cf6eee
--- /dev/null
+++ b/lib/rank_king/views/sources/text.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require "phlex"
+
+module RankKing
+  module Views
+    module Sources
+      # The Text source's sync dialog: every item's name, editable in place,
+      # and a box for new ones.
+      class Text < Phlex::HTML
+        def initialize(list:, csrf:)
+          @list = list
+          @csrf = csrf
+        end
+
+        def view_template
+          action = "/lists/#{@list.id}/sync"
+          form(method: "post", action:, class: "form") do
+            input(type: "hidden", name: "_csrf", value: @csrf.call(action))
+            h2 { "Edit items" }
+            p(class: "empty") { "Clear a name to remove that item." }
+            @list.items_dataset.order(:id).each do |item|
+              input(type: "text", name: "items[#{item.id}]", value: item.name, aria_label: "Item name")
+            end
+            div(class: "field") do
+              label(for: "new-items") { "Add items, one per line" }
+              textarea(name: "new_items", id: "new-items", rows: 3)
+            end
+            div(class: "dialog-actions") do
+              button(type: "button", class: "btn-link", commandfor: "sync-dialog", command: "close") { "Cancel" }
+              button(type: "submit", class: "btn-primary") { "Save" }
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/lib/rank_king/web.rb b/lib/rank_king/web.rb
index ee77af7..28b47b9 100644
--- a/lib/rank_king/web.rb
+++ b/lib/rank_king/web.rb
@@ -4,7 +4,6 @@ require "roda"
 
 require_relative "models"
 require_relative "proxy_auth"
-require_relative "sync"
 require_relative "views/home"
 require_relative "views/list/show"
 
@@ -63,42 +62,14 @@ module RankKing
             end
           end
 
+          # Submitted from the source's own sync dialog.
           r.on "sync" do
             r.post do
-              r.halt 422 if Sources.fetch(list.source_kind).manual?
-
-              source = list.source
-              Sync.call(list, source.entries, source:)
-
+              list.source.sync(r.params)
               r.redirect "/lists/#{list.id}"
             end
           end
 
-          # A manual source's items are edited one at a time, by id.
-          r.on "items" do
-            r.halt 422 unless Sources.fetch(list.source_kind).manual?
-
-            r.is do
-              r.post do
-                name = r.params["name"].to_s.strip
-                r.halt 422 if name.empty?
-
-                Item.create(list_id: list.id, name:)
-                r.redirect "/lists/#{list.id}"
-              end
-            end
-
-            r.on Integer, "archive" do |item_id|
-              r.post do
-                item = list.items_dataset.where(id: item_id).first
-                r.halt 404 unless item
-
-                item.update(archived_at: Time.now)
-                r.redirect "/lists/#{list.id}"
-              end
-            end
-          end
-
           r.on "results" do
             r.post do
               axis_id = r.params["axis_id"].to_i
diff --git a/public/css/app.css b/public/css/app.css
index db9105e..63ddc03 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -130,9 +130,28 @@ textarea {
   cursor: pointer;
 }
 
-form.inline {
-  display: inline;
-  margin-inline-start: var(--space-2xs);
+.section-header {
+  display: flex;
+  align-items: baseline;
+  gap: var(--space-s);
+}
+
+.dialog {
+  width: min(32rem, 100% - 2 * var(--space-s));
+  padding: var(--space-m);
+  border: 1px solid #ccc;
+  border-radius: 8px;
+}
+
+.dialog::backdrop {
+  background: rgba(0, 0, 0, 0.3);
+}
+
+.dialog-actions {
+  display: flex;
+  justify-content: flex-end;
+  align-items: baseline;
+  gap: var(--space-s);
 }
 
 /* -------------------- */
diff --git a/sig/rank_king/sources.rbs b/sig/rank_king/sources.rbs
index 02f720e..22251db 100644
--- a/sig/rank_king/sources.rbs
+++ b/sig/rank_king/sources.rbs
@@ -10,11 +10,20 @@ module RankKing
       def self.new: (key: String, name: String) -> instance
     end
 
-    # A synced source instance, for one list.
+    # A list's source.
     interface _Source
-      # The items currently in the source.
-      def entries: () -> Array[Entry]
+      # The text of the button that opens the sync dialog.
+      def label: () -> String
 
+      # What the sync dialog holds: a form that posts to the list's sync.
+      def form: (csrf: Method) -> _Component
+
+      # Applies what that form posted.
+      def sync: (Hash[String, untyped] params) -> void
+    end
+
+    # A source that fetches its items, as Sync.call needs it.
+    interface _Synced
       # Every item of the list, archived ones included, by the key its
       # entries carry.
       def items_by_key: () -> Hash[String, Item]
@@ -25,12 +34,14 @@ module RankKing
     end
 
     # A source kind: a class in Sources, named by a list's source_kind.
-    # A manual kind (Text) has no instance: its items are edited by hand.
     interface _SourceClass
-      def manual?: () -> bool
-      # The source for a list, configured from the kind's own table. Only
-      # kinds that sync define it.
+      # The source for a list, configured from the kind's own table.
       def for: (List list) -> _Source
     end
+
+    # A view component, as a source's form is.
+    interface _Component
+      def call: () -> String
+    end
   end
 end
diff --git a/sig/rank_king/views.rbs b/sig/rank_king/views.rbs
index 0d5cb78..517f3d6 100644
--- a/sig/rank_king/views.rbs
+++ b/sig/rank_king/views.rbs
@@ -8,6 +8,13 @@ module RankKing
       def call: () -> String
     end
 
+    module Sources
+      class Text
+        def initialize: (list: List, csrf: Method) -> void
+        def call: () -> String
+      end
+    end
+
     module ListViews
       class Show
         def initialize: (list: List, csrf: Method) -> void
diff --git a/test/test_web.rb b/test/test_web.rb
index 6729156..5d98363 100644
--- a/test/test_web.rb
+++ b/test/test_web.rb
@@ -133,35 +133,43 @@ class TestWeb < Minitest::Test
     assert_equal %w[Pizza Sushi], list.items.map(&:name)
   end
 
-  def test_add_item_to_text_list
+  def test_text_sync_dialog_lists_items_for_editing
     create_list(name: "Foods", items: "Pizza\nSushi")
     list = RankKing::List.first
+    pizza = list.items.find { |i| i.name == "Pizza" }
 
-    csrf_post "/lists/#{list.id}/items", { name: "Tacos" }
-    assert last_response.redirect?
-    assert_equal %w[Pizza Sushi Tacos], list.items_dataset.order(:id).map(:name)
+    get "/lists/#{list.id}"
+    assert_includes last_response.body, "Edit items"
+    assert_includes last_response.body, %(action="/lists/#{list.id}/sync")
+    assert_includes last_response.body, %(name="items[#{pizza.id}]" value="Pizza")
   end
 
-  def test_archived_items_are_hidden
-    create_list(name: "Foods", items: "Pizza\nSushi", axes: "Taste")
+  def test_text_sync_renames_archives_and_adds_by_id
+    create_list(name: "Foods", items: "Pizza\nSushi\nTacos", axes: "Taste")
     list = RankKing::List.first
-    sushi = list.items.find { |i| i.name == "Sushi" }
+    pizza, sushi, tacos = list.items_dataset.order(:id).all
 
-    csrf_post "/lists/#{list.id}/items/#{sushi.id}/archive"
+    csrf_post "/lists/#{list.id}/sync", {
+      "items" => { pizza.id.to_s => "Pizza Margherita", sushi.id.to_s => "  ", tacos.id.to_s => "Tacos" },
+      "new_items" => "Ramen\n\nCurry\n",
+    }
     assert last_response.redirect?
+
+    assert_equal "Pizza Margherita", pizza.reload.name
     refute_nil sushi.reload.archived_at
+    assert_nil tacos.reload.archived_at
+    assert_equal ["Pizza Margherita", "Tacos", "Ramen", "Curry"], list.items_dataset.order(:id).map(:name)
 
     get "/lists/#{list.id}"
-    assert_includes last_response.body, "Pizza"
     refute_includes last_response.body, "Sushi"
   end
 
-  def test_text_lists_have_no_sync
+  def test_text_sync_leaves_items_it_was_not_sent
     create_list(name: "Foods", items: "Pizza\nSushi")
     list = RankKing::List.first
 
-    get "/lists/#{list.id}"
-    refute_includes last_response.body, "/lists/#{list.id}/sync"
+    csrf_post "/lists/#{list.id}/sync", { "new_items" => "Tacos" }
+    assert_equal %w[Pizza Sushi Tacos], list.items_dataset.order(:id).map(:name)
   end
 
   def test_record_result_rejects_archived_item