[deploy] Finalize and add response templates feature (#7068)

This commit is contained in:
Andy Zhao 2020-04-16 15:53:29 -04:00 committed by GitHub
parent cc6249beff
commit f5ec83653c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 750 additions and 9 deletions

View 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

View file

@ -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";
}
);

View file

@ -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 + ')">\

View file

@ -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;
}

View file

@ -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

View file

@ -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]

View file

@ -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

View file

@ -0,0 +1,7 @@
import { loadResponseTemplates } from '../responseTemplates/responseTemplates';
window.InstantClick.on('change', () => {
loadResponseTemplates();
});
loadResponseTemplates();

View 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();
}
}

View file

@ -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

View file

@ -438,6 +438,7 @@ class User < ApplicationRecord
Notifications
Publishing\ from\ RSS
Organization
Response\ Templates
Billing
Account
Misc

View file

@ -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

View file

@ -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

View file

@ -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">

View file

@ -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">

View file

@ -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]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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