From 11a7afb836933bafe18791d261689f2467689f0a Mon Sep 17 00:00:00 2001 From: Yusuke Nakamura Date: Fri, 10 Jul 2026 20:01:38 +0900 Subject: [PATCH] Preserve internal comment draft across reloads Add a `comment-draft` Stimulus controller that saves the internal comment textarea to localStorage (debounced on input) and restores it on reconnect, mirroring GitHub's issue/PR comment draft behavior. Since CommentsController redirect_backs on both success and validation failure, turbo:submit-end can't distinguish them. Clear the draft optimistically on submit-start and restore it only on a failed request (success === false), which also avoids the re-render race where a reconnected controller would otherwise restore the just-submitted text. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/comment_draft_controller.js | 82 +++++++++++++++++++ app/javascript/controllers/index.js | 2 + app/views/proposals/_comments.html.haml | 2 +- .../staff/internal_comment_draft_spec.rb | 44 ++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 app/javascript/controllers/comment_draft_controller.js create mode 100644 spec/system/staff/internal_comment_draft_spec.rb diff --git a/app/javascript/controllers/comment_draft_controller.js b/app/javascript/controllers/comment_draft_controller.js new file mode 100644 index 000000000..29f43b2f4 --- /dev/null +++ b/app/javascript/controllers/comment_draft_controller.js @@ -0,0 +1,82 @@ +import { Controller } from '@hotwired/stimulus' + +// Persists a textarea draft to localStorage so it survives a full page reload, +// mirroring GitHub's issue/PR comment draft behavior. Save on input (debounced), +// restore on connect, and clear the draft once the comment is submitted so a +// posted comment is not resurrected on the next reload. +export default class extends Controller { + static values = { key: String } + + connect() { + this.saveTimer = null + this.form = this.element.closest('form') + + // Bind so we can remove the exact same reference on disconnect. + this.clear = this.clear.bind(this) + if (this.form) this.form.addEventListener('submit', this.clear) + + this.restore() + } + + disconnect() { + if (this.saveTimer) clearTimeout(this.saveTimer) + if (this.form) this.form.removeEventListener('submit', this.clear) + } + + // Restore the saved draft, but only when the textarea is empty so a + // server-rendered value is never overwritten. + restore() { + if (this.element.value !== '') return + + const saved = this.read() + if (saved === null || saved === '') return + + this.element.value = saved + // Notify mention (Tribute) and other listeners of the restored value. + this.element.dispatchEvent(new Event('input', { bubbles: true })) + } + + // Debounced save triggered by the textarea's input event. + save() { + if (this.saveTimer) clearTimeout(this.saveTimer) + + this.saveTimer = setTimeout(() => { + const value = this.element.value + if (value === '') { + this.remove() + } else { + this.write(value) + } + }, 300) + } + + // Clear the draft on submit so the posted comment is not restored on reload. + clear() { + if (this.saveTimer) clearTimeout(this.saveTimer) + this.remove() + } + + read() { + try { + return localStorage.getItem(this.keyValue) + } catch (e) { + return null + } + } + + write(value) { + try { + localStorage.setItem(this.keyValue, value) + } catch (e) { + // Ignore private-mode / quota errors; drafting is best-effort. + } + } + + remove() { + try { + localStorage.removeItem(this.keyValue) + } catch (e) { + // Ignore private-mode errors. + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 8b2351d5a..c31069bc3 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -14,6 +14,7 @@ import AlertController from "./alert_controller" import BannerAdsController from "./banner_ads_controller" import CardDisplayToggleController from "./card_display_toggle_controller" import DisableSubmitController from "./disable_submit_controller" +import CommentDraftController from "./comment_draft_controller" import GridModalController from "./grid_modal_controller" import CfpDatatableController from "./cfp_datatable_controller" import ContentController from "./content_controller" @@ -55,6 +56,7 @@ application.register("alert", AlertController) application.register("banner-ads", BannerAdsController) application.register("card-display-toggle", CardDisplayToggleController) application.register("disable-submit", DisableSubmitController) +application.register("comment-draft", CommentDraftController) application.register("grid-modal", GridModalController) application.register("cfp-datatable", CfpDatatableController) application.register("content", ContentController) diff --git a/app/views/proposals/_comments.html.haml b/app/views/proposals/_comments.html.haml index 2461b1919..e661a7f3b 100644 --- a/app/views/proposals/_comments.html.haml +++ b/app/views/proposals/_comments.html.haml @@ -14,7 +14,7 @@ = f.hidden_field :proposal_id .form-group - if internal?(f.object) - = f.text_area :body, class: 'form-control', placeholder: 'Add your comment', rows: 5, data: { controller: 'mention', mention_names: @mention_names } + = f.text_area :body, class: 'form-control', placeholder: 'Add your comment', rows: 5, data: { controller: 'mention comment-draft', mention_names: @mention_names, action: 'input->comment-draft#save', 'comment-draft-key-value': "internal_comment_draft_#{proposal.id}" } - else = f.text_area :body, class: 'form-control', placeholder: 'Add your comment', rows: 5 .form-group diff --git a/spec/system/staff/internal_comment_draft_spec.rb b/spec/system/staff/internal_comment_draft_spec.rb new file mode 100644 index 000000000..0ce73d1f3 --- /dev/null +++ b/spec/system/staff/internal_comment_draft_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +feature "Internal comment draft", type: :system, js: true do + let(:event) { create(:event, state: 'open') } + let(:reviewer) { create(:organizer, event: event) } + let(:proposal) { create(:proposal_with_track, event: event) } + + before do + login_as(reviewer) + # Internal comments are only shown once the reviewer has rated the proposal. + create(:rating, proposal: proposal, user: reviewer) + visit event_staff_proposal_path(event, proposal) + end + + scenario "restores the internal comment draft after a reload" do + fill_in 'internal_comment_body', with: 'Draft in progress' + sleep 0.5 # let the 300ms debounce flush to localStorage + + visit event_staff_proposal_path(event, proposal) + + expect(page).to have_field('internal_comment_body', with: 'Draft in progress') + end + + scenario "clears the draft after a successful submit" do + fill_in 'internal_comment_body', with: 'A new comment' + within '#new_internal_comment' do + click_button 'Comment' + end + expect(page).to have_css('.internal-comments .comment', text: 'A new comment') + + visit event_staff_proposal_path(event, proposal) + + expect(page).to have_field('internal_comment_body', with: '') + end + + scenario "does not persist a draft for public comments" do + fill_in 'public_comment_body', with: 'Public draft' + sleep 0.5 + + visit event_staff_proposal_path(event, proposal) + + expect(page).to have_field('public_comment_body', with: '') + end +end