diff --git a/package.json b/package.json index 51fd674f0..2e04b9a7b 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", diff --git a/src/redux/comments.js b/src/redux/comments.js new file mode 100644 index 000000000..1ce577c55 --- /dev/null +++ b/src/redux/comments.js @@ -0,0 +1,188 @@ +const keyMirror = require('keymirror'); +const mergeWith = require('lodash.mergewith'); +const uniqBy = require('lodash.uniqby'); + +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' +}); diff --git a/src/redux/preview.js b/src/redux/preview.js index 7239eba47..dc11478ed 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, @@ -33,8 +27,6 @@ module.exports.getInitialState = () => ({ }, projectInfo: {}, remixes: [], - comments: [], - replies: {}, faved: false, loved: false, original: {}, @@ -42,7 +34,6 @@ module.exports.getInitialState = () => ({ projectStudios: [], curatedStudios: [], currentStudioIds: [], - moreCommentsToLoad: false, projectNotAvailable: false, visibilityInfo: {} }); @@ -96,76 +87,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 +103,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 +164,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 +191,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 +311,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 +643,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/redux/project-comment-actions.js b/src/redux/project-comment-actions.js new file mode 100644 index 000000000..6a4e6c354 --- /dev/null +++ b/src/redux/project-comment-actions.js @@ -0,0 +1,180 @@ +const eachLimit = require('async/eachLimit'); + +const api = require('../lib/api'); +const log = require('../lib/log'); + +const COMMENT_LIMIT = 20; + +const { + addNewComment, + resetComments, + Status, + setFetchStatus, + setCommentDeleted, + setCommentReported, + setCommentRestored, + setMoreCommentsToLoad, + setComments, + setError, + setReplies, + setRepliesDeleted, + 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({ + 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 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, + + // 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 3da330c3c..05d484503 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 commentActions = 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: commentActions.commentsReducer, ...ProjectView.guiReducers }, { diff --git a/src/views/preview/project-view.jsx b/src/views/preview/project-view.jsx index f540c2f8c..2dc34e4cb 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 projectCommentActions = require('../../redux/project-comment-actions.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.comments.moreCommentsToLoad, 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(projectCommentActions.addNewComment(comment, topLevelCommentId)); }, handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(previewActions.deleteComment(projectId, commentId, topLevelCommentId, token)); + dispatch(projectCommentActions.deleteComment(projectId, commentId, topLevelCommentId, token)); }, handleReportComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(previewActions.reportComment(projectId, commentId, topLevelCommentId, token)); + dispatch(projectCommentActions.reportComment(projectId, commentId, topLevelCommentId, token)); }, handleRestoreComment: (projectId, commentId, topLevelCommentId, token) => { - dispatch(previewActions.restoreComment(projectId, commentId, topLevelCommentId, token)); + dispatch(projectCommentActions.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(projectCommentActions.resetComments()); + dispatch(projectCommentActions.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(projectCommentActions.getTopLevelComments(id, offset, ownerUsername, isAdmin, token)); }, getCommentById: (projectId, commentId, ownerUsername, isAdmin, token) => { - dispatch(previewActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token)); + dispatch(projectCommentActions.getCommentById(projectId, commentId, ownerUsername, isAdmin, token)); }, getMoreReplies: (projectId, commentId, offset, ownerUsername, isAdmin, token) => { - dispatch(previewActions.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)); @@ -1136,7 +1137,7 @@ const mapDispatchToProps = dispatch => ({ }, remixProject: () => { dispatch(GUI.remixProject()); - dispatch(previewActions.resetComments()); + dispatch(projectCommentActions.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(); -});