diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 0000000000..e94f8140cc --- /dev/null +++ b/.browserslistrc @@ -0,0 +1 @@ +defaults diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 12d7a2fbc0..4438184f54 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,6 +41,12 @@ jobs: - uses: ./.github/actions/setup-test-environment - name: Install JavaScript dependencies with Yarn run: yarn check || yarn install --frozen-lockfile; + - name: Run webpacker setup + env: + RAILS_ENV: test + DB_PASSWORD: root + DB_PORT: ${{ job.services.mysql.ports[3306] }} + run: bundle exec rails g webpack:install && bundle exec rails g webpack:install:react && bundle exec rails g react:install - name: "Functional Tests" env: RAILS_ENV: test @@ -59,6 +65,12 @@ jobs: - uses: ./.github/actions/setup-test-environment - name: Install JavaScript dependencies with Yarn run: yarn check || yarn install --frozen-lockfile; + - name: Run webpacker setup + env: + RAILS_ENV: test + DB_PASSWORD: root + DB_PORT: ${{ job.services.mysql.ports[3306] }} + run: bundle exec rails g webpack:install && bundle exec rails g webpack:install:react && bundle exec rails g react:install - name: "Integration Tests" env: RAILS_ENV: test @@ -90,6 +102,12 @@ jobs: - uses: ./.github/actions/setup-test-environment - name: Install JavaScript dependencies with Yarn run: yarn check || yarn install --frozen-lockfile; + - name: Run webpacker setup + env: + RAILS_ENV: test + DB_PASSWORD: root + DB_PORT: ${{ job.services.mysql.ports[3306] }} + run: bundle exec rails g webpack:install && bundle exec rails g webpack:install:react && bundle exec rails g react:install - name: "System Tests" env: RAILS_ENV: test diff --git a/.gitignore b/.gitignore index b0abad8970..d655ed793b 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,10 @@ vendor/bundle spec/TEST-Teaspoon-Result.xml yarn.lock yarn-error.log + +/public/packs +/public/packs-test +/node_modules +/yarn-error.log +yarn-debug.log* +.yarn-integrity diff --git a/.gitpod.yml b/.gitpod.yml index 10af5ea781..573962b93b 100644 --- a/.gitpod.yml +++ b/.gitpod.yml @@ -18,6 +18,8 @@ tasks: yarn install + rails g webpack:install && rails g webpack:install:react && rails g react:install + command: > passenger start diff --git a/Dockerfile b/Dockerfile index 560bad364a..2ae8dc4b1c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ RUN echo \ 'deb http://ftp.ca.debian.org/debian/ stretch main\n \ deb http://ftp.ca.debian.org/debian/ stretch-updates main\n \ deb http://security.debian.org stretch/updates main\n \ - deb http://deb.nodesource.com/node_8.x stretch main\n' \ + deb http://deb.nodesource.com/node_12.x stretch main\n' \ > /etc/apt/sources.list # Install dependencies diff --git a/Gemfile b/Gemfile index 36084b71ef..f8f8e627e3 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,7 @@ gem 'rails-i18n', '~> 5.1.3' gem 'rails_autolink' gem 'rb-readline' gem 'rdiscount', '~> 2.2' +gem 'react-rails' gem "recaptcha", require: "recaptcha/rails" gem 'responders', '~> 3.0' gem 'rubocop', '~> 1.11.0', require: false @@ -58,6 +59,7 @@ gem 'skylight' # performance tracking via skylight.io gem 'turbolinks', '~> 5' gem 'tzinfo-data', platforms: %i(mingw mswin x64_mingw jruby) gem 'unicode-emoji' +gem 'webpacker' gem 'whenever', require: false gem 'will_paginate', '>= 3.0.6' gem 'will_paginate-bootstrap4' diff --git a/Gemfile.lock b/Gemfile.lock index 09368a109b..188d71ac8c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -62,6 +62,10 @@ GEM scrypt (>= 1.2, < 4.0) authlogic-oid (1.0.4) authlogic + babel-source (5.8.35) + babel-transpiler (0.7.0) + babel-source (>= 4.0, < 6) + execjs (~> 2.0) bindex (0.6.0) buftok (0.2.0) builder (3.2.4) @@ -370,6 +374,8 @@ GEM ruby-openid (>= 2.1.8) rack-protection (2.0.8.1) rack + rack-proxy (0.6.5) + rack rack-test (1.1.0) rack (>= 1.0, < 3) rails (5.2.3) @@ -413,6 +419,12 @@ GEM ffi (~> 1.0) rb-readline (0.5.5) rdiscount (2.2.0.2) + react-rails (2.6.1) + babel-transpiler (>= 0.7.0) + connection_pool + execjs + railties (>= 3.2) + tilt recaptcha (4.14.0) json redis (4.1.4) @@ -480,6 +492,7 @@ GEM selenium-webdriver (3.142.7) childprocess (>= 0.5, < 4.0) rubyzip (>= 1.2.2) + semantic_range (2.3.1) sentry-raven (3.1.1) faraday (>= 1.0) sidekiq (5.2.9) @@ -556,6 +569,11 @@ GEM addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + webpacker (5.2.1) + activesupport (>= 5.2) + rack-proxy (>= 0.6.1) + railties (>= 5.2) + semantic_range (>= 2.3.0) websocket-driver (0.7.1) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) @@ -643,6 +661,7 @@ DEPENDENCIES rake (~> 13.0.3) rb-readline rdiscount (~> 2.2) + react-rails recaptcha responders (~> 3.0) rest-client @@ -670,6 +689,7 @@ DEPENDENCIES unicode-emoji web-console (>= 3.3.0) webmock (~> 3.11) + webpacker whenever will_paginate (>= 3.0.6) will_paginate-bootstrap4 diff --git a/Makefile b/Makefile index 51e1cc4a69..c0045c1bf2 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ redeploy-container: docker-compose build --pull docker-compose run --rm web yarn install docker-compose run --rm web bash -c "bundle exec rake db:migrate && bundle exec rake assets:precompile && bundle exec rake tmp:cache:clear" + docker-compose run --rm web bash -c "bundle exec rails webpacker:install && bundle exec rails webpacker:install:react && bundle exec rails g react:install" docker-compose down --remove-orphans docker-compose up -d docker-compose exec -T web bash -c "echo 172.17.0.1 smtp >> /etc/hosts" @@ -24,6 +25,7 @@ automated-redeploy: pull-from-stable redeploy-container deploy-container: docker-compose run --rm web yarn install docker-compose run --rm web bash -c "sleep 5 && bundle exec rake db:migrate && bundle exec rake assets:precompile" + docker-compose run --rm web bash -c "sleep 5 && bundle exec rails webpacker:install && bundle exec rails webpacker:install:react && bundle exec rails g react:install" docker-compose up -d docker-compose exec -T web bash -c "echo 172.17.0.1 smtp >> /etc/hosts" docker-compose exec -T mailman bash -c "echo 172.17.0.1 smtp >> /etc/hosts" diff --git a/app/controllers/comment_controller.rb b/app/controllers/comment_controller.rb index e1dcfbba86..1099b2b28c 100644 --- a/app/controllers/comment_controller.rb +++ b/app/controllers/comment_controller.rb @@ -158,4 +158,67 @@ def like_comment end end end + + def react_create + @node = Node.find params[:id] + @body = params[:body] + @user = current_user + + begin + @comment = create_comment(@node, @user, @body) + + if params[:reply_to].present? + @comment.reply_to = params[:reply_to].to_i + @comment.save + end + + new_comment = helpers.get_react_comments([@comment]) + render json: { comment: new_comment } + rescue CommentError + flash.now[:error] = 'The comment could not be saved.' + render plain: 'failure', status: :bad_request + end + end + + def react_delete + @comment = Comment.find params[:id] + + comments_node_and_path + + if current_user.uid == @node.uid || + @comment.uid == current_user.uid || + logged_in_as(%w(admin moderator)) + + if @comment.destroy + render json: { success: true } + return + else + flash[:error] = 'The comment could not be deleted.' + render plain: 'failure' + end + else + prompt_login 'Only the comment or post author can delete this comment' + end + end + + def react_update + @comment = Comment.find params[:id] + + comments_node_and_path + + if @comment.uid == current_user.uid + # should abstract ".comment" to ".body" for future migration to native db + @comment.comment = params[:body] + if @comment.save + new_comment = helpers.get_react_comments([@comment]) + render json: { comment: new_comment } + else + flash[:error] = 'The comment could not be updated.' + redirect_to @path + end + else + flash[:error] = 'Only the author of the comment can edit it.' + redirect_to @path + end + end end diff --git a/app/controllers/notes_controller.rb b/app/controllers/notes_controller.rb index 5238be75d1..71edcaf779 100644 --- a/app/controllers/notes_controller.rb +++ b/app/controllers/notes_controller.rb @@ -61,7 +61,45 @@ def show @tags = @node.tags @tagnames = @tags.collect(&:name) @preview = false + @react = params[:react] + + if params[:react] + # query everything we need in the comments state object + comments_record = @node + .comments_viewable_by(current_user) + .includes(%i(replied_comments node)) + .order('timestamp ASC') + + comments = helpers.get_react_comments(comments_record) + + current_user_json = nil + + if current_user + current_user_json = { + canModerate: current_user.can_moderate?, + id: current_user[:id], + role: current_user[:role], + status: current_user[:status] + } + end + @react_props = { + currentUser: current_user_json, + comments: comments, + elementText: { + commentFormPlaceholder: I18n.t('notes._comments.post_placeholder'), + commentsHeaderText: helpers.translation('notes._comments.comments'), + commentPreviewText: helpers.translation('comments._form.preview'), + commentPublishText: helpers.translation('comments._form.publish'), + userCommentedText: helpers.translation('notes._comment.commented') + }, + node: { + nodeId: @node.id, + nodeAuthorId: @node.uid + }, + user: current_user + } + end else page_not_found end diff --git a/app/helpers/comment_helper.rb b/app/helpers/comment_helper.rb index 35fe3c169c..389b18a8a7 100644 --- a/app/helpers/comment_helper.rb +++ b/app/helpers/comment_helper.rb @@ -11,4 +11,27 @@ def create_comment(node, user, body) raise CommentError end end + + # takes an activerecord query, returns a plain array + # used in notes_controller.rb and comment_controller.rb + def get_react_comments(comments_record) + comments = [] + comments_record.each do |comment| + comment_json = {} + comment_json[:authorId] = comment.uid + comment_json[:authorPicFilename] = comment.author.photo_file_name + comment_json[:authorPicUrl] = comment.author.photo_path(:thumb) + comment_json[:authorUsername] = comment.author.username + comment_json[:commentId] = comment.cid + comment_json[:commentName] = comment.name + comment_json[:createdAt] = comment.created_at + comment_json[:htmlCommentText] = raw insert_extras(filtered_comment_body(comment.render_body)) + comment_json[:rawCommentText] = comment.comment + comment_json[:replyTo] = comment.reply_to + time_created_string = distance_of_time_in_words(comment.created_at, Time.current, include_seconds: false, scope: 'datetime.time_ago_in_words') + comment_json[:timeCreatedString] = time_created_string + comments << comment_json + end + comments + end end diff --git a/app/javascript/components/.keep b/app/javascript/components/.keep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/javascript/components/App.js b/app/javascript/components/App.js new file mode 100644 index 0000000000..600fa7fa51 --- /dev/null +++ b/app/javascript/components/App.js @@ -0,0 +1,61 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { UserContext } from "./user-context"; +import { StaticPropsContext } from "./static-props-context"; +import { getEditTextAreaValues, getInitialCommentFormToggleState } from "./helpers"; + +import CommentsContainer from "./CommentsContainer"; + +const App = ({ + // ES6 destructure the props + // so we can simply refer to initialComments instead of this.props.initialComments + currentUser, + elementText, + node, + node: { + nodeId + }, + initialComments +}) => { + // process the initialComments object and create initial state that is passed down to CommentsContainer.js to make React Hooks + + // this is an object containing boolean values like: { "reply-33": false, "edit-1": true } + // this is used as the initial state showing whether or not an edit or reply comment form is shown or hidden + // false means the comment form is closed, true means open + const initialCommentFormToggleState = getInitialCommentFormToggleState(initialComments); + + // this is used as initial state for the content of + {/* placeholder: image upload elements */} + + {/* placeholder: comment preview section */} +
+ +   + + {staticProps.elementText.commentPreviewText} + +
+ + + )} + + ); +} + +CommentForm.propTypes = { + commentId: PropTypes.number, + commentFormType: PropTypes.string.isRequired, + formId: PropTypes.string.isRequired, + handleFormSubmit: PropTypes.func.isRequired, + handleTextAreaChange: PropTypes.func.isRequired, + textAreaValue: PropTypes.string +}; + +export default CommentForm; diff --git a/app/javascript/components/CommentHeader.js b/app/javascript/components/CommentHeader.js new file mode 100644 index 0000000000..57ffd7795b --- /dev/null +++ b/app/javascript/components/CommentHeader.js @@ -0,0 +1,70 @@ +import React from "react"; +import PropTypes from "prop-types"; + +import { StaticPropsContext } from "./static-props-context"; + +const CommentHeader = ({ + authorPicFilename, + authorUsername, + authorPicUrl, + commentId, + commentName, + timeCreatedString +}) => { + // top-left comment author information + let authorSection = []; + // author's profile pic, or anonymous blank circle + const authorProfilePic = (authorPicFilename) ? + Comment Author Profile Picture : +
; + + const authorName = (authorUsername) ? + + {" " + authorUsername} + : + commentName; + + return ( + + {staticProps => ( +
+ {authorProfilePic} + {authorName} + {" " + staticProps.elementText.userCommentedText} + + {" " + timeCreatedString} + +
+ )} +
+ ); +} + +CommentHeader.propTypes = { + authorPicFilename: PropTypes.string, + authorUsername: PropTypes.string, + authorPicUrl: PropTypes.string, + commentId: PropTypes.number.isRequired, + commentName: PropTypes.string, + timeCreatedString: PropTypes.string.isRequired +} + +export default CommentHeader; diff --git a/app/javascript/components/CommentReplies.js b/app/javascript/components/CommentReplies.js new file mode 100644 index 0000000000..5a62bdde5b --- /dev/null +++ b/app/javascript/components/CommentReplies.js @@ -0,0 +1,44 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const CommentReplies = ({ + children, + commentId, + isReplyFormVisible, + handleReplyFormToggle, + replyCommentForm +}) => { + const replyToggleLink =

handleReplyFormToggle("reply-" + commentId)} + style={{ + color: "#006dcc", + cursor: "pointer", + userSelect: "none" + }} + > + Reply to this comment... +

; + + const replyForm = isReplyFormVisible ? + replyCommentForm : + ""; + + return ( + <> + {children} + {replyToggleLink} + {replyForm} + + ); +} + +CommentReplies.propTypes = { + children: PropTypes.array, + commentId: PropTypes.number.isRequired, + isReplyFormVisible: PropTypes.bool.isRequired, + handleReplyFormToggle: PropTypes.func, + replyCommentForm: PropTypes.element +} + +export default CommentReplies; diff --git a/app/javascript/components/CommentToolbar.js b/app/javascript/components/CommentToolbar.js new file mode 100644 index 0000000000..daadbf8b2d --- /dev/null +++ b/app/javascript/components/CommentToolbar.js @@ -0,0 +1,66 @@ +/* eslint-disable complexity */ +import React from "react"; +import PropTypes from "prop-types"; + +import CommentToolbarButton from "./CommentToolbarButton"; + +const CommentToolbar = ({ + authorId, + currentUser, + deleteButton, + nodeAuthorId, + toggleEditButton +}) => { + // 1. edit button + const isUserAuthor = currentUser && authorId === currentUser.id; + + // 2. mark spam button (for moderators) OR flag as spam (for all users) + const markSpamIcon = ; + const flagSpamIcon = ; + const isUserModerator = currentUser && currentUser.canModerate; + const markSpamButton = isUserModerator ? + : + ; + // original Rails view's conditionals include logged_in_as['admin', 'moderator'] + // don't know if this is completely equivalent to user.canModerate + + // 3. delete comment button + const isUserNodeAuthor = currentUser && currentUser.id === authorId && authorId === nodeAuthorId; + const userCanDeleteComment = isUserAuthor || isUserModerator || isUserNodeAuthor; + + // 4. leave an emoji reaction button + const emojiIcon = ; + const emojiButton = currentUser ? + : + ""; + + return ( +
+ {/* placeholder: role icon for admins and moderators--but it's commented out in the original partial? */} + {/* placeholder: this comment was posted by email */} + {isUserAuthor && toggleEditButton} +   + {markSpamButton} +   + {userCanDeleteComment && deleteButton} +   + {emojiButton} +
+ ); +} + +CommentToolbar.propTypes = { + authorId: PropTypes.number.isRequired, + currentUser: PropTypes.object, + deleteButton: PropTypes.element.isRequired, + nodeAuthorId: PropTypes.number.isRequired, + toggleEditButton: PropTypes.element.isRequired +}; + +export default CommentToolbar; diff --git a/app/javascript/components/CommentToolbarButton.js b/app/javascript/components/CommentToolbarButton.js new file mode 100644 index 0000000000..ee8e4cae7b --- /dev/null +++ b/app/javascript/components/CommentToolbarButton.js @@ -0,0 +1,23 @@ +import React from "react"; +import PropTypes from "prop-types"; + +const CommentToolbarButton = ({ + icon, + onClick +}) => { + return ( + + {icon} + + ); +} + +CommentToolbarButton.propTypes = { + icon: PropTypes.element, + onClick: PropTypes.func +}; + +export default CommentToolbarButton; diff --git a/app/javascript/components/CommentsContainer.js b/app/javascript/components/CommentsContainer.js new file mode 100644 index 0000000000..b84ef6b4a3 --- /dev/null +++ b/app/javascript/components/CommentsContainer.js @@ -0,0 +1,168 @@ +import React, { useState } from "react"; +import PropTypes from "prop-types"; + +import { UserContext } from "./user-context"; +import { makeDeepCopy } from "./helpers"; + +import CommentForm from "./CommentForm"; +import CommentsHeader from "./CommentsHeader"; +import CommentsList from "./CommentsList" + +const CommentsContainer = ({ + initialCommentFormToggleState, + initialComments, + initialTextAreaValues, + nodeId +}) => { + // React Hook: Comments State + const [comments, setComments] = useState(initialComments); + + // React Hook: Visibility for Reply and Edit Comment Forms + const [commentFormsVisibility, setCommentFormsVisibility] = useState(initialCommentFormToggleState); + + // hide and reveal reply & edit comment forms + const handleFormVisibilityToggle = (commentFormId) => { + setCommentFormsVisibility(oldState => (Object.assign({}, oldState, { [commentFormId]: !oldState[commentFormId] }))); + } + + // React Hook: