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
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