Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions app/javascript/controllers/comment_draft_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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 only once the comment was submitted
// successfully — a failed submission (network error, 5xx) must keep the draft.
export default class extends Controller {
static values = { key: String }

connect() {
this.saveTimer = null
this.form = this.element.closest('form')

this.handleSubmitEnd = (event) => {
if (event.detail.success) this.clear()
}
if (this.form) this.form.addEventListener('turbo:submit-end', this.handleSubmitEnd)

this.restore()
}

disconnect() {
// A successful submit re-renders the frame, so this disconnect must not
// resurrect the just-cleared draft: flush only when a save is pending.
this.flush()
if (this.form) this.form.removeEventListener('turbo:submit-end', this.handleSubmitEnd)
}

// 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(() => {
this.saveTimer = null
this.persist()
}, 300)
}

// Write a pending (debounced but not yet saved) draft immediately.
flush() {
if (!this.saveTimer) return

clearTimeout(this.saveTimer)
this.saveTimer = null
this.persist()
}

persist() {
const value = this.element.value
if (value === '') {
this.remove()
} else {
this.write(value)
}
}

// Clear the draft on successful submit so the posted comment is not
// restored on reload.
clear() {
if (this.saveTimer) {
clearTimeout(this.saveTimer)
this.saveTimer = null
}
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.
}
}
}
2 changes: 2 additions & 0 deletions app/javascript/controllers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion app/views/proposals/_comments.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 63 additions & 0 deletions spec/system/staff/internal_comment_draft_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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) }
let(:draft_key) { "internal_comment_draft_#{proposal.id}" }

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

def read_draft(key)
page.evaluate_script("localStorage.getItem(#{key.to_json})")
end

# Wait out the controller's debounce until the draft lands in localStorage.
def wait_for_draft(key, value)
start = Time.now
until read_draft(key) == value
raise "draft #{key.inspect} was not saved within #{Capybara.default_max_wait_time}s" if Time.now - start > Capybara.default_max_wait_time
sleep 0.05
end
end

def draft_keys
page.evaluate_script("Object.keys(localStorage).filter(key => key.includes('comment_draft'))")
end

scenario "restores the internal comment draft after a reload" do
fill_in 'internal_comment_body', with: 'Draft in progress'
wait_for_draft(draft_key, 'Draft in progress')

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'
wait_for_draft(draft_key, 'A new comment')

within '#new_internal_comment' do
click_button 'Comment'
end
expect(page).to have_css('.internal-comments .comment', text: 'A new comment')

expect(read_draft(draft_key)).to be_nil
end

scenario "does not persist a draft for public comments" do
fill_in 'public_comment_body', with: 'Public draft'
# Positive control: prove the draft machinery has run before asserting
# that the public textarea produced no draft.
fill_in 'internal_comment_body', with: 'control'
wait_for_draft(draft_key, 'control')

expect(draft_keys).to eq([draft_key])
end
end
Loading