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
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 page reloads,
// mirroring GitHub's issue/PR comment draft behavior. Save on input (debounced),
// restore on connect, and clear on successful submit.
export default class extends Controller {
static values = { key: String }

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

// Bind handlers so we can remove the exact same references on disconnect.
this.onSubmitStart = this.onSubmitStart.bind(this)
this.onSubmitEnd = this.onSubmitEnd.bind(this)

if (this.form) {
this.form.addEventListener('turbo:submit-start', this.onSubmitStart)
this.form.addEventListener('turbo:submit-end', this.onSubmitEnd)
}

this.restore()
}

disconnect() {
if (this.saveTimer) clearTimeout(this.saveTimer)

if (this.form) {
this.form.removeEventListener('turbo:submit-start', this.onSubmitStart)
this.form.removeEventListener('turbo:submit-end', this.onSubmitEnd)
}
}

// 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)
}

// Optimistically clear the draft when submission starts. This runs before the
// Turbo frame re-renders, so a re-connected controller won't restore the
// just-submitted text. The value is stashed for restore-on-failure.
onSubmitStart() {
if (this.saveTimer) clearTimeout(this.saveTimer)
this.pendingValue = this.element.value
this.remove()
}

// Only network/server errors surface as success === false; validation
// failures redirect_back and look like success. Restore the draft on error.
onSubmitEnd(event) {
if (event.detail && event.detail.success === false && this.pendingValue) {
this.write(this.pendingValue)
}
this.pendingValue = null
}

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 @@ -13,6 +13,7 @@ import AlertAutodismissController from "./alert_autodismiss_controller"
import AlertController from "./alert_controller"
import BannerAdsController from "./banner_ads_controller"
import CardDisplayToggleController from "./card_display_toggle_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 @@ -53,6 +54,7 @@ application.register("alert-autodismiss", AlertAutodismissController)
application.register("alert", AlertController)
application.register("banner-ads", BannerAdsController)
application.register("card-display-toggle", CardDisplayToggleController)
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