Centralize series edits behind one Save
A section-level seriesEditor batches interval, shared, and note changes into a single PATCH fired on Save click, replacing the prior auto-save on change/blur.
Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
diff --git a/lib/ketchup/views/series/show.rb b/lib/ketchup/views/series/show.rb
index baa4c0f..50fca78 100644
--- a/lib/ketchup/views/series/show.rb
+++ b/lib/ketchup/views/series/show.rb
@@ -3,7 +3,6 @@
require "phlex"
require_relative "../layout"
-require_relative "../shared_icon"
module Ketchup
module Views
@@ -22,7 +21,10 @@ module Ketchup
render Layout.new(current_user: @current_user, title: "#{note_title} — Ketchup", active_view: nil) do
div(class: "dashboard") do
div(class: "main-column") do
- section(class: "section", "x-data": "{ editing: false }") do
+ section(
+ class: "section",
+ "x-data": "seriesEditor(#{@series.id}, #{@series.interval_count}, '#{@series.interval_unit}', #{@series.shared})"
+ ) do
div(class: "section-header") do
h2(class: "section-title") do
span(class: "section-title-text") { "Series" }
@@ -30,7 +32,7 @@ module Ketchup
button(
class: "section-edit-btn",
"x-show": "!editing",
- "x-on:click": "editing = true; $dispatch('start-editing')"
+ "x-on:click": "startEditing()"
) do
plain "Edit"
end
@@ -38,7 +40,7 @@ module Ketchup
class: "section-edit-btn section-edit-btn--cancel",
"x-show": "editing",
"x-cloak": true,
- "x-on:click": "editing = false; location.reload()"
+ "x-on:click": "cancel()"
) do
plain "Cancel"
end
@@ -46,7 +48,7 @@ module Ketchup
class: "section-edit-btn",
"x-show": "editing",
"x-cloak": true,
- "x-on:click": "editing = false; $dispatch('stop-editing')"
+ "x-on:click": "save()"
) do
plain "Save"
end
@@ -59,8 +61,7 @@ module Ketchup
div(class: "series-note", id: "series-note-detail",
"x-bind:class": "{ 'series-note--editable': editing }",
- "data-value": @series.note || "",
- "data-series-id": @series.id.to_s)
+ "data-value": @series.note || "")
dl(class: "detail-fields") do
dt { "Repeat every" }
@@ -70,45 +71,33 @@ module Ketchup
dd(
class: "detail-edit-interval",
"x-show": "editing",
- "x-cloak": true,
- "x-data": "intervalEditor(#{@series.id}, #{@series.interval_count}, '#{@series.interval_unit}')"
+ "x-cloak": true
) do
input(
type: "number",
class: "detail-input detail-input-count",
min: 1,
- "x-model.number": "count",
- "x-on:change": "save()"
+ "x-model.number": "count"
)
select(
class: "detail-input detail-input-unit",
- "x-model": "unit",
- "x-on:change": "save()"
+ "x-model": "unit"
) do
INTERVAL_OPTIONS.each { |val, label| option(value: val) { label } }
end
end
dt { "Shared" }
- dd("x-show": "!editing") do
- if @series.shared
- plain "Yes"
- render SharedIcon.new
- else
- plain "No"
- end
- end
+ dd("x-show": "!editing") { @series.shared ? "Yes" : "No" }
dd(
"x-show": "editing",
- "x-cloak": true,
- "x-data": "sharedEditor(#{@series.id}, #{@series.shared})"
+ "x-cloak": true
) do
label(class: "detail-checkbox-label") do
input(
type: "checkbox",
class: "detail-checkbox",
- "x-model": "shared",
- "x-on:change": "save()"
+ "x-model": "shared"
)
plain "Shared with everyone"
end
diff --git a/public/js/app.js b/public/js/app.js
index 54aebd0..27375cf 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -48,14 +48,6 @@ function compactOverType(el) {
return resize
}
-function saveSeriesField(seriesId, field, value) {
- return fetch(`/series/${seriesId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `${encodeURIComponent(field)}=${encodeURIComponent(value)}`,
- })
-}
-
OverType.setTheme({
name: "ketchup",
colors: {
@@ -86,7 +78,6 @@ OverType.setTheme({
function initPanelEditors(container) {
const noteDetail = container.querySelector("#series-note-detail")
if (noteDetail) {
- const seriesId = noteDetail.dataset.seriesId
const initialNote = noteDetail.dataset.value || ""
const [editor] = new OverType(noteDetail, {
@@ -99,17 +90,14 @@ function initPanelEditors(container) {
const resizeNote = compactOverType(noteDetail)
+ noteDetail._overtype = editor
+ noteDetail._initialNote = initialNote
+
const ta = noteDetail.querySelector("textarea")
if (ta) {
ta.style.pointerEvents = "none"
ta.readOnly = true
- ta.addEventListener("blur", () => {
- const note = editor.getValue().trim()
- if (note === (initialNote || "").trim()) return
- saveSeriesField(seriesId, "note", note).then((r) => { if (r.ok) location.reload() })
- })
-
container.addEventListener("start-editing", () => {
ta.style.pointerEvents = ""
ta.readOnly = false
@@ -118,11 +106,6 @@ function initPanelEditors(container) {
})
container.addEventListener("stop-editing", () => {
- const note = editor.getValue().trim()
- if (note !== (initialNote || "").trim()) {
- saveSeriesField(seriesId, "note", note).then((r) => { if (r.ok) location.reload() })
- return
- }
ta.style.pointerEvents = "none"
ta.readOnly = true
if (resizeNote) requestAnimationFrame(resizeNote)
@@ -132,15 +115,45 @@ function initPanelEditors(container) {
}
document.addEventListener("alpine:init", () => {
- Alpine.data("intervalEditor", (seriesId, initialCount, initialUnit) => ({
+ Alpine.data("seriesEditor", (seriesId, initialCount, initialUnit, initialShared) => ({
+ editing: false,
count: initialCount,
unit: initialUnit,
+ shared: initialShared,
+
+ startEditing() {
+ this.editing = true
+ this.$dispatch("start-editing")
+ },
+
+ cancel() {
+ this.editing = false
+ location.reload()
+ },
save() {
+ this.editing = false
+ this.$dispatch("stop-editing")
+
+ const params = new URLSearchParams()
+ if (this.count !== initialCount) params.set("interval_count", this.count)
+ if (this.unit !== initialUnit) params.set("interval_unit", this.unit)
+ if (this.shared !== initialShared) params.set("shared", this.shared ? "1" : "0")
+
+ const noteEl = this.$el.querySelector("#series-note-detail")
+ if (noteEl?._overtype) {
+ const note = noteEl._overtype.getValue().trim()
+ if (note && note !== (noteEl._initialNote || "").trim()) {
+ params.set("note", note)
+ }
+ }
+
+ if (params.toString() === "") return
+
fetch(`/series/${seriesId}`, {
method: "PATCH",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `interval_count=${encodeURIComponent(this.count)}&interval_unit=${encodeURIComponent(this.unit)}`,
+ body: params.toString(),
}).then((r) => { if (r.ok) location.reload() })
},
}))
@@ -251,18 +264,6 @@ document.addEventListener("alpine:init", () => {
},
}))
- Alpine.data("sharedEditor", (seriesId, initialShared) => ({
- shared: initialShared,
-
- save() {
- fetch(`/series/${seriesId}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: `shared=${encodeURIComponent(this.shared ? "1" : "0")}`,
- }).then((r) => { if (r.ok) location.reload() })
- },
- }))
-
// Series detail page editor
initPanelEditors(document)