diff --git a/app/assets/images/response-template.svg b/app/assets/images/response-template.svg new file mode 100644 index 000000000..fc7320f03 --- /dev/null +++ b/app/assets/images/response-template.svg @@ -0,0 +1 @@ + diff --git a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb index 33db006aa..24ec30105 100644 --- a/app/assets/javascripts/initializers/initializeCommentsPage.js.erb +++ b/app/assets/javascripts/initializers/initializeCommentsPage.js.erb @@ -480,7 +480,7 @@ function handleImageUpload(event, randomIdNumber) { var uploadedMessage = 'Uploaded! Paste into editor'; messageContainer.innerHTML = uploadedMessage; messageContainer.style.color = '#00c673'; - messageContainer.style.position = "relative" + messageContainer.style.position = "relative"; messageContainer.style.top = "5px"; } ); diff --git a/app/assets/javascripts/utilities/buildCommentFormHTML.js.erb b/app/assets/javascripts/utilities/buildCommentFormHTML.js.erb index f82b68d37..ac75075f9 100644 --- a/app/assets/javascripts/utilities/buildCommentFormHTML.js.erb +++ b/app/assets/javascripts/utilities/buildCommentFormHTML.js.erb @@ -19,12 +19,29 @@ function buildCommentFormHTML(commentableId, commentableType, parentId) { \ \ \ + \ \ '+previewDiv+'\ '+codeOfConductHTML+'\ \ markdown guide\ \ +
\ + \ +
\
\ \ +
+ `; + }) + .join(''); + } + if (typeOf === 'mod_comment') { + return response + .map((obj) => { + return ` +
+ ${obj.title} +

${obj.content}

+ + +
+ `; + }) + .join(''); + } + return `Error 😞`; +} + +function submitAsModerator(responseTemplateId, parentId) { + const commentableId = document.querySelector('input#comment_commentable_id') + .value; + + fetch(`/comments/moderator_create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + response_template: { + id: responseTemplateId, + }, + comment: { + body_markdown: '', + commentable_id: commentableId, + commentable_type: 'Article', + parent_id: parentId, + }, + }), + }) + .then((response) => response.json()) + .then((response) => { + if (response.status === 'created') { + window.location.pathname = response.path; + } else if (response.status === 'comment already exists') { + alert('This comment already exists.'); + } else if (response.error === 'error') { + alert( + `There was a problem submitting this comment: ${response.status}`, + ); + } + }); +} + +const confirmMsg = ` +Are you sure you want to submit this comment as Sloan? + +It will be sent immediately and users will be notified. + +Make sure this is the appropriate comment for the situation. + +This action is not reversible.`; + +function addClickListeners(form) { + const responsesContainer = form.querySelector( + '.response-templates-container', + ); + const parentCommentId = + form.id !== 'new_comment' + ? form.querySelector('input#comment_parent_id').value + : null; + const insertButtons = Array.from( + responsesContainer.getElementsByClassName('insert-template-button'), + ); + const moderatorSubmitButtons = Array.from( + responsesContainer.getElementsByClassName('moderator-submit-button'), + ); + + insertButtons.forEach((button) => { + button.addEventListener('click', (e) => { + const { content } = e.target.dataset; + const textArea = form.querySelector('textarea'); + const textAreaReplaceable = + textArea.value === null || + textArea.value === '' || + confirm('Are you sure you want to replace your current comment draft?'); + + if (textAreaReplaceable) { + textArea.value = content; + responsesContainer.classList.toggle('hidden'); + } + }); + }); + + moderatorSubmitButtons.forEach((button) => { + button.addEventListener('click', (e) => { + e.preventDefault(); + + if (confirm(confirmMsg)) { + submitAsModerator(e.target.dataset.responseTemplateId, parentCommentId); + } + }); + }); +} + +function fetchResponseTemplates(typeOf, formId) { + const form = document.getElementById(formId); + let dataContainer; + if (typeOf === 'personal_comment') { + dataContainer = form.querySelector('.personal-responses-container'); + } else if (typeOf === 'mod_comment') { + dataContainer = form.querySelector('.moderator-responses-container'); + } + /* eslint-disable-next-line no-undef */ + fetch(`/response_templates?type_of=${typeOf}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'X-CSRF-Token': window.csrfToken, + 'Content-Type': 'application/json', + }, + }) + .then((response) => response.json()) + .then((response) => { + form.querySelector('img.loading-img').classList.toggle('hidden'); + dataContainer.innerHTML = buildHTML(response, typeOf); + const topLevelData = document.getElementById('response-templates-data'); + topLevelData.innerHTML = dataContainer.parentElement.innerHTML; + addClickListeners(form); + }); +} + +function prepareHeaderButtons(form) { + const personalTemplateButton = form.querySelector( + '.personal-template-button', + ); + const modTemplateButton = form.querySelector('.moderator-template-button'); + + personalTemplateButton.addEventListener('click', (e) => { + toggleTemplateTypeButton(form, e); + }); + modTemplateButton.addEventListener('click', (e) => { + toggleTemplateTypeButton(form, e); + }); + modTemplateButton.classList.remove('hidden'); + + modTemplateButton.addEventListener( + 'click', + () => { + const topLevelData = document.getElementById('response-templates-data'); + const modDataNotFetched = + topLevelData.innerHTML !== '' + ? topLevelData.querySelector('.moderator-responses-container') + .childElementCount === 0 + : false; + if (modDataNotFetched) { + form.querySelector('img.loading-img').classList.toggle('hidden'); + fetchResponseTemplates('mod_comment', form.id); + } + }, + { once: true }, + ); +} + +function copyData(responsesContainer) { + responsesContainer.innerHTML = document.getElementById( + 'response-templates-data', + ).innerHTML; +} + +function loadData(form) { + form.querySelector('img.loading-img').classList.toggle('hidden'); + fetchResponseTemplates('personal_comment', form.id); +} + +function openButtonCallback(form) { + const responsesContainer = form.querySelector( + '.response-templates-container', + ); + const dataFetched = + document.getElementById('response-templates-data').innerHTML !== ''; + + responsesContainer.classList.toggle('hidden'); + + const containerHidden = responsesContainer.classList.contains('hidden'); + + if (dataFetched && !containerHidden) { + copyData(responsesContainer); + addClickListeners(form); + } else if (!dataFetched && !containerHidden) { + loadData(form) + } + /* eslint-disable-next-line no-undef */ + if (userData().moderator_for_tags.length > 0) { + prepareHeaderButtons(form); + } else { + form.querySelector('.personal-template-button').classList.add('hidden'); + } +} + +function prepareOpenButton(form) { + const button = form.querySelector('.response-templates-button'); + if (!button) { + return; + } + + button.addEventListener('click', () => { + openButtonCallback(form); + }); + + button.dataset.hasListener = "true"; +} + +function observeForReplyClick() { + const config = { childList: true, subtree: true }; + + const callback = (mutations) => { + const form = mutations[0].addedNodes[0]; + if (form.nodeName === 'FORM') { + prepareOpenButton(form); + } + }; + + const observer = new MutationObserver(callback); + + const commentTree = document.getElementById('comment-trees-container'); + observer.observe(commentTree, config); + + window.addEventListener('beforeunload', () => { + observer.disconnect(); + }); + + window.InstantClick.on('change', () => { + observer.disconnect(); + }); +} + +function handleLoggedOut() { + const toggleButton = document.querySelector('.response-templates-button'); + // global method from app/assets/javascripts/utilities/showModal.js + /* eslint-disable-next-line no-undef */ + toggleButton.addEventListener('click', showModal); +} +/* eslint-enable no-alert */ +/* eslint-enable no-restricted-globals */ + +export function loadResponseTemplates() { + const { userStatus } = document.body.dataset; + const form = document.getElementById('new_comment'); + + if (userStatus === 'logged-out') { + handleLoggedOut(); + } + if (document.getElementById('response-templates-data')) { + if ( + form && + form.querySelector('.response-templates-button').dataset.hasListener === 'false' + ) { + prepareOpenButton(form); + } + observeForReplyClick(); + } +} diff --git a/app/models/response_template.rb b/app/models/response_template.rb index 8259255af..187331052 100644 --- a/app/models/response_template.rb +++ b/app/models/response_template.rb @@ -4,12 +4,14 @@ class ResponseTemplate < ApplicationRecord belongs_to :user, optional: true UNIQUENESS_SCOPE = %i[user_id type_of content_type].freeze - TYPE_OF_TYPES = %w[personal_comment mod_comment abuse_report_email_reply email_reply].freeze + TYPE_OF_TYPES = %w[personal_comment mod_comment abuse_report_email_reply email_reply tag_adjustment].freeze + USER_NIL_TYPE_OF_TYPES = %w[mod_comment abuse_report_email_reply email_reply tag_adjustment].freeze CONTENT_TYPES = %w[plain_text html body_markdown].freeze COMMENT_CONTENT_TYPE = %w[body_markdown].freeze EMAIL_CONTENT_TYPES = %w[plain_text html].freeze COMMENT_VALIDATION_MSG = "Comment templates must use Markdown as its content type.".freeze EMAIL_VALIDATION_MSG = "Email templates must use plain text or HTML as its content type.".freeze + USER_NIL_TYPE_OF_MSG = "cannot have a user ID associated.".freeze validates :type_of, :content_type, :content, :title, presence: true validates :content, uniqueness: { scope: UNIQUENESS_SCOPE } @@ -21,4 +23,11 @@ class ResponseTemplate < ApplicationRecord validates :content_type, inclusion: { in: EMAIL_CONTENT_TYPES, message: EMAIL_VALIDATION_MSG }, if: -> { type_of&.include?("email") } + validate :user_nil_only_for_user_nil_types + + def user_nil_only_for_user_nil_types + if user_id.present? && USER_NIL_TYPE_OF_TYPES.include?(type_of) + errors.add(:type_of, USER_NIL_TYPE_OF_MSG) + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index 89519ac15..3bfb28d5a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -438,6 +438,7 @@ class User < ApplicationRecord Notifications Publishing\ from\ RSS Organization + Response\ Templates Billing Account Misc diff --git a/app/policies/comment_policy.rb b/app/policies/comment_policy.rb index c59828b07..210e63b52 100644 --- a/app/policies/comment_policy.rb +++ b/app/policies/comment_policy.rb @@ -27,6 +27,10 @@ class CommentPolicy < ApplicationPolicy true end + def moderator_create? + !user_is_blocked? && (user_is_moderator? || minimal_admin?) + end + def hide? user_is_commentable_author? end @@ -47,8 +51,16 @@ class CommentPolicy < ApplicationPolicy %i[body_markdown commentable_id commentable_type parent_id] end + def permitted_attributes_for_moderator_create + %i[commentable_id commentable_type parent_id] + end + private + def user_is_moderator? + user.moderator_for_tags.present? + end + def user_is_comment_banned? user.has_role? :comment_banned end diff --git a/app/policies/response_template_policy.rb b/app/policies/response_template_policy.rb index ea054fc3b..c1f314b9f 100644 --- a/app/policies/response_template_policy.rb +++ b/app/policies/response_template_policy.rb @@ -1,6 +1,10 @@ class ResponseTemplatePolicy < ApplicationPolicy PERMITTED_ATTRIBUTES = %i[content_type content title].freeze + def index? + true + end + def admin_index? minimal_admin? end diff --git a/app/views/comments/_form.html.erb b/app/views/comments/_form.html.erb index f73fc3e6a..b34e18aaf 100644 --- a/app/views/comments/_form.html.erb +++ b/app/views/comments/_form.html.erb @@ -1,4 +1,5 @@ -<%= javascript_packs_with_chunks_tag "validateFileInputs", defer: true %> +
+<%= javascript_packs_with_chunks_tag "validateFileInputs", "responseTemplates", defer: true %> <% if @comment.errors.any? %>
@@ -28,6 +29,23 @@ <%= f.hidden_field :parent_id, value: @parent_comment.id if @parent_comment %> <% end %>
+ <%= f.text_area :body_markdown, placeholder: "Add to the discussion", onfocus: "handleFocus(event)", @@ -45,6 +63,11 @@ markdown guide" /> +
+ +