Implement comment preview feature (#223)

* Create #preview endpoint for CommentController

* Create comment preview button WIP

* Implement preview feature for reply comment W

* Style preview button CSS WIP

* Style preview button CSS WIP

* Tweak markdown toggle logic and add styling

* Update comment feature spec

* Add error handling for comment preview & fix lint

* Extract handleCommentPreview into it's own file

* Adjust functionality for comment preview [WIP]

* Modify comment preview functionality and CSS

* Adjust code style based on linter
This commit is contained in:
Mac Siri 2018-04-24 14:30:52 -04:00 committed by Ben Halpern
parent 21b24f12fe
commit 4bc9020956
12 changed files with 226 additions and 61 deletions

View file

@ -37,6 +37,7 @@ function callInitalizers(){
initializeAnalytics();
initializeCommentDropdown();
initializeSettings();
initializeCommentPreview();
initializeAdditionalContentBoxes();
if (!initializeLiveArticle.called){
initializeLiveArticle();

View file

@ -0,0 +1,50 @@
function initializeCommentPreview() {
if (document.getElementById('preview-button')) {
document.getElementById('preview-button').onclick = handleCommentPreview;
}
}
function handleCommentPreview(e) {
e.preventDefault();
var form = e.target.parentElement.parentElement;
var textArea = form.querySelector('textarea');
if (textArea.value !== '') {
var previewDiv = form.querySelector('.comment-preview-div');
var previewButton = form.querySelector('.comment-action-preview');
if (previewButton.innerHTML.indexOf('PREVIEW') > -1) {
textArea.classList.toggle('preview-loading');
getAndShowPreview(previewDiv, textArea);
previewButton.innerHTML = 'MARKDOWN';
} else {
previewDiv.classList.toggle('preview-toggle');
textArea.classList.toggle('preview-toggle');
previewButton.innerHTML = 'PREVIEW';
}
}
}
function getAndShowPreview(previewDiv, textArea) {
var commentMarkdown = textArea.value;
var payload = JSON.stringify({
comment: {
body_markdown: commentMarkdown,
},
});
getCsrfToken()
.then(sendFetch('comment-preview', payload))
.then(function(response) {
return response.json();
})
.then(successCb)
.catch(function(err) {
console.log('error!');
console.log(err);
});
function successCb(body) {
previewDiv.classList.toggle('preview-toggle');
textArea.classList.toggle('preview-loading');
textArea.classList.toggle('preview-toggle');
previewDiv.innerHTML = body.processed_html;
}
}

View file

@ -228,6 +228,9 @@ function handleCommentSubmit(event) {
mainCommentsForm.classList.remove("submitting");
textArea.value = "";
textArea.classList = "";
var preview = document.getElementById("preview-div");
preview.classList.add("preview-toggle");
preview.innerHTML = "";
var container = document.getElementById("comment-trees-container");
container.insertBefore(newNode, container.firstChild);
}

View file

@ -9,6 +9,10 @@ function buildCommentFormHTML(commentableId, commentableType, parentId) {
</div>'
}
var randomIdNumber = Math.floor(Math.random() * 1991)
var previewDiv = '<div class="preview-toggle comment-preview-div body"></div>'
var previewButton = '<button id="preview-button" class="comment-action-button comment-action-preview" onclick="handleCommentPreview(event)">PREVIEW</button>'
return '<form class="new_comment" onsubmit="handleCommentSubmit.bind(this)(event)" id="new-comment-' + parentId + '" action="/comments" accept-charset="UTF-8" method="post" data-comment-id="'+parentId+'">\
<input name="utf8" type="hidden" value="&#x2713;" />\
<input type="hidden" name="authenticity_token" value="' + authToken + '">\
@ -16,6 +20,7 @@ function buildCommentFormHTML(commentableId, commentableType, parentId) {
<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" />\
<textarea id="textarea-for-' + parentId + '" class="embiggened" name="comment[body_markdown]" id="comment_body_markdown" required></textarea>\
'+previewDiv+'\
'+codeOfConductHTML+'\
<a href="/p/editor_guide" class="markdown-guide" target="_blank" title="Markdown Guide">\
<img class="icon-image" src="<%= asset_path('info.svg') %>" />\
@ -29,8 +34,11 @@ function buildCommentFormHTML(commentableId, commentableType, parentId) {
<input type="submit" id="image-upload-submit-' + randomIdNumber + '" value="Upload" style="display:none">\
<input class="uploaded-image" id="uploaded-image-' + randomIdNumber + '" />\
</div>\
<a href="#" class="cancel" onclick="cancel(event,this)">CANCEL</a>\
<input type="submit" name="commit" value="SUBMIT" />\
<div class="actions reply-actions">\
<a href="#" class="cancel" onclick="cancel(event,this)">CANCEL</a>\
'+previewButton+'\
<input type="submit" class="comment-action-button" name="commit" value="SUBMIT" />\
</div>\
</form>';
}
@ -38,4 +46,4 @@ function cancel(event, el) {
event.preventDefault();
replaceActionButts(el.parentNode.parentNode.parentNode.parentNode);
initializeCommentsPage();
}
}

View file

@ -61,6 +61,18 @@ function sendFetch(switchStatement, body) {
credentials: 'same-origin',
});
};
case 'comment-preview':
return function(csrfToken) {
return window.fetch('/comments/preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: body,
credentials: 'same-origin',
});
};
default:
console.log('A wrong switchStatement was used.');
break;

View file

@ -125,7 +125,7 @@ a.header-link{
position: absolute;
left:calc(0.2vw + 43px);
bottom:0px;
width:calc(100% - 145px);
width: initial;
text-align:left;
.uploaded-image {
vertical-align:11px;
@ -248,9 +248,57 @@ a.header-link{
&.embiggened-max{
height:calc(30vw + 18px);
}
&.preview-loading{
background: white url(image_path('loading-ellipsis.svg')) no-repeat center center;
background-size: 50px;
}
}
.preview-toggle {
display: none;
}
.comment-preview-div {
padding: 10px 20px 2px;
min-height:calc(13vw + 6px);
text-align: left;
font-size: 19px;
font-family: $helvetica;
background: lighten($yellow, 19%);
box-shadow: $shadow;
width: calc(100% - 80px);
margin: 19px auto 8px;
border: 2px solid $yellow;
p.loading-message{
opacity:0.6;
}
pre {
overflow-x: auto;
}
}
.actions{
text-align:right;
.comment-action-button {
font-family: $helvetica-condensed;
font-stretch:condensed;
color:white;
border:0px;
font-size:13px;
font-weight:500;
margin-top:3px;
padding:5px 12px;
border-radius:3px;
cursor:pointer;
appearance: none;
}
.comment-action-preview {
background: $dark-gray;
z-index: 10;
}
}
.reply-actions {
margin-bottom: 0;
padding-bottom: 0;
}
.code-of-conduct {
display: block;
@ -272,17 +320,6 @@ a.header-link{
input[type="submit"]{
margin-right:19px;
background: $bold-blue;
font-family: $helvetica-condensed;
font-stretch:condensed;
color:white;
border:0px;
font-size:13px;
font-weight:500;
margin-top:3px;
padding:5px 12px;
border-radius:3px;
cursor:pointer;
appearance: none;
}
&.submitting{
input[type="submit"]{
@ -677,7 +714,7 @@ a.header-link{
height:20px;
}
.editor-image-upload{
width:calc(100% - 150px);
width: initial;
text-align:left;
.uploaded-image {
width: calc( 93% - 54px);
@ -747,6 +784,7 @@ a.header-link{
input[type="submit"]{
padding:5px 8px;
margin-bottom:5px;
margin-right:7px;
}
.cancel{

View file

@ -1,7 +1,8 @@
class CommentsController < ApplicationController
before_action :set_comment, only: [ :update, :destroy]
before_action :set_comment, only: %i[update destroy]
before_action :set_cache_control_headers, only: [:index]
before_action :raise_banned, only: [:create,:update]
before_action :raise_banned, only: %i[create update]
before_action :authenticate_user!, only: %i[preview create]
# GET /comments
# GET /comments.json
@ -16,7 +17,7 @@ class CommentsController < ApplicationController
if @podcast
@user = @podcast
@commentable = @user.podcast_episodes.find_by_slug(params[:slug]) or not_found
(@commentable = @user.podcast_episodes.find_by_slug(params[:slug])) || not_found
else
@user = User.find_by_username(params[:username]) ||
Organization.find_by_slug(params[:username]) ||
@ -51,10 +52,6 @@ class CommentsController < ApplicationController
# POST /comments
# POST /comments.json
def create
unless current_user
redirect_to "/"
return
end
raise if RateLimitChecker.new(current_user).limit_by_situation("comment_creation")
@comment = Comment.new(comment_params)
@comment.user_id = current_user.id
@ -101,7 +98,7 @@ class CommentsController < ApplicationController
# PATCH/PUT /comments/1.json
def update
raise unless @comment.user_id == current_user.id
if @comment.update(comment_update_params.merge({ edited_at: DateTime.now }))
if @comment.update(comment_update_params.merge(edited_at: DateTime.now))
Mention.create_all(@comment)
redirect_to "#{@comment.commentable.path}/comments/#{@comment.id_code_generated}", notice: "Comment was successfully updated."
else
@ -125,29 +122,43 @@ class CommentsController < ApplicationController
end
def delete_confirm
unless current_user && Comment.where(id: params[:id_code].to_i(26), user_id: current_user.id ).first
unless current_user && Comment.where(id: params[:id_code].to_i(26), user_id: current_user.id).first
@comment = Comment.find(params[:id_code].to_i(26))
redirect_to @comment.path
return
end
@comment = Comment.where(id: params[:id_code].to_i(26), user_id: current_user.id ).first
@comment = Comment.where(id: params[:id_code].to_i(26), user_id: current_user.id).first
end
def preview
begin
fixed_body_markdown = MarkdownFixer.fix_for_preview(comment_params[:body_markdown])
parsed_markdown = MarkdownParser.new(fixed_body_markdown)
processed_html = parsed_markdown.finalize
rescue StandardError => e
processed_html = "<p>😔 There was a error in your markdown</p><hr><p>#{e}</p>"
end
respond_to do |format|
format.json { render json: { processed_html: processed_html }, status: 200 }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_comment
@comment = Comment.find(params[:id])
end
# Never trust parameters from the scary internet, only allow the white list through.
def comment_params
params.require(:comment).permit(:body_markdown,
:commentable_id,
:commentable_type,
:parent_id)
end
# Use callbacks to share common setup or constraints between actions.
def set_comment
@comment = Comment.find(params[:id])
end
def comment_update_params
params.require(:comment).permit(:body_markdown)
end
# Never trust parameters from the scary internet, only allow the white list through.
def comment_params
params.require(:comment).permit(:body_markdown,
:commentable_id,
:commentable_type,
:parent_id)
end
def comment_update_params
params.require(:comment).permit(:body_markdown)
end
end

View file

@ -1,14 +1,14 @@
class HexComparer
attr_accessor :hexes, :amount
def initialize(hexes, amount=1)
def initialize(hexes, amount = 1)
@hexes = hexes
@amount = amount
end
def order
hexes.sort
end
end
def smallest
order.first

View file

@ -26,7 +26,9 @@
id: "text-area",
readonly: !user_signed_in?,
autofocus: @comment.persisted?,
required: true %>
required: true
%>
<div class="preview-toggle comment-preview-div" id="preview-div"></div>
</div>
<div class="code-of-conduct" id="toggle-code-of-conduct-checkbox">
</div>
@ -45,7 +47,8 @@
</div>
<% end %>
<div class="actions" id="submit-wrapper">
<%= f.submit "SUBMIT", id: "submit-button", onclick:"validateField(event)" %>
<button id="preview-button" class="comment-action-button comment-action-preview">PREVIEW</button>
<%= f.submit "SUBMIT", id: "submit-button", onclick:"validateField(event)", class:"comment-action-button" %>
</div>
<% end %>

View file

@ -155,6 +155,7 @@ Rails.application.routes.draw do
get "/sponsors" => "pages#sponsors"
get "/search" => "stories#search"
post "articles/preview" => "articles#preview"
post "comments/preview" => "comments#preview"
get "/freestickers" => "giveaways#new"
get "/freestickers/edit" => "giveaways#edit"
get "/scholarship", to: redirect("/p/scholarships")

View file

@ -1,30 +1,44 @@
require "rails_helper"
feature "Creating Comment" do
RSpec.describe "Creating Comment", type: :feature, js: true do
let(:user) { create(:user) }
let(:raw_comment) { Faker::Lorem.paragraph }
let(:article) do
create(:article, user_id: user.id, show_comments: true)
end
background do
before do
login_via_session_as user
end
# scenario "User fills out comment box normally" do
# visit article.path.to_s
# fill_in "text-area", with: raw_comment
# click_button("SUBMIT")
# expect(page).to have_css(".comment-action-buttons")
# expect(page).to have_text(raw_comment)
# end
it "User fills out comment box normally" do
visit article.path.to_s
fill_in "text-area", with: raw_comment
find(".checkbox").click
click_button("SUBMIT")
expect(page).to have_text(raw_comment)
end
# scenario "User replies to a comment" do
# create(:comment, commentable_id: article.id, user_id: user.id)
# visit article.path.to_s
# find(".toggle-reply-form").click
# fill_in "text-area", with: raw_comment
# click_button("SUBMIT")
# expect(page).to have_text(raw_comment)
# end
it "User fill out commen box then click previews and submit" do
visit article.path.to_s
fill_in "text-area", with: raw_comment
find(".checkbox").click
click_button("PREVIEW")
expect(page).to have_text(raw_comment)
expect(page).to have_text("MARKDOWN")
click_button("MARKDOWN")
# expect(page).to have_text(raw_comment)
expect(page).to have_text("PREVIEW")
click_button("SUBMIT")
expect(page).to have_text(raw_comment)
end
it "User replies to a comment" do
create(:comment, commentable_id: article.id, user_id: user.id)
visit article.path.to_s
find(".toggle-reply-form").click
find(:xpath, "//div[@class='actions']/form[@class='new_comment']/textarea").set(raw_comment)
find(:xpath, "//div[contains(@class, 'reply-actions')]/input[@name='commit']").click
expect(page).to have_text(raw_comment)
end
end

View file

@ -62,4 +62,28 @@ RSpec.describe "Comments", type: :request do
end
end
end
describe "POST /comments/preview" do
it "returns 401 if user is not logged in" do
post "/comments/preview", params: { comment: { body_markdown: "hi"}}, headers: { HTTP_ACCEPT: "application/json" }
expect(response).to have_http_status(401)
end
context "when logged-in" do
before do
login_as user
post "/comments/preview",
params: { comment: { body_markdown: "hi" } },
headers: { HTTP_ACCEPT: "application/json" }
end
it "returns 200 on good request" do
expect(response).to have_http_status(200)
end
it "returns json" do
expect(response.content_type).to eq("application/json")
end
end
end
end