[deploy] Finalize and add response templates feature (#7068)
This commit is contained in:
parent
cc6249beff
commit
f5ec83653c
22 changed files with 750 additions and 9 deletions
1
app/assets/images/response-template.svg
Normal file
1
app/assets/images/response-template.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="file-alt" class="svg-inline--fa fa-file-alt fa-w-12" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path fill="currentColor" d="M224 136V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H248c-13.2 0-24-10.8-24-24zm64 236c0 6.6-5.4 12-12 12H108c-6.6 0-12-5.4-12-12v-8c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12v8zm0-64c0 6.6-5.4 12-12 12H108c-6.6 0-12-5.4-12-12v-8c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12v8zm0-72v8c0 6.6-5.4 12-12 12H108c-6.6 0-12-5.4-12-12v-8c0-6.6 5.4-12 12-12h168c6.6 0 12 5.4 12 12zm96-114.1v6.1H256V0h6.1c6.4 0 12.5 2.5 17 7l97.9 98c4.5 4.5 7 10.6 7 16.9z"></path></svg>
|
||||
|
After Width: | Height: | Size: 714 B |
|
|
@ -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";
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,12 +19,29 @@ function buildCommentFormHTML(commentableId, commentableType, parentId) {
|
|||
<input value="' + commentableId + '" type="hidden" name="comment[commentable_id]" id="comment_commentable_id" />\
|
||||
<input value="' + commentableType + '" type="hidden" name="comment[commentable_type]" id="comment_commentable_type" />\
|
||||
<input value="' + parentId + '" type="hidden" name="comment[parent_id]" id="comment_parent_id" />\
|
||||
<div class="response-templates-container hidden">\
|
||||
<header>\
|
||||
<button type="button" class="personal-template-button active" data-target-type="personal">PERSONAL</button>\
|
||||
<button type="button" class="moderator-template-button hidden" data-target-type="moderator">MODERATOR</button>\
|
||||
</header>\
|
||||
<img class="loading-img hidden" src="<%= asset_path("loading-ellipsis.svg") %>" alt="loading">\
|
||||
<div class="personal-responses-container">\
|
||||
</div>\
|
||||
<div class="moderator-responses-container hidden">\
|
||||
</div>\
|
||||
<a target="_blank" rel="noopener nofollow" href="/settings/response-templates">Create new template</a>\
|
||||
</div>\
|
||||
<textarea id="textarea-for-' + parentId + '" class="embiggened" name="comment[body_markdown]" id="comment_body_markdown" required onkeydown="handleKeyDown.bind(this)(event)"></textarea>\
|
||||
'+previewDiv+'\
|
||||
'+codeOfConductHTML+'\
|
||||
<a href="/p/editor_guide" class="markdown-guide" target="_blank" rel="noopener" title="Markdown Guide">\
|
||||
<img alt="markdown guide" class="icon-image" src="<%= asset_path('info.svg') %>" />\
|
||||
</a>\
|
||||
<div class="editor-response-templates in-reply">\
|
||||
<button class="response-templates-button" title="Use a response template" type="button" data-has-listener="false">\
|
||||
<img src="<%= asset_path("response-template.svg") %>" alt="" class="icon-image">\
|
||||
</button>\
|
||||
</div>\
|
||||
<div class="editor-image-upload">\
|
||||
<input type="file" id="image-upload-' + randomIdNumber + '" name="file" accept="image/*" style="display:none">\
|
||||
<button title="Upload Image" class="image-upload-button" id="image-upload-button-' + randomIdNumber + '" onclick="handleImageUpload(event,'+ randomIdNumber + ')">\
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ a.header-link {
|
|||
align-items: center;
|
||||
font-size: 0.8em;
|
||||
position: absolute;
|
||||
left: calc(0.2vw + 43px);
|
||||
left: calc(0.2vw + 70px);
|
||||
bottom: 0px;
|
||||
width: initial;
|
||||
text-align: left;
|
||||
|
|
@ -199,6 +199,20 @@ a.header-link {
|
|||
}
|
||||
}
|
||||
}
|
||||
.editor-response-templates {
|
||||
position: absolute;
|
||||
left: 52px;
|
||||
bottom: 4px;
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
&.in-reply {
|
||||
z-index: 6;
|
||||
bottom: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.top-level-actions {
|
||||
margin: 0px 0px 85px;
|
||||
|
|
@ -271,6 +285,130 @@ a.header-link {
|
|||
.field {
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
.response-templates-container {
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
display: flex;
|
||||
width: calc(100% - 52px);
|
||||
max-height: calc(40vh + 80px);
|
||||
margin: 15px auto 2px;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
font-size: 16px;
|
||||
font-family: $helvetica;
|
||||
@include themeable(background, theme-container-accent-background, $light-gray);
|
||||
@include themeable(color, theme-color, $black);
|
||||
|
||||
.loading-img {
|
||||
display: inline-block;
|
||||
margin: 0 auto;
|
||||
width: 75px;
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
header > button {
|
||||
font-family: $helvetica-condensed;
|
||||
font-stretch: condensed;
|
||||
color: $bold-blue;
|
||||
border: 1px solid $bold-blue;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 5px 0px;
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
width: 125px;
|
||||
margin: 5px;
|
||||
float: left;
|
||||
&.active {
|
||||
background: $bold-blue;
|
||||
color: white;
|
||||
}
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mod-response-wrapper {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
position: relative;
|
||||
@include themeable(background, theme-container-background, white);
|
||||
margin: 5px auto;
|
||||
border-radius: 8px;
|
||||
width: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
span {
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
width: calc(100% - 140px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
p {
|
||||
font-weight: normal;
|
||||
display: block;
|
||||
width: calc(100% - 140px);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin: 0.5em 0;
|
||||
@include themeable(color, theme-secondary-color, $medium-gray);
|
||||
}
|
||||
&.mod-response-wrapper-empty {
|
||||
text-align: center;
|
||||
p {
|
||||
width: 100%;
|
||||
font-size: 1.05em;
|
||||
font-weight: bold;
|
||||
margin: 1.5em 0;
|
||||
@include themeable(color, theme-color, $black);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: $helvetica-condensed;
|
||||
font-stretch: condensed;
|
||||
color: white;
|
||||
border: 0px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-top: 3px;
|
||||
padding: 5px 0px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin-right: 10px;
|
||||
background: $bold-blue;
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 0px;
|
||||
width: 125px;
|
||||
}
|
||||
.moderator-submit-button {
|
||||
background: $green;
|
||||
color: $black;
|
||||
top: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
font-weight: bold;
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
position: relative;
|
||||
top: 4px;
|
||||
left: 3px;
|
||||
@include themeable(fill, link-branded-color, #3b49df)
|
||||
}
|
||||
}
|
||||
}
|
||||
.article-comment-form-preamble {
|
||||
font-size: 0.7em;
|
||||
margin-left: 20px;
|
||||
|
|
@ -1119,3 +1257,6 @@ a.header-link {
|
|||
font-style: italic;
|
||||
}
|
||||
}
|
||||
#response-templates-data {
|
||||
display: none;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ class CommentsController < ApplicationController
|
|||
before_action :set_cache_control_headers, only: [:index]
|
||||
before_action :authenticate_user!, only: %i[preview create hide unhide]
|
||||
after_action :verify_authorized
|
||||
after_action only: [:moderator_create] do
|
||||
Audit::Logger.log(:moderator, current_user, params.dup)
|
||||
end
|
||||
|
||||
# GET /comments
|
||||
# GET /comments.json
|
||||
|
|
@ -115,6 +118,38 @@ class CommentsController < ApplicationController
|
|||
render json: { error: message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def moderator_create
|
||||
return if RateLimitChecker.new(current_user).limit_by_action("comment_creation")
|
||||
|
||||
response_template = ResponseTemplate.find(params[:response_template][:id])
|
||||
authorize response_template, :moderator_create?
|
||||
|
||||
moderator = User.find(SiteConfig.mascot_user_id)
|
||||
@comment = Comment.new(permitted_attributes(Comment))
|
||||
@comment.user_id = moderator.id
|
||||
@comment.body_markdown = response_template.content
|
||||
authorize @comment
|
||||
|
||||
if @comment.save
|
||||
Mention.create_all(@comment)
|
||||
Notification.send_new_comment_notifications_without_delay(@comment)
|
||||
|
||||
render json: { status: "created", path: @comment.path }
|
||||
elsif (@comment = Comment.where(body_markdown: @comment.body_markdown,
|
||||
commentable_id: @comment.commentable.id,
|
||||
ancestry: @comment.ancestry)[0])
|
||||
render json: { status: "comment already exists" }, status: :conflict
|
||||
else
|
||||
render json: { status: @comment&.errors&.full_messages&.to_sentence }, status: :unprocessable_entity
|
||||
end
|
||||
rescue StandardError => e
|
||||
skip_authorization
|
||||
|
||||
Rails.logger.error(e)
|
||||
message = "There was an error in your markdown: #{e}"
|
||||
render json: { error: "error", status: message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# PATCH/PUT /comments/1
|
||||
# PATCH/PUT /comments/1.json
|
||||
def update
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
class Internal::ResponseTemplatesController < Internal::ApplicationController
|
||||
layout "internal"
|
||||
after_action only: %i[create update destroy] do
|
||||
Audit::Logger.log(:moderator, current_user, params.dup)
|
||||
end
|
||||
|
||||
def index
|
||||
@response_templates = if params[:filter]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,29 @@
|
|||
class ResponseTemplatesController < ApplicationController
|
||||
after_action :verify_authorized
|
||||
before_action :authenticate_user!, :ensure_json_request, only: %i[index]
|
||||
rescue_from ArgumentError, with: :error_unprocessable_entity
|
||||
|
||||
MOD_TYPES = %w[mod_comment tag_adjustment].freeze
|
||||
ADMIN_TYPES = %w[email_reply abuse_report_email_reply].freeze
|
||||
|
||||
def index
|
||||
raise ArgumentError, "Missing param type_of" if params[:type_of].blank?
|
||||
|
||||
user_id = params[:type_of] == "personal_comment" ? current_user.id : nil
|
||||
@response_templates = ResponseTemplate.where(type_of: params[:type_of], user_id: user_id)
|
||||
|
||||
if MOD_TYPES.include?(params[:type_of])
|
||||
authorize @response_templates, :moderator_index?
|
||||
elsif ADMIN_TYPES.include?(params[:type_of])
|
||||
authorize @response_templates, :admin_index?
|
||||
else
|
||||
authorize @response_templates, :index?
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.json { render :index }
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
authorize ResponseTemplate
|
||||
|
|
@ -61,4 +85,12 @@ class ResponseTemplatesController < ApplicationController
|
|||
ResponseTemplate.new(permitted_attributes(ResponseTemplate))
|
||||
end
|
||||
end
|
||||
|
||||
def ensure_json_request
|
||||
routing_error unless request.format == :json
|
||||
end
|
||||
|
||||
def error_unprocessable_entity(message)
|
||||
render json: { error: message, status: 422 }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
|
|
|||
7
app/javascript/packs/responseTemplates.js
Normal file
7
app/javascript/packs/responseTemplates.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { loadResponseTemplates } from '../responseTemplates/responseTemplates';
|
||||
|
||||
window.InstantClick.on('change', () => {
|
||||
loadResponseTemplates();
|
||||
});
|
||||
|
||||
loadResponseTemplates();
|
||||
305
app/javascript/responseTemplates/responseTemplates.js
Normal file
305
app/javascript/responseTemplates/responseTemplates.js
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/* eslint-disable no-alert */
|
||||
/* eslint-disable no-restricted-globals */
|
||||
|
||||
function toggleTemplateTypeButton(form, e) {
|
||||
const { targetType } = e.target.dataset;
|
||||
const activeType = targetType === 'personal' ? 'moderator' : 'personal';
|
||||
e.target.classList.toggle('active');
|
||||
form
|
||||
.querySelector(`.${activeType}-template-button`)
|
||||
.classList.toggle('active');
|
||||
form
|
||||
.querySelector(`.${targetType}-responses-container`)
|
||||
.classList.toggle('hidden');
|
||||
form
|
||||
.querySelector(`.${activeType}-responses-container`)
|
||||
.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
const noResponsesHTML = `
|
||||
<div class="mod-response-wrapper mod-response-wrapper-empty">
|
||||
<p>🤔... It looks like you don't have any templates yet.</p>
|
||||
<p>Create templates to quickly answer FAQs or store snippets for re-use.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
function buildHTML(response, typeOf) {
|
||||
if (response.length === 0 && typeOf === 'personal_comment') {
|
||||
return noResponsesHTML;
|
||||
}
|
||||
if (typeOf === 'personal_comment') {
|
||||
return response
|
||||
.map((obj) => {
|
||||
return `
|
||||
<div class="mod-response-wrapper">
|
||||
<span>${obj.title}</span>
|
||||
<p>${obj.content}</p>
|
||||
<button class="insert-template-button" type="button" data-content="${obj.content}">INSERT</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
if (typeOf === 'mod_comment') {
|
||||
return response
|
||||
.map((obj) => {
|
||||
return `
|
||||
<div class="mod-response-wrapper">
|
||||
<span>${obj.title}</span>
|
||||
<p>${obj.content}</p>
|
||||
<button class="insert-template-button" type="button" data-content="${obj.content}">INSERT</button>
|
||||
<button class="moderator-submit-button" type="submit" data-response-template-id="${obj.id}">SEND AS MOD</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -438,6 +438,7 @@ class User < ApplicationRecord
|
|||
Notifications
|
||||
Publishing\ from\ RSS
|
||||
Organization
|
||||
Response\ Templates
|
||||
Billing
|
||||
Account
|
||||
Misc
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
<%= javascript_packs_with_chunks_tag "validateFileInputs", defer: true %>
|
||||
<div id="response-templates-data"></div>
|
||||
<%= javascript_packs_with_chunks_tag "validateFileInputs", "responseTemplates", defer: true %>
|
||||
|
||||
<% if @comment.errors.any? %>
|
||||
<div id="error_explanation">
|
||||
|
|
@ -28,6 +29,23 @@
|
|||
<%= f.hidden_field :parent_id, value: @parent_comment.id if @parent_comment %>
|
||||
<% end %>
|
||||
<div class="field" id="textarea-wrapper">
|
||||
<div class="response-templates-container hidden">
|
||||
<header>
|
||||
<button type="button" class="personal-template-button active" data-target-type="personal" data-form-id="new_comment">PERSONAL</button>
|
||||
<button type="button" class="moderator-template-button hidden" data-target-type="moderator" data-form-id="new_comment">MODERATOR</button>
|
||||
</header>
|
||||
<img class="loading-img hidden" src="<%= asset_path("loading-ellipsis.svg") %>" alt="loading">
|
||||
<div class="personal-responses-container">
|
||||
<%# filled by JS %>
|
||||
</div>
|
||||
<div class="moderator-responses-container hidden">
|
||||
<%# filled by JS %>
|
||||
</div>
|
||||
<a target="_blank" rel="noopener nofollow" href="<%= user_settings_path(tab: "response-templates") %>" class="mod-response-create-new">
|
||||
Create new template
|
||||
<%= inline_svg_tag("external-link-logo.svg", aria: true) %>
|
||||
</a>
|
||||
</div>
|
||||
<%= f.text_area :body_markdown,
|
||||
placeholder: "Add to the discussion",
|
||||
onfocus: "handleFocus(event)",
|
||||
|
|
@ -45,6 +63,11 @@
|
|||
<a href="/p/editor_guide" class="markdown-guide" target="_blank" rel="noopener" title="Markdown Guide">
|
||||
<img alt="markdown guide" class="icon-image" src="<%= asset_path("info.svg") %>" />
|
||||
</a>
|
||||
<div class="editor-response-templates">
|
||||
<button class="response-templates-button" title="Use a response template" type="button" data-has-listener="false">
|
||||
<img src="<%= asset_path("response-template.svg") %>" alt="" class="icon-image" data-form-id="new_comment">
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-image-upload">
|
||||
<input type="file" id="image-upload-main" name="file" accept="image/*" style="display:none">
|
||||
<button class="image-upload-button" id="image-upload-button-main" onclick="handleImageUpload(event,'main')" title="Upload Image">
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@
|
|||
data-commentable-id="<%= @commentable.id %>"
|
||||
data-commentable-type="<%= @commentable.class.name %>">
|
||||
<% if @root_comment %>
|
||||
<div id="response-templates-data"></div>
|
||||
<%= javascript_packs_with_chunks_tag "responseTemplates", defer: true %>
|
||||
<div class="top-level-actions">
|
||||
<h3>re: <%= @commentable.title %> <a href="<%= @commentable.path %>">VIEW POST</a></h3>
|
||||
<span class="comment-action-buttons">
|
||||
|
|
|
|||
|
|
@ -159,6 +159,9 @@ Rails.application.routes.draw do
|
|||
resources :comments, only: %i[create update destroy] do
|
||||
patch "/hide", to: "comments#hide"
|
||||
patch "/unhide", to: "comments#unhide"
|
||||
collection do
|
||||
post "/moderator_create", to: "comments#moderator_create"
|
||||
end
|
||||
end
|
||||
resources :comment_mutes, only: %i[update]
|
||||
resources :users, only: %i[index], defaults: { format: :json } # internal API
|
||||
|
|
@ -167,7 +170,7 @@ Rails.application.routes.draw do
|
|||
end
|
||||
resources :twitch_live_streams, only: :show, param: :username
|
||||
resources :reactions, only: %i[index create]
|
||||
resources :response_templates, only: %i[create edit update destroy]
|
||||
resources :response_templates, only: %i[index create edit update destroy]
|
||||
resources :feedback_messages, only: %i[index create]
|
||||
resources :organizations, only: %i[update create]
|
||||
resources :followed_articles, only: [:index]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
class AddResponseTemplateIndexUserIdTypeOf < ActiveRecord::Migration[5.2]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_index :response_templates, %i[user_id type_of], algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
|
|
@ -931,6 +931,7 @@ ActiveRecord::Schema.define(version: 2020_04_09_050122) do
|
|||
t.datetime "updated_at", null: false
|
||||
t.bigint "user_id"
|
||||
t.index ["type_of"], name: "index_response_templates_on_type_of"
|
||||
t.index ["user_id", "type_of"], name: "index_response_templates_on_user_id_and_type_of"
|
||||
t.index ["user_id"], name: "index_response_templates_on_user_id"
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ RSpec.describe ResponseTemplate, type: :model do
|
|||
expect(response_template.valid?).to eq false
|
||||
expect(response_template.errors.messages[:content_type].to_sentence).to eq ResponseTemplate::COMMENT_VALIDATION_MSG
|
||||
end
|
||||
|
||||
it "validates that there is no user ID associated" do
|
||||
response_template = build(:response_template, type_of: "mod_comment", content_type: "body_markdown", user_id: 1)
|
||||
expect(response_template.valid?).to eq false
|
||||
expect(response_template.errors.messages[:type_of].to_sentence).to eq ResponseTemplate::USER_NIL_TYPE_OF_MSG
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ RSpec.describe CommentPolicy, type: :policy do
|
|||
%i[body_markdown receive_notifications]
|
||||
end
|
||||
|
||||
let(:valid_attributes_for_moderator_create) do
|
||||
%i[commentable_id commentable_type parent_id]
|
||||
end
|
||||
|
||||
context "when user is not signed-in" do
|
||||
let(:user) { nil }
|
||||
|
||||
|
|
@ -24,7 +28,7 @@ RSpec.describe CommentPolicy, type: :policy do
|
|||
let!(:user) { create(:user) }
|
||||
|
||||
it { is_expected.to permit_actions(%i[create]) }
|
||||
it { is_expected.to forbid_actions(%i[edit update destroy delete_confirm hide unhide]) }
|
||||
it { is_expected.to forbid_actions(%i[edit update destroy delete_confirm hide unhide moderator_create]) }
|
||||
|
||||
it { is_expected.to permit_mass_assignment_of(valid_attributes_for_create).for_action(:create) }
|
||||
|
||||
|
|
@ -39,12 +43,36 @@ RSpec.describe CommentPolicy, type: :policy do
|
|||
|
||||
it { is_expected.to forbid_actions(%i[create edit update destroy delete_confirm hide unhide]) }
|
||||
end
|
||||
|
||||
context "when user is a tag moderator" do
|
||||
before do
|
||||
tag = create(:tag)
|
||||
user.add_role(:tag_moderator, tag)
|
||||
end
|
||||
|
||||
it { is_expected.to permit_actions(%i[create moderator_create]) }
|
||||
|
||||
it do
|
||||
expect(comment_policy).to permit_mass_assignment_of(valid_attributes_for_moderator_create).for_action(:moderator_create)
|
||||
end
|
||||
end
|
||||
|
||||
context "when user is an admin" do
|
||||
before { user.add_role :admin }
|
||||
|
||||
it { is_expected.to permit_actions(%i[create moderator_create]) }
|
||||
|
||||
it do
|
||||
expect(comment_policy).to permit_mass_assignment_of(valid_attributes_for_moderator_create).for_action(:moderator_create)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when user is the author" do
|
||||
let(:user) { comment.user }
|
||||
|
||||
it { is_expected.to permit_actions(%i[edit update new create delete_confirm destroy]) }
|
||||
it { is_expected.to forbid_actions(%i[moderator_create]) }
|
||||
|
||||
it { is_expected.to permit_mass_assignment_of(valid_attributes_for_create).for_action(:create) }
|
||||
it { is_expected.to permit_mass_assignment_of(valid_attributes_for_update).for_action(:update) }
|
||||
|
|
@ -53,7 +81,7 @@ RSpec.describe CommentPolicy, type: :policy do
|
|||
before { user.add_role(:banned) }
|
||||
|
||||
it { is_expected.to permit_actions(%i[edit update destroy delete_confirm]) }
|
||||
it { is_expected.to forbid_actions(%i[create hide unhide]) }
|
||||
it { is_expected.to forbid_actions(%i[create hide unhide moderator_create]) }
|
||||
|
||||
it do
|
||||
expect(comment_policy).to permit_mass_assignment_of(valid_attributes_for_update).for_action(:update)
|
||||
|
|
@ -64,12 +92,26 @@ RSpec.describe CommentPolicy, type: :policy do
|
|||
before { user.add_role(:comment_banned) }
|
||||
|
||||
it { is_expected.to permit_actions(%i[edit update destroy delete_confirm]) }
|
||||
it { is_expected.to forbid_actions(%i[create hide unhide]) }
|
||||
it { is_expected.to forbid_actions(%i[create hide unhide moderator_create]) }
|
||||
|
||||
it do
|
||||
expect(comment_policy).to permit_mass_assignment_of(valid_attributes_for_update).for_action(:update)
|
||||
end
|
||||
end
|
||||
|
||||
context "when user is a tag moderator" do
|
||||
before do
|
||||
tag = create(:tag)
|
||||
user.add_role(:tag_moderator, tag)
|
||||
end
|
||||
|
||||
it { is_expected.to permit_actions(%i[edit update destroy delete_confirm moderator_create create]) }
|
||||
|
||||
it do
|
||||
expect(comment_policy).to permit_mass_assignment_of(valid_attributes_for_update).for_action(:update)
|
||||
expect(comment_policy).to permit_mass_assignment_of(valid_attributes_for_moderator_create).for_action(:moderator_create)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when user is commentable author" do
|
||||
|
|
@ -80,5 +122,6 @@ RSpec.describe CommentPolicy, type: :policy do
|
|||
|
||||
it { is_expected.to permit_actions(%i[hide unhide create]) }
|
||||
it { is_expected.to forbid_actions(%i[edit update destroy delete_confirm]) }
|
||||
it { is_expected.to forbid_actions(%i[edit update destroy delete_confirm moderator_create]) }
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -5,6 +5,96 @@ RSpec.describe "ResponseTemplate", type: :request do
|
|||
let(:moderator) { create(:user, :tag_moderator) }
|
||||
let(:admin) { create(:user, :admin) }
|
||||
|
||||
describe "GET /response_templates #index" do
|
||||
it "has status unauthorized if no user is logged in" do
|
||||
get response_templates_path, headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(response.status_message).to eq "Unauthorized"
|
||||
end
|
||||
|
||||
context "when signed in as a regular user" do
|
||||
before { sign_in user }
|
||||
|
||||
it "responds with JSON" do
|
||||
create(:response_template, user: user, type_of: "personal_comment")
|
||||
get response_templates_path, headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(response.content_type).to eq "application/json"
|
||||
end
|
||||
|
||||
it "raises RoutingError if the format is not JSON" do
|
||||
expect { get response_templates_path }.to raise_error ActionController::RoutingError
|
||||
end
|
||||
|
||||
it "returns an array of all the user's response templates" do
|
||||
total_response_templates = 2
|
||||
create_list(:response_template, total_response_templates, user: user, type_of: "personal_comment")
|
||||
get response_templates_path, params: { type_of: "personal_comment" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(response.parsed_body.class).to eq Array
|
||||
expect(response.parsed_body.length).to eq total_response_templates
|
||||
end
|
||||
|
||||
it "returns only the users' response templates" do
|
||||
create(:response_template, user: nil, type_of: "mod_comment")
|
||||
create_list(:response_template, 2, user: user, type_of: "personal_comment")
|
||||
get response_templates_path, params: { type_of: "personal_comment" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
user_ids = JSON.parse(response.body).map { |hash| hash["user_id"] }
|
||||
expect(user_ids).to eq [user.id, user.id]
|
||||
end
|
||||
|
||||
it "raises an error if trying to view moderator response templates" do
|
||||
create(:response_template, user: nil, type_of: "mod_comment")
|
||||
expect do
|
||||
get response_templates_path, params: { type_of: "mod_comment" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
end.to raise_error Pundit::NotAuthorizedError
|
||||
end
|
||||
|
||||
it "raises an error if trying to view admin response templates" do
|
||||
create(:response_template, user: nil, type_of: "email_reply", content_type: "html")
|
||||
expect do
|
||||
get response_templates_path, params: { type_of: "email_reply" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
end.to raise_error Pundit::NotAuthorizedError
|
||||
end
|
||||
end
|
||||
|
||||
context "when signed in as a mod user" do
|
||||
before { sign_in moderator }
|
||||
|
||||
it "responds with JSON" do
|
||||
create(:response_template, user: moderator, type_of: "personal_comment")
|
||||
get response_templates_path, params: { type_of: "mod_comment" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(response.content_type).to eq "application/json"
|
||||
end
|
||||
|
||||
it "returns the correct amount of moderator response templates" do
|
||||
create_list(:response_template, 2, user: nil, type_of: "mod_comment")
|
||||
create_list(:response_template, 2, user: moderator, type_of: "personal_comment")
|
||||
get response_templates_path, params: { type_of: "mod_comment" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(JSON.parse(response.body).length).to eq 2
|
||||
end
|
||||
|
||||
it "raises unauthorized error if trying to view admin response templates" do
|
||||
create_list(:response_template, 2, user: nil, type_of: "email_reply", content_type: "html")
|
||||
expect do
|
||||
get response_templates_path, params: { type_of: "email_reply" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
end.to raise_error Pundit::NotAuthorizedError
|
||||
end
|
||||
end
|
||||
|
||||
context "when signed in as an admin" do
|
||||
before { sign_in admin }
|
||||
|
||||
it "allows access by responding with status OK" do
|
||||
get response_templates_path, params: { type_of: "email_reply" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(response.status_message).to eq "OK"
|
||||
end
|
||||
|
||||
it "allows access and returns an array of admin level response templates" do
|
||||
create_list(:response_template, 2, user: nil, type_of: "email_reply", content_type: "html")
|
||||
get response_templates_path, params: { type_of: "email_reply" }, headers: { HTTP_ACCEPT: "application/json" }
|
||||
expect(JSON.parse(response.body).length).to eq 2
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /response_templates #create" do
|
||||
before { sign_in user }
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ RSpec.describe "User uses response templates settings", type: :system do
|
|||
|
||||
it "shows the proper message when deleting a reponse template", js: true do
|
||||
visit "/settings/response-templates"
|
||||
# page.driver.browser.switch_to.alert.accept
|
||||
accept_confirm { click_button "DELETE" }
|
||||
expect(page).to have_text "was deleted."
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue