Skip to content
Draft
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
82 changes: 82 additions & 0 deletions app/javascript/controllers/comment_draft_controller.js
Original file line number Diff line number Diff line change
@@ -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.
}
}
}
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
44 changes: 44 additions & 0 deletions spec/system/staff/internal_comment_draft_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading