From 27dafac7f04318312725fe18132c02e8372cdc7b Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Thu, 4 Feb 2021 14:30:20 -0500 Subject: [PATCH 1/8] Move comments out of project reducer --- src/redux/comments.js | 336 ++++++++++++++++++++++++ src/redux/preview.js | 296 --------------------- src/views/preview/preview.jsx | 2 + src/views/preview/project-view.jsx | 27 +- test/unit-legacy/redux/comments-test.js | 151 +++++++++++ test/unit-legacy/redux/preview-test.js | 120 --------- 6 files changed, 503 insertions(+), 429 deletions(-) create mode 100644 src/redux/comments.js create mode 100644 test/unit-legacy/redux/comments-test.js diff --git a/src/redux/comments.js b/src/redux/comments.js new file mode 100644 index 000000000..539cf02ad --- /dev/null +++ b/src/redux/comments.js @@ -0,0 +1,336 @@ +const keyMirror = require('keymirror'); +const eachLimit = require('async/eachLimit'); +const mergeWith = require('lodash.mergewith'); +const uniqBy = require('lodash.uniqby'); + +const api = require('../lib/api'); +const log = require('../lib/log'); + +const COMMENT_LIMIT = 20; + +module.exports.Status = keyMirror({ + FETCHED: null, + NOT_FETCHED: null, + FETCHING: null, + ERROR: null +}); + +module.exports.getInitialState = () => ({ + status: { + comments: module.exports.Status.NOT_FETCHED + }, + comments: [], + replies: {}, + moreCommentsToLoad: false +}); + +module.exports.commentsReducer = (state, action) => { + if (typeof state === 'undefined') { + state = module.exports.getInitialState(); + } + + switch (action.type) { + case 'RESET_TO_INTIAL_STATE': + return module.exports.getInitialState(); + case 'RESET_COMMENTS': + return Object.assign({}, state, { + comments: [], + replies: {} + }); + case 'SET_COMMENT_FETCH_STATUS': + return Object.assign({}, state, { + status: Object.assign({}, state.status, { + [action.infoType]: action.status + }) + }); + case 'SET_COMMENTS': + return Object.assign({}, state, { + comments: uniqBy(state.comments.concat(action.items), 'id') + }); + case 'UPDATE_COMMENT': + if (action.topLevelCommentId) { + return Object.assign({}, state, { + replies: Object.assign({}, state.replies, { + [action.topLevelCommentId]: state.replies[action.topLevelCommentId].map(comment => { + if (comment.id === action.commentId) { + return Object.assign({}, comment, action.comment); + } + return comment; + }) + }) + }); + } + + return Object.assign({}, state, { + comments: state.comments.map(comment => { + if (comment.id === action.commentId) { + return Object.assign({}, comment, action.comment); + } + return comment; + }) + }); + case 'ADD_NEW_COMMENT': + if (action.topLevelCommentId) { + return Object.assign({}, state, { + replies: Object.assign({}, state.replies, { + // Replies to comments go at the end of the thread + [action.topLevelCommentId]: state.replies[action.topLevelCommentId].concat(action.comment) + }) + }); + } + + // Reply to the top level project, put the reply at the beginning + return Object.assign({}, state, { + comments: [action.comment, ...state.comments], + replies: Object.assign({}, state.replies, {[action.comment.id]: []}) + }); + case 'UPDATE_ALL_REPLIES': + return Object.assign({}, state, { + replies: Object.assign({}, state.replies, { + [action.commentId]: state.replies[action.commentId].map(reply => + Object.assign({}, reply, action.comment) + ) + }) + }); + case 'SET_REPLIES': + return Object.assign({}, state, { + // Append new replies to the state.replies structure + replies: mergeWith({}, state.replies, action.replies, (replies, newReplies) => ( + uniqBy((replies || []).concat(newReplies || []), 'id') + )), + // Also set the `moreRepliesToLoad` property on the top-level comments + comments: state.comments.map(comment => { + if (action.replies[comment.id]) { + return Object.assign({}, comment, { + moreRepliesToLoad: action.replies[comment.id].length === COMMENT_LIMIT + }); + } + return comment; + }) + }); + case 'SET_MORE_COMMENTS_TO_LOAD': + return Object.assign({}, state, { + moreCommentsToLoad: action.moreCommentsToLoad + }); + default: + return state; + } +}; + +module.exports.setFetchStatus = (type, status) => ({ + type: 'SET_COMMENT_FETCH_STATUS', + infoType: type, + status: status +}); + +module.exports.setComments = items => ({ + type: 'SET_COMMENTS', + items: items +}); + +module.exports.setReplies = replies => ({ + type: 'SET_REPLIES', + replies: replies +}); + +module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({ + type: 'UPDATE_COMMENT', + commentId: commentId, + topLevelCommentId: topLevelCommentId, + comment: { + visibility: 'deleted' + } +}); + +module.exports.setRepliesDeleted = commentId => ({ + type: 'UPDATE_ALL_REPLIES', + commentId: commentId, + comment: { + visibility: 'deleted' + } +}); + +module.exports.setCommentReported = (commentId, topLevelCommentId) => ({ + type: 'UPDATE_COMMENT', + commentId: commentId, + topLevelCommentId: topLevelCommentId, + comment: { + visibility: 'reported' + } +}); + +module.exports.setCommentRestored = (commentId, topLevelCommentId) => ({ + type: 'UPDATE_COMMENT', + commentId: commentId, + topLevelCommentId: topLevelCommentId, + comment: { + visibility: 'visible' + } +}); + +module.exports.setRepliesRestored = commentId => ({ + type: 'UPDATE_ALL_REPLIES', + commentId: commentId, + comment: { + visibility: 'visible' + } +}); + +module.exports.addNewComment = (comment, topLevelCommentId) => ({ + type: 'ADD_NEW_COMMENT', + comment: comment, + topLevelCommentId: topLevelCommentId +}); + +module.exports.setMoreCommentsToLoad = moreCommentsToLoad => ({ + type: 'SET_MORE_COMMENTS_TO_LOAD', + moreCommentsToLoad: moreCommentsToLoad +}); + +module.exports.resetComments = () => ({ + type: 'RESET_COMMENTS' +}); + +module.exports.getTopLevelComments = (id, offset, ownerUsername, isAdmin, token) => (dispatch => { + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING)); + api({ + uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${id}/comments`, + authentication: token ? token : null, + params: {offset: offset || 0, limit: COMMENT_LIMIT} + }, (err, body, res) => { + if (err) { + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); + dispatch(module.exports.setError(err)); + return; + } + if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); + dispatch(module.exports.setError('No comment info')); + return; + } + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED)); + dispatch(module.exports.setComments(body)); + dispatch(module.exports.getReplies(id, body.map(comment => comment.id), 0, ownerUsername, isAdmin, token)); + + // If we loaded a full page of comments, assume there are more to load. + // This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require + // any more server query complexity, so seems worth it. In the case of a project with + // number of comments divisible by the COMMENT_LIMIT, the load more button will be + // clickable, but upon clicking it will go away. + dispatch(module.exports.setMoreCommentsToLoad(body.length === COMMENT_LIMIT)); + }); +}); + +module.exports.getCommentById = (projectId, commentId, ownerUsername, isAdmin, token) => (dispatch => { + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING)); + api({ + uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${commentId}`, + authentication: token ? token : null + }, (err, body, res) => { + if (err) { + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); + dispatch(module.exports.setError(err)); + return; + } + if (!body || res.statusCode >= 400) { // NotFound + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); + dispatch(module.exports.setError('No comment info')); + return; + } + + if (body.parent_id) { + // If the comment is a reply, load the parent + return dispatch(module.exports.getCommentById(projectId, body.parent_id, ownerUsername, isAdmin, token)); + } + + // If the comment is not a reply, show it as top level and load replies + dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED)); + dispatch(module.exports.setComments([body])); + dispatch(module.exports.getReplies(projectId, [body.id], 0, ownerUsername, isAdmin, token)); + }); +}); + +module.exports.getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => { + dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING)); + const fetchedReplies = {}; + eachLimit(commentIds, 10, (parentId, callback) => { + api({ + uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`, + authentication: token ? token : null, + params: {offset: offset || 0, limit: COMMENT_LIMIT} + }, (err, body, res) => { + if (err) { + return callback(`Error fetching comment replies: ${err}`); + } + if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound + return callback('No comment reply information'); + } + fetchedReplies[parentId] = body; + callback(null, body); + }); + }, err => { + if (err) { + dispatch(module.exports.setFetchStatus('replies', module.exports.Status.ERROR)); + dispatch(module.exports.setError(err)); + return; + } + dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHED)); + dispatch(module.exports.setReplies(fetchedReplies)); + }); +}); + +module.exports.deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { + /* TODO fetching/fetched/error states updates for comment deleting */ + api({ + uri: `/proxy/comments/project/${projectId}/comment/${commentId}`, + authentication: token, + withCredentials: true, + method: 'DELETE', + useCsrf: true + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + log.error(err || res.body); + return; + } + dispatch(module.exports.setCommentDeleted(commentId, topLevelCommentId)); + if (!topLevelCommentId) { + dispatch(module.exports.setRepliesDeleted(commentId)); + } + }); +}); + +module.exports.reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { + api({ + uri: `/proxy/project/${projectId}/comment/${commentId}/report`, + authentication: token, + withCredentials: true, + method: 'POST', + useCsrf: true + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + log.error(err || res.body); + return; + } + // TODO use the reportId in the response for unreporting functionality + dispatch(module.exports.setCommentReported(commentId, topLevelCommentId)); + }); +}); + +module.exports.restoreComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { + api({ + uri: `/proxy/admin/project/${projectId}/comment/${commentId}/undelete`, + authentication: token, + withCredentials: true, + method: 'PUT', + useCsrf: true + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + log.error(err || res.body); + return; + } + dispatch(module.exports.setCommentRestored(commentId, topLevelCommentId)); + if (!topLevelCommentId) { + dispatch(module.exports.setRepliesRestored(commentId)); + } + }); +}); diff --git a/src/redux/preview.js b/src/redux/preview.js index 7239eba47..e4c7c74be 100644 --- a/src/redux/preview.js +++ b/src/redux/preview.js @@ -1,14 +1,9 @@ const defaults = require('lodash.defaults'); const keyMirror = require('keymirror'); -const eachLimit = require('async/eachLimit'); -const mergeWith = require('lodash.mergewith'); -const uniqBy = require('lodash.uniqby'); const api = require('../lib/api'); const log = require('../lib/log'); -const COMMENT_LIMIT = 20; - module.exports.Status = keyMirror({ FETCHED: null, NOT_FETCHED: null, @@ -19,7 +14,6 @@ module.exports.Status = keyMirror({ module.exports.getInitialState = () => ({ status: { project: module.exports.Status.NOT_FETCHED, - comments: module.exports.Status.NOT_FETCHED, faved: module.exports.Status.NOT_FETCHED, loved: module.exports.Status.NOT_FETCHED, original: module.exports.Status.NOT_FETCHED, @@ -32,9 +26,6 @@ module.exports.getInitialState = () => ({ studioRequests: {} }, projectInfo: {}, - remixes: [], - comments: [], - replies: {}, faved: false, loved: false, original: {}, @@ -42,7 +33,6 @@ module.exports.getInitialState = () => ({ projectStudios: [], curatedStudios: [], currentStudioIds: [], - moreCommentsToLoad: false, projectNotAvailable: false, visibilityInfo: {} }); @@ -96,76 +86,6 @@ module.exports.previewReducer = (state, action) => { item !== action.studioId )) }); - case 'RESET_COMMENTS': - return Object.assign({}, state, { - comments: [], - replies: {} - }); - case 'SET_COMMENTS': - return Object.assign({}, state, { - comments: uniqBy(state.comments.concat(action.items), 'id') - }); - case 'UPDATE_COMMENT': - if (action.topLevelCommentId) { - return Object.assign({}, state, { - replies: Object.assign({}, state.replies, { - [action.topLevelCommentId]: state.replies[action.topLevelCommentId].map(comment => { - if (comment.id === action.commentId) { - return Object.assign({}, comment, action.comment); - } - return comment; - }) - }) - }); - } - - return Object.assign({}, state, { - comments: state.comments.map(comment => { - if (comment.id === action.commentId) { - return Object.assign({}, comment, action.comment); - } - return comment; - }) - }); - case 'ADD_NEW_COMMENT': - if (action.topLevelCommentId) { - return Object.assign({}, state, { - replies: Object.assign({}, state.replies, { - // Replies to comments go at the end of the thread - [action.topLevelCommentId]: state.replies[action.topLevelCommentId].concat(action.comment) - }) - }); - } - - // Reply to the top level project, put the reply at the beginning - return Object.assign({}, state, { - comments: [action.comment, ...state.comments], - replies: Object.assign({}, state.replies, {[action.comment.id]: []}) - }); - case 'UPDATE_ALL_REPLIES': - return Object.assign({}, state, { - replies: Object.assign({}, state.replies, { - [action.commentId]: state.replies[action.commentId].map(reply => - Object.assign({}, reply, action.comment) - ) - }) - }); - case 'SET_REPLIES': - return Object.assign({}, state, { - // Append new replies to the state.replies structure - replies: mergeWith({}, state.replies, action.replies, (replies, newReplies) => ( - uniqBy((replies || []).concat(newReplies || []), 'id') - )), - // Also set the `moreRepliesToLoad` property on the top-level comments - comments: state.comments.map(comment => { - if (action.replies[comment.id]) { - return Object.assign({}, comment, { - moreRepliesToLoad: action.replies[comment.id].length === COMMENT_LIMIT - }); - } - return comment; - }) - }); case 'SET_LOVED': return Object.assign({}, state, { loved: action.info @@ -182,10 +102,6 @@ module.exports.previewReducer = (state, action) => { state = JSON.parse(JSON.stringify(state)); state.status.studioRequests[action.studioId] = action.status; return state; - case 'SET_MORE_COMMENTS_TO_LOAD': - return Object.assign({}, state, { - moreCommentsToLoad: action.moreCommentsToLoad - }); case 'SET_VISIBILITY_INFO': return Object.assign({}, state, { visibilityInfo: action.visibilityInfo @@ -247,16 +163,6 @@ module.exports.setProjectStudios = items => ({ items: items }); -module.exports.setComments = items => ({ - type: 'SET_COMMENTS', - items: items -}); - -module.exports.setReplies = replies => ({ - type: 'SET_REPLIES', - replies: replies -}); - module.exports.setCuratedStudios = items => ({ type: 'SET_CURATED_STUDIOS', items: items @@ -284,64 +190,6 @@ module.exports.setStudioFetchStatus = (studioId, status) => ({ status: status }); -module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({ - type: 'UPDATE_COMMENT', - commentId: commentId, - topLevelCommentId: topLevelCommentId, - comment: { - visibility: 'deleted' - } -}); - -module.exports.setRepliesDeleted = commentId => ({ - type: 'UPDATE_ALL_REPLIES', - commentId: commentId, - comment: { - visibility: 'deleted' - } -}); - -module.exports.setCommentReported = (commentId, topLevelCommentId) => ({ - type: 'UPDATE_COMMENT', - commentId: commentId, - topLevelCommentId: topLevelCommentId, - comment: { - visibility: 'reported' - } -}); - -module.exports.setCommentRestored = (commentId, topLevelCommentId) => ({ - type: 'UPDATE_COMMENT', - commentId: commentId, - topLevelCommentId: topLevelCommentId, - comment: { - visibility: 'visible' - } -}); - -module.exports.setRepliesRestored = commentId => ({ - type: 'UPDATE_ALL_REPLIES', - commentId: commentId, - comment: { - visibility: 'visible' - } -}); - -module.exports.addNewComment = (comment, topLevelCommentId) => ({ - type: 'ADD_NEW_COMMENT', - comment: comment, - topLevelCommentId: topLevelCommentId -}); - -module.exports.setMoreCommentsToLoad = moreCommentsToLoad => ({ - type: 'SET_MORE_COMMENTS_TO_LOAD', - moreCommentsToLoad: moreCommentsToLoad -}); - -module.exports.resetComments = () => ({ - type: 'RESET_COMMENTS' -}); - module.exports.setVisibilityInfo = visibilityInfo => ({ type: 'SET_VISIBILITY_INFO', visibilityInfo: visibilityInfo @@ -462,94 +310,6 @@ module.exports.getFavedStatus = (id, username, token) => (dispatch => { }); }); -module.exports.getTopLevelComments = (id, offset, ownerUsername, isAdmin, token) => (dispatch => { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING)); - api({ - uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${id}/comments`, - authentication: token ? token : null, - params: {offset: offset || 0, limit: COMMENT_LIMIT} - }, (err, body, res) => { - if (err) { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError(err)); - return; - } - if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError('No comment info')); - return; - } - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED)); - dispatch(module.exports.setComments(body)); - dispatch(module.exports.getReplies(id, body.map(comment => comment.id), 0, ownerUsername, isAdmin, token)); - - // If we loaded a full page of comments, assume there are more to load. - // This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require - // any more server query complexity, so seems worth it. In the case of a project with - // number of comments divisible by the COMMENT_LIMIT, the load more button will be - // clickable, but upon clicking it will go away. - dispatch(module.exports.setMoreCommentsToLoad(body.length === COMMENT_LIMIT)); - }); -}); - -module.exports.getCommentById = (projectId, commentId, ownerUsername, isAdmin, token) => (dispatch => { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING)); - api({ - uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${commentId}`, - authentication: token ? token : null - }, (err, body, res) => { - if (err) { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError(err)); - return; - } - if (!body || res.statusCode >= 400) { // NotFound - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError('No comment info')); - return; - } - - if (body.parent_id) { - // If the comment is a reply, load the parent - return dispatch(module.exports.getCommentById(projectId, body.parent_id, ownerUsername, isAdmin, token)); - } - - // If the comment is not a reply, show it as top level and load replies - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED)); - dispatch(module.exports.setComments([body])); - dispatch(module.exports.getReplies(projectId, [body.id], 0, ownerUsername, isAdmin, token)); - }); -}); - -module.exports.getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => { - dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING)); - const fetchedReplies = {}; - eachLimit(commentIds, 10, (parentId, callback) => { - api({ - uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`, - authentication: token ? token : null, - params: {offset: offset || 0, limit: COMMENT_LIMIT} - }, (err, body, res) => { - if (err) { - return callback(`Error fetching comment replies: ${err}`); - } - if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound - return callback('No comment reply information'); - } - fetchedReplies[parentId] = body; - callback(null, body); - }); - }, err => { - if (err) { - dispatch(module.exports.setFetchStatus('replies', module.exports.Status.ERROR)); - dispatch(module.exports.setError(err)); - return; - } - dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHED)); - dispatch(module.exports.setReplies(fetchedReplies)); - }); -}); - module.exports.setFavedStatus = (faved, id, username, token) => (dispatch => { dispatch(module.exports.setFetchStatus('faved', module.exports.Status.FETCHING)); if (faved) { @@ -882,62 +642,6 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => { }); }); -module.exports.deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { - /* TODO fetching/fetched/error states updates for comment deleting */ - api({ - uri: `/proxy/comments/project/${projectId}/comment/${commentId}`, - authentication: token, - withCredentials: true, - method: 'DELETE', - useCsrf: true - }, (err, body, res) => { - if (err || res.statusCode !== 200) { - log.error(err || res.body); - return; - } - dispatch(module.exports.setCommentDeleted(commentId, topLevelCommentId)); - if (!topLevelCommentId) { - dispatch(module.exports.setRepliesDeleted(commentId)); - } - }); -}); - -module.exports.reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { - api({ - uri: `/proxy/project/${projectId}/comment/${commentId}/report`, - authentication: token, - withCredentials: true, - method: 'POST', - useCsrf: true - }, (err, body, res) => { - if (err || res.statusCode !== 200) { - log.error(err || res.body); - return; - } - // TODO use the reportId in the response for unreporting functionality - dispatch(module.exports.setCommentReported(commentId, topLevelCommentId)); - }); -}); - -module.exports.restoreComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { - api({ - uri: `/proxy/admin/project/${projectId}/comment/${commentId}/undelete`, - authentication: token, - withCredentials: true, - method: 'PUT', - useCsrf: true - }, (err, body, res) => { - if (err || res.statusCode !== 200) { - log.error(err || res.body); - return; - } - dispatch(module.exports.setCommentRestored(commentId, topLevelCommentId)); - if (!topLevelCommentId) { - dispatch(module.exports.setRepliesRestored(commentId)); - } - }); -}); - module.exports.shareProject = (projectId, token) => (dispatch => { dispatch(module.exports.setFetchStatus('project', module.exports.Status.FETCHING)); api({ diff --git a/src/views/preview/preview.jsx b/src/views/preview/preview.jsx index 3da330c3c..8864b5a3a 100644 --- a/src/views/preview/preview.jsx +++ b/src/views/preview/preview.jsx @@ -5,6 +5,7 @@ const Page = require('../../components/page/www/page.jsx'); const render = require('../../lib/render.jsx'); const previewActions = require('../../redux/preview.js'); +const commentsActions = require('../../redux/comments.js'); const isSupportedBrowser = require('../../lib/supported-browser').default; const UnsupportedBrowser = require('./unsupported-browser.jsx'); @@ -16,6 +17,7 @@ if (isSupportedBrowser()) { document.getElementById('app'), { preview: previewActions.previewReducer, + comments: commentsActions.commentsReducer, ...ProjectView.guiReducers }, { diff --git a/src/views/preview/project-view.jsx b/src/views/preview/project-view.jsx index f540c2f8c..f3ed4de6f 100644 --- a/src/views/preview/project-view.jsx +++ b/src/views/preview/project-view.jsx @@ -29,6 +29,7 @@ const Meta = require('./meta.jsx'); const sessionActions = require('../../redux/session.js'); const navigationActions = require('../../redux/navigation.js'); const previewActions = require('../../redux/preview.js'); +const commentsActions = require('../../redux/comments.js'); const frameless = require('../../lib/frameless'); @@ -998,7 +999,7 @@ const mapStateToProps = state => { canShare: userOwnsProject && state.permissions.social, canToggleComments: userOwnsProject || isAdmin, canUseBackpack: isLoggedIn, - comments: state.preview.comments, + comments: state.comments.comments, enableCommunity: projectInfoPresent, faved: state.preview.faved, favedLoaded: state.preview.status.faved === previewActions.Status.FETCHED, @@ -1013,7 +1014,7 @@ const mapStateToProps = state => { isShared: isShared, loved: state.preview.loved, lovedLoaded: state.preview.status.loved === previewActions.Status.FETCHED, - moreCommentsToLoad: state.preview.moreCommentsToLoad, + moreCommentsToLoad: state.commentsmoreCommentsToLoad, original: state.preview.original, parent: state.preview.parent, playerMode: state.scratchGui.mode.isPlayerOnly, @@ -1022,7 +1023,7 @@ const mapStateToProps = state => { projectStudios: state.preview.projectStudios, registrationOpen: state.navigation.registrationOpen, remixes: state.preview.remixes, - replies: state.preview.replies, + replies: state.comments.replies, sessionStatus: state.session.status, // check if used useScratch3Registration: state.navigation.useScratch3Registration, user: state.session.session.user, @@ -1034,16 +1035,16 @@ const mapStateToProps = state => { const mapDispatchToProps = dispatch => ({ handleAddComment: (comment, topLevelCommentId) => { - dispatch(previewActions.addNewComment(comment, topLevelCommentId)); + dispatch(commentsActions.addNewComment(comment, topLevelCommentId)); }, handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(previewActions.deleteComment(projectId, commentId, topLevelCommentId, token)); + dispatch(commentsActions.deleteComment(projectId, commentId, topLevelCommentId, token)); }, handleReportComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(previewActions.reportComment(projectId, commentId, topLevelCommentId, token)); + dispatch(commentsActions.reportComment(projectId, commentId, topLevelCommentId, token)); }, handleRestoreComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(previewActions.restoreComment(projectId, commentId, topLevelCommentId, token)); + dispatch(commentsActions.restoreComment(projectId, commentId, topLevelCommentId, token)); }, handleOpenRegistration: event => { event.preventDefault(); @@ -1061,8 +1062,8 @@ const mapDispatchToProps = dispatch => ({ dispatch(navigationActions.toggleLoginOpen()); }, handleSeeAllComments: (id, ownerUsername, isAdmin, token) => { - dispatch(previewActions.resetComments()); - dispatch(previewActions.getTopLevelComments(id, 0, ownerUsername, isAdmin, token)); + dispatch(commentsActions.resetComments()); + dispatch(commentsActions.getTopLevelComments(id, 0, ownerUsername, isAdmin, token)); }, handleUpdateProjectThumbnail: (id, blob) => { dispatch(previewActions.updateProjectThumbnail(id, blob)); @@ -1093,13 +1094,13 @@ const mapDispatchToProps = dispatch => ({ } }, getTopLevelComments: (id, offset, ownerUsername, isAdmin, token) => { - dispatch(previewActions.getTopLevelComments(id, offset, ownerUsername, isAdmin, token)); + dispatch(commentsActions.getTopLevelComments(id, offset, ownerUsername, isAdmin, token)); }, getCommentById: (projectId, commentId, ownerUsername, isAdmin, token) => { - dispatch(previewActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token)); + dispatch(commentsActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token)); }, getMoreReplies: (projectId, commentId, offset, ownerUsername, isAdmin, token) => { - dispatch(previewActions.getReplies(projectId, [commentId], offset, ownerUsername, isAdmin, token)); + dispatch(commentsActions.getReplies(projectId, [commentId], offset, ownerUsername, isAdmin, token)); }, getFavedStatus: (id, username, token) => { dispatch(previewActions.getFavedStatus(id, username, token)); @@ -1136,7 +1137,7 @@ const mapDispatchToProps = dispatch => ({ }, remixProject: () => { dispatch(GUI.remixProject()); - dispatch(previewActions.resetComments()); + dispatch(commentsActions.resetComments()); }, setPlayer: player => { dispatch(GUI.setPlayer(player)); diff --git a/test/unit-legacy/redux/comments-test.js b/test/unit-legacy/redux/comments-test.js new file mode 100644 index 000000000..5c72233eb --- /dev/null +++ b/test/unit-legacy/redux/comments-test.js @@ -0,0 +1,151 @@ +const tap = require('tap'); +const Comments = require('../../../src/redux/comments'); +const initialState = Comments.getInitialState(); +const reducer = Comments.commentsReducer; + +let state; + +tap.tearDown(() => process.nextTick(process.exit)); + +tap.test('Reducer', t => { + t.type(reducer, 'function'); + t.type(initialState, 'object'); + + // Reducers should return their default state when called without state + let undefinedState; + t.deepEqual(initialState, reducer(undefinedState, {type: 'fake action'})); + t.end(); +}); + +tap.test('setFetchStatus', t => { + // initial value + t.equal(initialState.status.comments, Comments.Status.NOT_FETCHED); + + state = reducer(initialState, Comments.setFetchStatus('comments', Comments.Status.FETCHING)); + t.equal(state.status.comments, Comments.Status.FETCHING); + + state = reducer(state, Comments.setFetchStatus('comments', Comments.Status.FETCHED)); + t.equal(state.status.comments, Comments.Status.FETCHED); + + t.end(); +}); + +tap.test('setComments', t => { + // Initial value + t.deepEqual(initialState.comments, []); + + state = reducer(initialState, Comments.setComments([{id: 1}, {id: 2}])); + state = reducer(state, Comments.setComments([{id: 3}, {id: 4}])); + t.deepEqual(state.comments, [{id: 1}, {id: 2}, {id: 3}, {id: 4}]); + + t.end(); +}); + +const commentState = { + comments: [ + {id: 'id1', visibility: 'visible'}, + {id: 'id2', visibility: 'visible'}, + {id: 'id3', visibility: 'visible'} + ], + replies: { + id1: [ + {id: 'id4', visibility: 'visible'}, + {id: 'id5', visibility: 'visible'} + ] + } +}; + +tap.test('setComments, discards duplicates', t => { + state = reducer(commentState, Comments.setComments([{id: 'id1'}])); + // Does not increase the number of comments, still 3 + t.equal(state.comments.length, 3); + t.end(); +}); + +tap.test('setCommentDeleted, top level comment', t => { + state = reducer(commentState, Comments.setCommentDeleted('id2')); + t.equal(state.comments[1].visibility, 'deleted'); + t.end(); +}); + +tap.test('setCommentDeleted, reply comment', t => { + state = reducer(commentState, Comments.setCommentDeleted('id4', 'id1')); + t.equal(state.replies.id1[0].visibility, 'deleted'); + t.end(); +}); + +tap.test('setRepliesDeleted/Restored', t => { + state = reducer(commentState, Comments.setRepliesDeleted('id1')); + t.equal(state.replies.id1[0].visibility, 'deleted'); + t.equal(state.replies.id1[1].visibility, 'deleted'); + + state = reducer(state, Comments.setRepliesRestored('id1')); + t.equal(state.replies.id1[0].visibility, 'visible'); + t.equal(state.replies.id1[1].visibility, 'visible'); + t.end(); +}); + +tap.test('setCommentReported, top level comment', t => { + state = reducer(commentState, Comments.setCommentReported('id2')); + t.equal(state.comments[1].visibility, 'reported'); + t.end(); +}); + +tap.test('setCommentReported, reply comment', t => { + state = reducer(commentState, Comments.setCommentReported('id4', 'id1')); + t.equal(state.replies.id1[0].visibility, 'reported'); + t.end(); +}); + +tap.test('addNewComment, top level comment', t => { + state = reducer(commentState, Comments.addNewComment({id: 'new comment'})); + // Adds comment to beginning of list + t.equal(state.comments[0].id, 'new comment'); + t.end(); +}); + +tap.test('addNewComment, reply comment', t => { + state = reducer(commentState, Comments.addNewComment({id: 'new comment'}, 'id1')); + // Adds replies to the end of the replies list + t.equal(state.replies.id1[2].id, 'new comment'); + t.end(); +}); + +tap.test('setReplies', t => { + // setReplies should append new replies + state = reducer(commentState, Comments.setReplies({ + id1: {id: 'id6'} + })); + t.equal(state.replies.id1[2].id, 'id6'); + t.equal(state.comments[0].moreRepliesToLoad, false); + + // setReplies should ignore duplicates, do the same as above again + t.equal(state.replies.id1.length, 3); + state = reducer(state, Comments.setReplies({id1: {id: 'id6'}})); + t.equal(state.replies.id1.length, 3); + + // setReplies can add replies to a comment that didn't have any + state = reducer(state, Comments.setReplies({ + id2: {id: 'id7'} + })); + t.equal(state.replies.id1.length, 3); + t.equal(state.replies.id2.length, 1); + t.equal(state.replies.id2[0].id, 'id7'); + t.equal(state.comments[0].moreRepliesToLoad, false); + t.equal(state.comments[1].moreRepliesToLoad, false); + + // Getting 20 (COMMENT_LIMIT) replies sets moreRepliesToLoad to true + state = reducer(state, Comments.setReplies({ + id3: (new Array(20)).map((_, i) => ({id: `id${i + 1}`})) + })); + t.equal(state.comments[0].moreRepliesToLoad, false); + t.equal(state.comments[1].moreRepliesToLoad, false); + t.equal(state.comments[2].moreRepliesToLoad, true); + + // Getting one more reply sets moreRepliesToLoad back to false + state = reducer(state, Comments.setReplies({ + id3: {id: 'id21'} + })); + t.equal(state.comments[2].moreRepliesToLoad, false); + t.end(); +}); diff --git a/test/unit-legacy/redux/preview-test.js b/test/unit-legacy/redux/preview-test.js index 24a91ce84..f1fa0488d 100644 --- a/test/unit-legacy/redux/preview-test.js +++ b/test/unit-legacy/redux/preview-test.js @@ -54,123 +54,3 @@ tap.test('updateProjectInfo', t => { }); t.end(); }); - -tap.test('setComments', t => { - // Initial value - t.deepEqual(initialState.comments, []); - - state = reducer(initialState, Preview.setComments([{id: 1}, {id: 2}])); - state = reducer(state, Preview.setComments([{id: 3}, {id: 4}])); - t.deepEqual(state.comments, [{id: 1}, {id: 2}, {id: 3}, {id: 4}]); - - t.end(); -}); - -const commentState = { - comments: [ - {id: 'id1', visibility: 'visible'}, - {id: 'id2', visibility: 'visible'}, - {id: 'id3', visibility: 'visible'} - ], - replies: { - id1: [ - {id: 'id4', visibility: 'visible'}, - {id: 'id5', visibility: 'visible'} - ] - } -}; - -tap.test('setComments, discards duplicates', t => { - state = reducer(commentState, Preview.setComments([{id: 'id1'}])); - // Does not increase the number of comments, still 3 - t.equal(state.comments.length, 3); - t.end(); -}); - -tap.test('setCommentDeleted, top level comment', t => { - state = reducer(commentState, Preview.setCommentDeleted('id2')); - t.equal(state.comments[1].visibility, 'deleted'); - t.end(); -}); - -tap.test('setCommentDeleted, reply comment', t => { - state = reducer(commentState, Preview.setCommentDeleted('id4', 'id1')); - t.equal(state.replies.id1[0].visibility, 'deleted'); - t.end(); -}); - -tap.test('setRepliesDeleted/Restored', t => { - state = reducer(commentState, Preview.setRepliesDeleted('id1')); - t.equal(state.replies.id1[0].visibility, 'deleted'); - t.equal(state.replies.id1[1].visibility, 'deleted'); - - state = reducer(state, Preview.setRepliesRestored('id1')); - t.equal(state.replies.id1[0].visibility, 'visible'); - t.equal(state.replies.id1[1].visibility, 'visible'); - t.end(); -}); - -tap.test('setCommentReported, top level comment', t => { - state = reducer(commentState, Preview.setCommentReported('id2')); - t.equal(state.comments[1].visibility, 'reported'); - t.end(); -}); - -tap.test('setCommentReported, reply comment', t => { - state = reducer(commentState, Preview.setCommentReported('id4', 'id1')); - t.equal(state.replies.id1[0].visibility, 'reported'); - t.end(); -}); - -tap.test('addNewComment, top level comment', t => { - state = reducer(commentState, Preview.addNewComment({id: 'new comment'})); - // Adds comment to beginning of list - t.equal(state.comments[0].id, 'new comment'); - t.end(); -}); - -tap.test('addNewComment, reply comment', t => { - state = reducer(commentState, Preview.addNewComment({id: 'new comment'}, 'id1')); - // Adds replies to the end of the replies list - t.equal(state.replies.id1[2].id, 'new comment'); - t.end(); -}); - -tap.test('setReplies', t => { - // setReplies should append new replies - state = reducer(commentState, Preview.setReplies({ - id1: {id: 'id6'} - })); - t.equal(state.replies.id1[2].id, 'id6'); - t.equal(state.comments[0].moreRepliesToLoad, false); - - // setReplies should ignore duplicates, do the same as above again - t.equal(state.replies.id1.length, 3); - state = reducer(state, Preview.setReplies({id1: {id: 'id6'}})); - t.equal(state.replies.id1.length, 3); - - // setReplies can add replies to a comment that didn't have any - state = reducer(state, Preview.setReplies({ - id2: {id: 'id7'} - })); - t.equal(state.replies.id1.length, 3); - t.equal(state.replies.id2.length, 1); - t.equal(state.replies.id2[0].id, 'id7'); - t.equal(state.comments[0].moreRepliesToLoad, false); - t.equal(state.comments[1].moreRepliesToLoad, false); - - // Getting 20 (COMMENT_LIMIT) replies sets moreRepliesToLoad to true - state = reducer(state, Preview.setReplies({ - id3: (new Array(20)).map((_, i) => ({id: `id${i + 1}`})) - })); - t.equal(state.comments[0].moreRepliesToLoad, false); - t.equal(state.comments[1].moreRepliesToLoad, false); - t.equal(state.comments[2].moreRepliesToLoad, true); - - // Getting one more reply sets moreRepliesToLoad back to false - state = reducer(state, Preview.setReplies({ - id3: {id: 'id21'} - })); - t.equal(state.comments[2].moreRepliesToLoad, false); - t.end(); -}); From 8b2bb5fe928e6e25047f182afba3cbd295c1dd7a Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Mon, 8 Mar 2021 12:15:23 -0500 Subject: [PATCH 2/8] Include legacy unit tests when running test:unit --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 4d5d65651..0ff4a6908 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,8 @@ "test:smoke:sauce": "SMOKE_REMOTE=true tap ./test/integration-legacy/smoke-testing/*.js --timeout=60000 --no-coverage -R classic", "test:unit": "npm run test:unit:jest && npm run test:unit:tap", "test:unit:jest": "jest ./test/unit/ && jest ./test/localization/*.test.js", - "test:unit:tap": "tap ./test/{unit-legacy,localization-legacy}/*.js --no-coverage -R classic", - "test:coverage": "tap ./test/{unit-legacy,localization-legacy}/*.js --coverage --coverage-report=lcov", + "test:unit:tap": "tap ./test/{unit-legacy,localization-legacy} --no-coverage -R classic", + "test:coverage": "tap ./test/{unit-legacy,localization-legacy} --coverage --coverage-report=lcov", "build": "npm run clean && npm run translate && NODE_OPTIONS=--max_old_space_size=8000 webpack --bail", "clean": "rm -rf ./build && rm -rf ./intl && mkdir -p build && mkdir -p intl", "deploy": "npm run deploy:s3 && npm run deploy:fastly", From 49e62afa8b8d9b421e60fa79ea351ff41995e0ab Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Mon, 8 Mar 2021 12:27:32 -0500 Subject: [PATCH 3/8] Fix typo in mapStateToProps --- src/views/preview/project-view.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/preview/project-view.jsx b/src/views/preview/project-view.jsx index f3ed4de6f..43b1e83a7 100644 --- a/src/views/preview/project-view.jsx +++ b/src/views/preview/project-view.jsx @@ -1014,7 +1014,7 @@ const mapStateToProps = state => { isShared: isShared, loved: state.preview.loved, lovedLoaded: state.preview.status.loved === previewActions.Status.FETCHED, - moreCommentsToLoad: state.commentsmoreCommentsToLoad, + moreCommentsToLoad: state.comments.moreCommentsToLoad, original: state.preview.original, parent: state.preview.parent, playerMode: state.scratchGui.mode.isPlayerOnly, From ac6b4616ba344c745d4039c07b9d0b3e9e817fcb Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Thu, 18 Mar 2021 11:19:47 -0400 Subject: [PATCH 4/8] Split project specific comment actions --- src/redux/comments.js | 144 ---------------------- src/redux/project-comment-actions.js | 173 +++++++++++++++++++++++++++ src/views/preview/project-view.jsx | 15 +-- 3 files changed, 181 insertions(+), 151 deletions(-) create mode 100644 src/redux/project-comment-actions.js diff --git a/src/redux/comments.js b/src/redux/comments.js index 539cf02ad..3eea11cc1 100644 --- a/src/redux/comments.js +++ b/src/redux/comments.js @@ -190,147 +190,3 @@ module.exports.setMoreCommentsToLoad = moreCommentsToLoad => ({ module.exports.resetComments = () => ({ type: 'RESET_COMMENTS' }); - -module.exports.getTopLevelComments = (id, offset, ownerUsername, isAdmin, token) => (dispatch => { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING)); - api({ - uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${id}/comments`, - authentication: token ? token : null, - params: {offset: offset || 0, limit: COMMENT_LIMIT} - }, (err, body, res) => { - if (err) { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError(err)); - return; - } - if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError('No comment info')); - return; - } - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED)); - dispatch(module.exports.setComments(body)); - dispatch(module.exports.getReplies(id, body.map(comment => comment.id), 0, ownerUsername, isAdmin, token)); - - // If we loaded a full page of comments, assume there are more to load. - // This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require - // any more server query complexity, so seems worth it. In the case of a project with - // number of comments divisible by the COMMENT_LIMIT, the load more button will be - // clickable, but upon clicking it will go away. - dispatch(module.exports.setMoreCommentsToLoad(body.length === COMMENT_LIMIT)); - }); -}); - -module.exports.getCommentById = (projectId, commentId, ownerUsername, isAdmin, token) => (dispatch => { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHING)); - api({ - uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${commentId}`, - authentication: token ? token : null - }, (err, body, res) => { - if (err) { - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError(err)); - return; - } - if (!body || res.statusCode >= 400) { // NotFound - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.ERROR)); - dispatch(module.exports.setError('No comment info')); - return; - } - - if (body.parent_id) { - // If the comment is a reply, load the parent - return dispatch(module.exports.getCommentById(projectId, body.parent_id, ownerUsername, isAdmin, token)); - } - - // If the comment is not a reply, show it as top level and load replies - dispatch(module.exports.setFetchStatus('comments', module.exports.Status.FETCHED)); - dispatch(module.exports.setComments([body])); - dispatch(module.exports.getReplies(projectId, [body.id], 0, ownerUsername, isAdmin, token)); - }); -}); - -module.exports.getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => { - dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHING)); - const fetchedReplies = {}; - eachLimit(commentIds, 10, (parentId, callback) => { - api({ - uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`, - authentication: token ? token : null, - params: {offset: offset || 0, limit: COMMENT_LIMIT} - }, (err, body, res) => { - if (err) { - return callback(`Error fetching comment replies: ${err}`); - } - if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound - return callback('No comment reply information'); - } - fetchedReplies[parentId] = body; - callback(null, body); - }); - }, err => { - if (err) { - dispatch(module.exports.setFetchStatus('replies', module.exports.Status.ERROR)); - dispatch(module.exports.setError(err)); - return; - } - dispatch(module.exports.setFetchStatus('replies', module.exports.Status.FETCHED)); - dispatch(module.exports.setReplies(fetchedReplies)); - }); -}); - -module.exports.deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { - /* TODO fetching/fetched/error states updates for comment deleting */ - api({ - uri: `/proxy/comments/project/${projectId}/comment/${commentId}`, - authentication: token, - withCredentials: true, - method: 'DELETE', - useCsrf: true - }, (err, body, res) => { - if (err || res.statusCode !== 200) { - log.error(err || res.body); - return; - } - dispatch(module.exports.setCommentDeleted(commentId, topLevelCommentId)); - if (!topLevelCommentId) { - dispatch(module.exports.setRepliesDeleted(commentId)); - } - }); -}); - -module.exports.reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { - api({ - uri: `/proxy/project/${projectId}/comment/${commentId}/report`, - authentication: token, - withCredentials: true, - method: 'POST', - useCsrf: true - }, (err, body, res) => { - if (err || res.statusCode !== 200) { - log.error(err || res.body); - return; - } - // TODO use the reportId in the response for unreporting functionality - dispatch(module.exports.setCommentReported(commentId, topLevelCommentId)); - }); -}); - -module.exports.restoreComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { - api({ - uri: `/proxy/admin/project/${projectId}/comment/${commentId}/undelete`, - authentication: token, - withCredentials: true, - method: 'PUT', - useCsrf: true - }, (err, body, res) => { - if (err || res.statusCode !== 200) { - log.error(err || res.body); - return; - } - dispatch(module.exports.setCommentRestored(commentId, topLevelCommentId)); - if (!topLevelCommentId) { - dispatch(module.exports.setRepliesRestored(commentId)); - } - }); -}); diff --git a/src/redux/project-comment-actions.js b/src/redux/project-comment-actions.js new file mode 100644 index 000000000..857a4df1a --- /dev/null +++ b/src/redux/project-comment-actions.js @@ -0,0 +1,173 @@ +const eachLimit = require('async/eachLimit'); + +const api = require('../lib/api'); +const log = require('../lib/log'); + +const COMMENT_LIMIT = 20; + +const { + Status, + setFetchStatus, + setCommentDeleted, + setCommentReported, + setCommentRestored, + setMoreCommentsToLoad, + setComments, + setError, + setReplies, + setRepliesDeleted, + setRepliesRestored +} = require('../redux/comments.js'); + +const getTopLevelComments = (id, offset, ownerUsername, isAdmin, token) => (dispatch => { + dispatch(setFetchStatus('comments', Status.FETCHING)); + api({ + uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${id}/comments`, + authentication: token ? token : null, + params: {offset: offset || 0, limit: COMMENT_LIMIT} + }, (err, body, res) => { + if (err) { + dispatch(setFetchStatus('comments', Status.ERROR)); + dispatch(setError(err)); + return; + } + if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound + dispatch(setFetchStatus('comments', Status.ERROR)); + dispatch(setError('No comment info')); + return; + } + dispatch(setFetchStatus('comments', Status.FETCHED)); + dispatch(setComments(body)); + dispatch(getReplies(id, body.map(comment => comment.id), 0, ownerUsername, isAdmin, token)); + + // If we loaded a full page of comments, assume there are more to load. + // This will be wrong (1 / COMMENT_LIMIT) of the time, but does not require + // any more server query complexity, so seems worth it. In the case of a project with + // number of comments divisible by the COMMENT_LIMIT, the load more button will be + // clickable, but upon clicking it will go away. + dispatch(setMoreCommentsToLoad(body.length === COMMENT_LIMIT)); + }); +}); + +const getCommentById = (projectId, commentId, ownerUsername, isAdmin, token) => (dispatch => { + dispatch(setFetchStatus('comments', Status.FETCHING)); + api({ + uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${commentId}`, + authentication: token ? token : null + }, (err, body, res) => { + if (err) { + dispatch(setFetchStatus('comments', Status.ERROR)); + dispatch(setError(err)); + return; + } + if (!body || res.statusCode >= 400) { // NotFound + dispatch(setFetchStatus('comments', Status.ERROR)); + dispatch(setError('No comment info')); + return; + } + + if (body.parent_id) { + // If the comment is a reply, load the parent + return dispatch(getCommentById(projectId, body.parent_id, ownerUsername, isAdmin, token)); + } + + // If the comment is not a reply, show it as top level and load replies + dispatch(setFetchStatus('comments', Status.FETCHED)); + dispatch(setComments([body])); + dispatch(getReplies(projectId, [body.id], 0, ownerUsername, isAdmin, token)); + }); +}); + +const getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => { + dispatch(setFetchStatus('replies', Status.FETCHING)); + const fetchedReplies = {}; + eachLimit(commentIds, 10, (parentId, callback) => { + api({ + uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`, + authentication: token ? token : null, + params: {offset: offset || 0, limit: COMMENT_LIMIT} + }, (err, body, res) => { + if (err) { + return callback(`Error fetching comment replies: ${err}`); + } + if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound + return callback('No comment reply information'); + } + fetchedReplies[parentId] = body; + callback(null, body); + }); + }, err => { + if (err) { + dispatch(setFetchStatus('replies', Status.ERROR)); + dispatch(setError(err)); + return; + } + dispatch(setFetchStatus('replies', Status.FETCHED)); + dispatch(setReplies(fetchedReplies)); + }); +}); + +const deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { + /* TODO fetching/fetched/error states updates for comment deleting */ + api({ + uri: `/proxy/comments/project/${projectId}/comment/${commentId}`, + authentication: token, + withCredentials: true, + method: 'DELETE', + useCsrf: true + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + log.error(err || res.body); + return; + } + dispatch(setCommentDeleted(commentId, topLevelCommentId)); + if (!topLevelCommentId) { + dispatch(setRepliesDeleted(commentId)); + } + }); +}); + +const reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { + api({ + uri: `/proxy/project/${projectId}/comment/${commentId}/report`, + authentication: token, + withCredentials: true, + method: 'POST', + useCsrf: true + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + log.error(err || res.body); + return; + } + // TODO use the reportId in the response for unreporting functionality + dispatch(setCommentReported(commentId, topLevelCommentId)); + }); +}); + +const restoreComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { + api({ + uri: `/proxy/admin/project/${projectId}/comment/${commentId}/undelete`, + authentication: token, + withCredentials: true, + method: 'PUT', + useCsrf: true + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + log.error(err || res.body); + return; + } + dispatch(setCommentRestored(commentId, topLevelCommentId)); + if (!topLevelCommentId) { + dispatch(setRepliesRestored(commentId)); + } + }); +}); + +module.exports = { + getTopLevelComments, + getCommentById, + getReplies, + deleteComment, + reportComment, + restoreComment +}; \ No newline at end of file diff --git a/src/views/preview/project-view.jsx b/src/views/preview/project-view.jsx index 43b1e83a7..88927878e 100644 --- a/src/views/preview/project-view.jsx +++ b/src/views/preview/project-view.jsx @@ -30,6 +30,7 @@ const sessionActions = require('../../redux/session.js'); const navigationActions = require('../../redux/navigation.js'); const previewActions = require('../../redux/preview.js'); const commentsActions = require('../../redux/comments.js'); +const projectCommentActions = require('../../redux/project-comment-actions.js'); const frameless = require('../../lib/frameless'); @@ -1038,13 +1039,13 @@ const mapDispatchToProps = dispatch => ({ dispatch(commentsActions.addNewComment(comment, topLevelCommentId)); }, handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(commentsActions.deleteComment(projectId, commentId, topLevelCommentId, token)); + dispatch(projectCommentActions.deleteComment(projectId, commentId, topLevelCommentId, token)); }, handleReportComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(commentsActions.reportComment(projectId, commentId, topLevelCommentId, token)); + dispatch(projectCommentActions.reportComment(projectId, commentId, topLevelCommentId, token)); }, handleRestoreComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(commentsActions.restoreComment(projectId, commentId, topLevelCommentId, token)); + dispatch(projectCommentActions.restoreComment(projectId, commentId, topLevelCommentId, token)); }, handleOpenRegistration: event => { event.preventDefault(); @@ -1063,7 +1064,7 @@ const mapDispatchToProps = dispatch => ({ }, handleSeeAllComments: (id, ownerUsername, isAdmin, token) => { dispatch(commentsActions.resetComments()); - dispatch(commentsActions.getTopLevelComments(id, 0, ownerUsername, isAdmin, token)); + dispatch(projectCommentActions.getTopLevelComments(id, 0, ownerUsername, isAdmin, token)); }, handleUpdateProjectThumbnail: (id, blob) => { dispatch(previewActions.updateProjectThumbnail(id, blob)); @@ -1094,13 +1095,13 @@ const mapDispatchToProps = dispatch => ({ } }, getTopLevelComments: (id, offset, ownerUsername, isAdmin, token) => { - dispatch(commentsActions.getTopLevelComments(id, offset, ownerUsername, isAdmin, token)); + dispatch(projectCommentActions.getTopLevelComments(id, offset, ownerUsername, isAdmin, token)); }, getCommentById: (projectId, commentId, ownerUsername, isAdmin, token) => { - dispatch(commentsActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token)); + dispatch(projectCommentActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token)); }, getMoreReplies: (projectId, commentId, offset, ownerUsername, isAdmin, token) => { - dispatch(commentsActions.getReplies(projectId, [commentId], offset, ownerUsername, isAdmin, token)); + dispatch(projectCommentActions.getReplies(projectId, [commentId], offset, ownerUsername, isAdmin, token)); }, getFavedStatus: (id, username, token) => { dispatch(previewActions.getFavedStatus(id, username, token)); From 4029f431b386a5e92c1708a9703e52936e521fc8 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Thu, 18 Mar 2021 11:42:01 -0400 Subject: [PATCH 5/8] Fix linting --- src/redux/comments.js | 4 -- src/redux/project-comment-actions.js | 60 ++++++++++++++-------------- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/src/redux/comments.js b/src/redux/comments.js index 3eea11cc1..1ce577c55 100644 --- a/src/redux/comments.js +++ b/src/redux/comments.js @@ -1,11 +1,7 @@ const keyMirror = require('keymirror'); -const eachLimit = require('async/eachLimit'); const mergeWith = require('lodash.mergewith'); const uniqBy = require('lodash.uniqby'); -const api = require('../lib/api'); -const log = require('../lib/log'); - const COMMENT_LIMIT = 20; module.exports.Status = keyMirror({ diff --git a/src/redux/project-comment-actions.js b/src/redux/project-comment-actions.js index 857a4df1a..a5c573546 100644 --- a/src/redux/project-comment-actions.js +++ b/src/redux/project-comment-actions.js @@ -19,6 +19,35 @@ const { setRepliesRestored } = require('../redux/comments.js'); +const getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => { + dispatch(setFetchStatus('replies', Status.FETCHING)); + const fetchedReplies = {}; + eachLimit(commentIds, 10, (parentId, callback) => { + api({ + uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`, + authentication: token ? token : null, + params: {offset: offset || 0, limit: COMMENT_LIMIT} + }, (err, body, res) => { + if (err) { + return callback(`Error fetching comment replies: ${err}`); + } + if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound + return callback('No comment reply information'); + } + fetchedReplies[parentId] = body; + callback(null, body); + }); + }, err => { + if (err) { + dispatch(setFetchStatus('replies', Status.ERROR)); + dispatch(setError(err)); + return; + } + dispatch(setFetchStatus('replies', Status.FETCHED)); + dispatch(setReplies(fetchedReplies)); + }); +}); + const getTopLevelComments = (id, offset, ownerUsername, isAdmin, token) => (dispatch => { dispatch(setFetchStatus('comments', Status.FETCHING)); api({ @@ -78,35 +107,6 @@ const getCommentById = (projectId, commentId, ownerUsername, isAdmin, token) => }); }); -const getReplies = (projectId, commentIds, offset, ownerUsername, isAdmin, token) => (dispatch => { - dispatch(setFetchStatus('replies', Status.FETCHING)); - const fetchedReplies = {}; - eachLimit(commentIds, 10, (parentId, callback) => { - api({ - uri: `${isAdmin ? '/admin' : `/users/${ownerUsername}`}/projects/${projectId}/comments/${parentId}/replies`, - authentication: token ? token : null, - params: {offset: offset || 0, limit: COMMENT_LIMIT} - }, (err, body, res) => { - if (err) { - return callback(`Error fetching comment replies: ${err}`); - } - if (typeof body === 'undefined' || res.statusCode >= 400) { // NotFound - return callback('No comment reply information'); - } - fetchedReplies[parentId] = body; - callback(null, body); - }); - }, err => { - if (err) { - dispatch(setFetchStatus('replies', Status.ERROR)); - dispatch(setError(err)); - return; - } - dispatch(setFetchStatus('replies', Status.FETCHED)); - dispatch(setReplies(fetchedReplies)); - }); -}); - const deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { /* TODO fetching/fetched/error states updates for comment deleting */ api({ @@ -170,4 +170,4 @@ module.exports = { deleteComment, reportComment, restoreComment -}; \ No newline at end of file +}; From fd7b2ce41b0574ebefd5d43b28f8cb848b0788c5 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Thu, 18 Mar 2021 15:18:55 -0400 Subject: [PATCH 6/8] Replace remixes which was removed by mistake --- src/redux/preview.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/redux/preview.js b/src/redux/preview.js index e4c7c74be..dc11478ed 100644 --- a/src/redux/preview.js +++ b/src/redux/preview.js @@ -26,6 +26,7 @@ module.exports.getInitialState = () => ({ studioRequests: {} }, projectInfo: {}, + remixes: [], faved: false, loved: false, original: {}, From d02ddef8e7f16d94c40f35e7dfb80da348dab809 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Wed, 24 Mar 2021 14:38:41 -0400 Subject: [PATCH 7/8] Remove single nesting requirement that was preventing tests from running --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 26842b593..2a5d46674 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "test:unit:jest": "npm run test:unit:jest:unit && npm run test:unit:jest:localization", "test:unit:jest:unit": "jest ./test/unit/ --reporters=default", "test:unit:jest:localization": "jest ./test/localization/*.test.js --reporters=default", - "test:unit:tap": "tap ./test/{unit-legacy,localization-legacy}/*.js --no-coverage -R classic", + "test:unit:tap": "tap ./test/{unit-legacy,localization-legacy}/ --no-coverage -R classic", "test:unit:convertReportToXunit": "tap ./test/results/unit-raw.tap --no-coverage -R xunit > ./test/results/unit-tap-results.xml", - "test:coverage": "tap ./test/{unit-legacy,localization-legacy}/*.js --coverage --coverage-report=lcov", + "test:coverage": "tap ./test/{unit-legacy,localization-legacy}/ --coverage --coverage-report=lcov", "build": "npm run clean && npm run translate && NODE_OPTIONS=--max_old_space_size=8000 webpack --bail", "clean": "rm -rf ./build && rm -rf ./intl && mkdir -p build && mkdir -p intl", "deploy": "npm run deploy:s3 && npm run deploy:fastly", From 8a33d1c5a54c91e2cdc05ed2d7548f85a2237869 Mon Sep 17 00:00:00 2001 From: Paul Kaplan Date: Wed, 24 Mar 2021 14:48:05 -0400 Subject: [PATCH 8/8] Re-export missing base comment actions to simplify --- src/redux/project-comment-actions.js | 9 ++++++++- src/views/preview/preview.jsx | 4 ++-- src/views/preview/project-view.jsx | 7 +++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/redux/project-comment-actions.js b/src/redux/project-comment-actions.js index a5c573546..6a4e6c354 100644 --- a/src/redux/project-comment-actions.js +++ b/src/redux/project-comment-actions.js @@ -6,6 +6,8 @@ const log = require('../lib/log'); const COMMENT_LIMIT = 20; const { + addNewComment, + resetComments, Status, setFetchStatus, setCommentDeleted, @@ -169,5 +171,10 @@ module.exports = { getReplies, deleteComment, reportComment, - restoreComment + restoreComment, + + // Re-export these specific action creators directly so the implementer + // does not need to go to two places for comment actions + addNewComment, + resetComments }; diff --git a/src/views/preview/preview.jsx b/src/views/preview/preview.jsx index 8864b5a3a..05d484503 100644 --- a/src/views/preview/preview.jsx +++ b/src/views/preview/preview.jsx @@ -5,7 +5,7 @@ const Page = require('../../components/page/www/page.jsx'); const render = require('../../lib/render.jsx'); const previewActions = require('../../redux/preview.js'); -const commentsActions = require('../../redux/comments.js'); +const commentActions = require('../../redux/comments.js'); const isSupportedBrowser = require('../../lib/supported-browser').default; const UnsupportedBrowser = require('./unsupported-browser.jsx'); @@ -17,7 +17,7 @@ if (isSupportedBrowser()) { document.getElementById('app'), { preview: previewActions.previewReducer, - comments: commentsActions.commentsReducer, + comments: commentActions.commentsReducer, ...ProjectView.guiReducers }, { diff --git a/src/views/preview/project-view.jsx b/src/views/preview/project-view.jsx index 88927878e..2dc34e4cb 100644 --- a/src/views/preview/project-view.jsx +++ b/src/views/preview/project-view.jsx @@ -29,7 +29,6 @@ const Meta = require('./meta.jsx'); const sessionActions = require('../../redux/session.js'); const navigationActions = require('../../redux/navigation.js'); const previewActions = require('../../redux/preview.js'); -const commentsActions = require('../../redux/comments.js'); const projectCommentActions = require('../../redux/project-comment-actions.js'); const frameless = require('../../lib/frameless'); @@ -1036,7 +1035,7 @@ const mapStateToProps = state => { const mapDispatchToProps = dispatch => ({ handleAddComment: (comment, topLevelCommentId) => { - dispatch(commentsActions.addNewComment(comment, topLevelCommentId)); + dispatch(projectCommentActions.addNewComment(comment, topLevelCommentId)); }, handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => { dispatch(projectCommentActions.deleteComment(projectId, commentId, topLevelCommentId, token)); @@ -1063,7 +1062,7 @@ const mapDispatchToProps = dispatch => ({ dispatch(navigationActions.toggleLoginOpen()); }, handleSeeAllComments: (id, ownerUsername, isAdmin, token) => { - dispatch(commentsActions.resetComments()); + dispatch(projectCommentActions.resetComments()); dispatch(projectCommentActions.getTopLevelComments(id, 0, ownerUsername, isAdmin, token)); }, handleUpdateProjectThumbnail: (id, blob) => { @@ -1138,7 +1137,7 @@ const mapDispatchToProps = dispatch => ({ }, remixProject: () => { dispatch(GUI.remixProject()); - dispatch(commentsActions.resetComments()); + dispatch(projectCommentActions.resetComments()); }, setPlayer: player => { dispatch(GUI.setPlayer(player));