Unverified Commit eb3c65d0 authored by Eric Rosenbaum's avatar Eric Rosenbaum Committed by GitHub

Revert "Revert "[master] Release 2021-04-07""

parent 1b005239
......@@ -81,7 +81,7 @@ aliases:
- run:
name: "setup python"
command: |
curl https://bootstrap.pypa.io/3.5/get-pip.py -o get-pip.py
curl https://bootstrap.pypa.io/pip/3.5/get-pip.py -o get-pip.py
python3 get-pip.py pip==21.0.1
pip install s3cmd==2.1.0
- run:
......@@ -134,7 +134,7 @@ jobs:
# <<: *integration_tap
workflows:
build-staging-production: # build-test-deploy
build-test-deploy:
jobs:
- build-staging:
context:
......@@ -154,42 +154,39 @@ workflows:
branches:
only:
- master
# - deploy-staging:
# context:
# - scratch-www-all
# - scratch-www-staging
# requires:
# - build-staging
# filters:
# branches:
# only:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
# - integration-staging-jest:
# context:
# - scratch-www-all
# - scratch-www-staging
# requires:
# - deploy-staging
# filters:
# branches:
# only:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
# - integration-staging-tap:
# context:
# - scratch-www-all
# - scratch-www-staging
# requires:
# - deploy-staging
# filters:
# branches:
# only:
# - develop
# - /^hotfix\/.*/
# - /^release\/.*/
# - circleCI-configure-tests
- deploy-staging:
context:
- scratch-www-all
- scratch-www-staging
requires:
- build-staging
filters:
branches:
only:
- develop
- /^hotfix\/.*/
- /^release\/.*/
- integration-staging-jest:
context:
- scratch-www-all
- scratch-www-staging
requires:
- deploy-staging
filters:
branches:
only:
- develop
- /^hotfix\/.*/
- /^release\/.*/
- integration-staging-tap:
context:
- scratch-www-all
- scratch-www-staging
requires:
- deploy-staging
filters:
branches:
only:
- develop
- /^hotfix\/.*/
- /^release\/.*/
......@@ -114,15 +114,6 @@ jobs:
include:
- stage: test
deploy:
- provider: script
skip_cleanup: $SKIP_CLEANUP
script: npm run deploy
on:
repo: LLK/scratch-www
branch:
- develop
- hotfix/*
- release/*
- provider: script
skip_cleanup: $SKIP_CLEANUP
script: npm run deploy
......@@ -138,6 +129,6 @@ stages:
- name: test
if: type != cron
- name: smoke
if: type NOT IN (cron, pull_request) AND (branch =~ /^(develop|master|release\/|hotfix\/)/)
if: type NOT IN (cron, pull_request) AND (branch =~ /^(master)/)
- name: update translations
if: branch == develop AND type == cron
This diff is collapsed.
......@@ -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",
......@@ -53,6 +53,7 @@
"express": "4.16.1",
"express-http-proxy": "1.1.0",
"lodash.defaults": "4.0.1",
"lodash.get": "^4.4.2",
"react-helmet": "5.2.0",
"react-router-dom": "^5.2.0",
"scratch-parser": "^5.0.0",
......@@ -125,7 +126,7 @@
"redux-mock-store": "^1.2.3",
"redux-thunk": "2.0.1",
"sass-loader": "6.0.6",
"scratch-gui": "0.1.0-prerelease.20210324120840",
"scratch-gui": "0.1.0-prerelease.20210401231322",
"scratch-l10n": "latest",
"selenium-webdriver": "3.6.0",
"slick-carousel": "1.6.0",
......
......@@ -2,129 +2,145 @@
{
"id": 1,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 2,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 3,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 4,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 5,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 6,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 7,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 8,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 9,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 10,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 11,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 12,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 13,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 14,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 15,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
},
{
"id": 16,
"type": "project",
"title": "Project",
"title": "Project Title",
"thumbnailUrl": "",
"creator": "",
"href": "#"
"author": {"username": "project creator"},
"href": "#",
"stats": {}
}
]
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'
});
This diff is collapsed.
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
};
const keyMirror = require('keymirror');
const defaults = require('lodash.defaults');
const get = require('lodash.get');
const {requestSession, requestSessionWithRetry} = require('../lib/session');
const messageCountActions = require('./message-count.js');
......@@ -118,3 +119,13 @@ module.exports.refreshSessionWithRetry = () => (dispatch => {
dispatch(module.exports.setSessionError(err));
});
});
// Selectors
module.exports.selectIsLoggedIn = state => get(state, ['session', 'session', 'user'], false);
module.exports.selectUsername = state => get(state, ['session', 'session', 'user', 'username'], null);
module.exports.selectToken = state => get(state, ['session', 'session', 'user', 'token'], null);
module.exports.selectIsAdmin = state => get(state, ['session', 'session', 'permissions', 'admin'], false);
module.exports.selectIsSocial = state => get(state, ['session', 'session', 'permissions', 'social'], false);
// NB logged out user id as NaN so that it can never be used in equality testing since NaN !== NaN
module.exports.selectUserId = state => get(state, ['session', 'session', 'user', 'id'], NaN);
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 = (studioId, commentIds, offset, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('replies', Status.FETCHING));
const fetchedReplies = {};
eachLimit(commentIds, 10, (parentId, callback) => {
api({
uri: `${isAdmin ? '/admin' : ''}/studios/${studioId}/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, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('comments', Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : ''}/studios/${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, 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 = (studioId, commentId, isAdmin, token) => (dispatch => {
dispatch(setFetchStatus('comments', Status.FETCHING));
api({
uri: `${isAdmin ? '/admin' : ''}/studios/${studioId}/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(studioId, body.parent_id, 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(studioId, [body.id], 0, isAdmin, token));
});
});
const deleteComment = (studioId, commentId, topLevelCommentId, token) => (dispatch => {
/* TODO fetching/fetched/error states updates for comment deleting */
api({
uri: `/proxy/comments/studio/${studioId}/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 = (studioId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/studio/${studioId}/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 = (studioId, commentId, topLevelCommentId, token) => (dispatch => {
api({
uri: `/proxy/admin/studio/${studioId}/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
};
......@@ -3,6 +3,8 @@ const keyMirror = require('keymirror');
const api = require('../lib/api');
const log = require('../lib/log');
const {selectUserId, selectIsAdmin, selectIsSocial} = require('./session');
const Status = keyMirror({
FETCHED: null,
NOT_FETCHED: null,
......@@ -18,6 +20,7 @@ const getInitialState = () => ({
commentingAllowed: false,
thumbnail: '',
followers: 0,
owner: null,
rolesStatus: Status.NOT_FETCHED,
manager: false,
......@@ -55,6 +58,8 @@ const studioReducer = (state, action) => {
}
};
// Action Creators
const setFetchStatus = (fetchType, fetchStatus, error) => ({
type: 'SET_FETCH_STATUS',
fetchType,
......@@ -72,6 +77,8 @@ const setRoles = roles => ({
roles: roles
});
// Thunks
const getInfo = studioId => (dispatch => {
dispatch(setFetchStatus('infoStatus', Status.FETCHING));
api({uri: `/studios/${studioId}`}, (err, body, res) => {
......@@ -86,7 +93,8 @@ const getInfo = studioId => (dispatch => {
openToAll: body.open_to_all,
commentingAllowed: body.commenting_allowed,
updated: new Date(body.history.modified),
followers: body.stats.followers
followers: body.stats.followers,
owner: body.owner
}));
});
});
......@@ -111,10 +119,34 @@ const getRoles = (studioId, username, token) => (dispatch => {
});
});
// Selectors
// Fine-grain selector helpers - not exported, use the higher level selectors below
const isCreator = state => selectUserId(state) === state.studio.owner;
const isCurator = state => state.studio.curator;
const isManager = state => state.studio.manager || isCreator(state);
// Action-based permissions selectors
const selectCanEditInfo = state => selectIsAdmin(state) || isManager(state);
const selectCanAddProjects = state =>
isManager(state) ||
isCurator(state) ||
(selectIsSocial(state) && state.studio.openToAll);
// This isn't "canComment" since they could be muted, but comment composer handles that
const selectShowCommentComposer = state => selectIsSocial(state);
module.exports = {
getInitialState,
studioReducer,
Status,
// Thunks
getInfo,
getRoles
getRoles,
// Selectors
selectCanEditInfo,
selectCanAddProjects,
selectShowCommentComposer
};
......@@ -4,12 +4,5 @@
"pattern": "^/components/?$",
"view": "components/components",
"title": "Components"
},
{
"name": "studio",
"pattern": "^/studios-playground/\\d+(/projects|/curators|/activity|/comments)?/?(\\?.*)?$",
"routeAlias": "/studios-playground/?$",
"view": "studio/studio",
"title": "Studio Playground"
}
]
......@@ -296,6 +296,13 @@
"view": "studentregistration/studentregistration",
"title": "Class Registration"
},
{
"name": "studio",
"pattern": "^/studios-playground/\\d+(/projects|/curators|/activity|/comments)?/?(\\?.*)?$",
"routeAlias": "/studios-playground/?$",
"view": "studio/studio",
"title": "Studio Playground"
},
{
"name": "teacher-faq",
"pattern": "^/educators/faq/?$",
......
......@@ -8,22 +8,78 @@ const Carousel = require('../../components/carousel/carousel.jsx');
const Form = require('../../components/forms/form.jsx');
const Input = require('../../components/forms/input.jsx');
const Spinner = require('../../components/spinner/spinner.jsx');
const Grid = require('../../components/grid/grid.jsx');
const TextArea = require('../../components/forms/textarea.jsx');
const SubNavigation = require('../../components/subnavigation/subnavigation.jsx');
const Select = require('../../components/forms/select.jsx');
require('./components.scss');
const Components = () => (
<div className="components">
<div className="inner">
<h1>Nav Bubbles</h1>
<div className="subnavigation">
<SubNavigation>
<a href="">
<li className="active">
cats
</li>
</a>
<a href="">
<li>
also cats
</li>
</a>
<a href="">
<li>
not cats
</li>
</a>
</SubNavigation>
</div>
<h1>Grid</h1>
<Grid
showAvatar
/>
<h1>Button</h1>
<Button>I love button</Button>
<Button>I love buttons</Button>
<h1>Form</h1>
<div className="form">
<Form>
<Select
label="Drop-down"
required
options={[
{
label: 'first option',
value: 1
},
{
label: 'second option',
value: 2
},
{
label: 'third option',
value: 3
}
]}
name="name"
value={1}
/>
<Input
label="Text input"
required
maxLength="30"
name="test"
type="text"
/>
<TextArea
label="Text area"
name="textarea1"
required
/>
</Form>
</div>
<h1>Box Component</h1>
<Box
more="Cat Gifs"
......
......@@ -5,6 +5,19 @@
margin: 0 0 10px 0;
}
.subnavigation {
li {
background-color: $active-gray;
&.active {
background-color: $ui-blue;
}
}
}
.form {
width: 200px;
}
.colors {
span {
display: inline-block;
......
......@@ -110,7 +110,7 @@ class Comment extends React.Component {
highlighted,
id,
parentId,
projectId,
postURI,
replyUsername,
visibility
} = this.props;
......@@ -234,7 +234,7 @@ class Comment extends React.Component {
isReply
commenteeId={author.id}
parentId={parentId || id}
projectId={projectId}
postURI={postURI}
onAddComment={this.handlePostReply}
onCancel={this.handleToggleReplying}
/>
......@@ -285,7 +285,7 @@ Comment.propTypes = {
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
postURI: PropTypes.string,
replyUsername: PropTypes.string,
visibility: PropTypes.string
};
......
......@@ -79,7 +79,7 @@ class ComposeComment extends React.Component {
handlePost () {
this.setState({status: ComposeStatus.SUBMITTING});
api({
uri: `/proxy/comments/project/${this.props.projectId}`,
uri: this.props.postURI,
authentication: this.props.user.token,
withCredentials: true,
method: 'POST',
......@@ -434,7 +434,7 @@ ComposeComment.propTypes = {
onAddComment: PropTypes.func,
onCancel: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
postURI: PropTypes.string,
user: PropTypes.shape({
id: PropTypes.number,
username: PropTypes.string,
......
......@@ -87,7 +87,7 @@ class TopLevelComment extends React.Component {
onReport,
onRestore,
replies,
projectId,
postURI,
visibility
} = this.props;
......@@ -97,7 +97,7 @@ class TopLevelComment extends React.Component {
<FlexRow className="comment-container">
<Comment
highlighted={highlightedCommentId === id}
projectId={projectId}
postURI={postURI}
onAddComment={this.handleAddComment}
{...{
author,
......@@ -138,7 +138,7 @@ class TopLevelComment extends React.Component {
id={reply.id}
key={reply.id}
parentId={id}
projectId={projectId}
postURI={postURI}
replyUsername={this.authorUsername(reply.commentee_id)}
visibility={reply.visibility}
onAddComment={this.handleAddComment}
......@@ -188,7 +188,7 @@ TopLevelComment.propTypes = {
onReport: PropTypes.func,
onRestore: PropTypes.func,
parentId: PropTypes.number,
projectId: PropTypes.string,
postURI: PropTypes.string,
replies: PropTypes.arrayOf(PropTypes.object),
visibility: PropTypes.string
};
......
......@@ -581,7 +581,7 @@ const PreviewPresentation = ({
{projectInfo.comments_allowed ? (
isLoggedIn ? (
isShared && <ComposeComment
projectId={projectId}
postURI={`/proxy/comments/project/${projectId}`}
onAddComment={onAddComment}
/>
) : (
......@@ -613,7 +613,7 @@ const PreviewPresentation = ({
key={comment.id}
moreRepliesToLoad={comment.moreRepliesToLoad}
parentId={comment.parent_id}
projectId={projectId}
postURI={`/proxy/comments/project/${projectId}`}
replies={replies && replies[comment.id] ? replies[comment.id] : []}
visibility={comment.visibility}
onAddComment={onAddComment}
......
......@@ -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
},
{
......
......@@ -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));
......
......@@ -4,7 +4,7 @@
"onePointFour.introNote": "{noteLabel} You can still share projects from 1.4 to the Scratch website. However, projects created in newer versions of Scratch cannot be opened in 1.4.",
"onePointFour.downloads": "Downloads",
"onePointFour.macTitle": "Mac OS X",
"onePointFour.macBody": "Compatible with Mac OSX 10.4 or later",
"onePointFour.macBody": "Compatible with Mac OSX 10.4 through 10.14",
"onePointFour.windowsTitle": "Windows",
"onePointFour.windowsBody": "Compatible with Windows 2000, XP, Vista, 7, and 8",
"onePointFour.windowsNetworkInstaller": "installer",
......
import React from 'react';
import React, {useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
const StudioComments = () => {
import Button from '../../components/forms/button.jsx';
import ComposeComment from '../preview/comment/compose-comment.jsx';
import TopLevelComment from '../preview/comment/top-level-comment.jsx';
import studioCommentActions from '../../redux/studio-comment-actions.js';
import {selectShowCommentComposer} from '../../redux/studio.js';
const StudioComments = ({
comments,
getTopLevelComments,
handleNewComment,
moreCommentsToLoad,
replies,
shouldShowCommentComposer
}) => {
const {studioId} = useParams();
const handleLoadComments = useCallback(() => {
getTopLevelComments(studioId, comments.length);
}, [studioId, comments.length]);
useEffect(() => {
if (comments.length === 0) getTopLevelComments(studioId, 0);
}, [studioId]);
return (
<div>
<h2>Comments</h2>
<p>Studio {studioId}</p>
<div>
{shouldShowCommentComposer &&
<ComposeComment
postURI={`/proxy/comments/studio/${studioId}`}
onAddComment={handleNewComment}
/>
}
{comments.map(comment => (
<TopLevelComment
author={comment.author}
canReply={shouldShowCommentComposer}
content={comment.content}
datetimeCreated={comment.datetime_created}
id={comment.id}
key={comment.id}
moreRepliesToLoad={comment.moreRepliesToLoad}
parentId={comment.parent_id}
postURI={`/proxy/comments/studio/${studioId}`}
replies={replies && replies[comment.id] ? replies[comment.id] : []}
visibility={comment.visibility}
onAddComment={handleNewComment}
/>
))}
{moreCommentsToLoad &&
<Button
className="button load-more-button"
onClick={handleLoadComments}
>
<FormattedMessage id="general.loadMore" />
</Button>
}
</div>
</div>
);
};
export default StudioComments;
StudioComments.propTypes = {
comments: PropTypes.arrayOf(PropTypes.shape({})),
getTopLevelComments: PropTypes.func,
handleNewComment: PropTypes.func,
moreCommentsToLoad: PropTypes.bool,
replies: PropTypes.shape({}),
shouldShowCommentComposer: PropTypes.bool
};
export default connect(
state => ({
comments: state.comments.comments,
moreCommentsToLoad: state.comments.moreCommentsToLoad,
replies: state.comments.replies,
shouldShowCommentComposer: selectShowCommentComposer(state)
}),
{
getTopLevelComments: studioCommentActions.getTopLevelComments,
handleNewComment: studioCommentActions.addNewComment
}
)(StudioComments);
......@@ -3,9 +3,11 @@ import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import Debug from './debug.jsx';
import {getInfo, getRoles} from '../../redux/studio';
const StudioInfo = ({username, studio, token, onLoadInfo, onLoadRoles}) => {
import {selectUsername, selectToken} from '../../redux/session';
import {getInfo, getRoles, selectCanEditInfo} from '../../redux/studio';
const StudioInfo = ({username, studio, token, canEditInfo, onLoadInfo, onLoadRoles}) => {
const {studioId} = useParams();
useEffect(() => { // Load studio info after first render
......@@ -23,11 +25,16 @@ const StudioInfo = ({username, studio, token, onLoadInfo, onLoadRoles}) => {
label="Studio Info"
data={studio}
/>
<Debug
label="Studio Info Permissions"
data={{canEditInfo}}
/>
</div>
);
};
StudioInfo.propTypes = {
canEditInfo: PropTypes.bool,
username: PropTypes.string,
token: PropTypes.string,
studio: PropTypes.shape({
......@@ -38,14 +45,12 @@ StudioInfo.propTypes = {
};
export default connect(
state => {
const user = state.session.session.user;
return {
state => ({
studio: state.studio,
username: user && user.username,
token: user && user.token
};
},
username: selectUsername(state),
token: selectToken(state),
canEditInfo: selectCanEditInfo(state)
}),
dispatch => ({
onLoadInfo: studioId => dispatch(getInfo(studioId)),
onLoadRoles: (studioId, username, token) => dispatch(
......
......@@ -7,10 +7,11 @@ import {projectFetcher} from './lib/fetchers';
import {projects} from './lib/redux-modules';
import Debug from './debug.jsx';
const {actions, selector} = projects;
const {actions, selector: projectsSelector} = projects;
import {selectCanAddProjects} from '../../redux/studio';
const StudioProjects = ({
items, error, loading, moreToLoad, onLoadMore
canAddProjects, items, error, loading, moreToLoad, onLoadMore
}) => {
const {studioId} = useParams();
......@@ -27,6 +28,10 @@ const StudioProjects = ({
label="Error"
data={error}
/>}
<Debug
label="Project Permissions"
data={{canAddProjects}}
/>
<div>
{items.map((item, index) =>
(<Debug
......@@ -48,6 +53,7 @@ const StudioProjects = ({
};
StudioProjects.propTypes = {
canAddProjects: PropTypes.bool,
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
......@@ -55,7 +61,10 @@ StudioProjects.propTypes = {
onLoadMore: PropTypes.func
};
const mapStateToProps = state => selector(state);
const mapStateToProps = state => ({
...projectsSelector(state),
canAddProjects: selectCanAddProjects(state)
});
const mapDispatchToProps = dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
......
......@@ -25,6 +25,7 @@ import {
} from './lib/redux-modules';
const {studioReducer} = require('../../redux/studio');
const {commentsReducer} = require('../../redux/comments');
const StudioShell = () => {
const match = useRouteMatch();
......@@ -75,6 +76,7 @@ render(
[curators.key]: curators.reducer,
[managers.key]: managers.reducer,
[activity.key]: activity.reducer,
studio: studioReducer
studio: studioReducer,
comments: commentsReducer
}
);
{
"studios": {
"isManager": {
"manager": true
},
"isCurator": {
"curator": true
},
"creator1": {
"owner": 1
},
"openToAll": {
"openToAll": true
}
},
"sessions": {
"user1Admin": {
"session": {
"user": {
"id": 1
},
"permissions": {
"admin": true
}
}
},
"user1Social": {
"session": {
"user": {
"id": 1
},
"permissions": {
"social": true
}
}
},
"user1": {
"session": {
"user": {
"id": 1,
"username": "user1-username",
"token": "user1-token"
},
"permissions": {
"admin": false,
"social": false
}
}
}
}
}
\ No newline at end of file
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();
});
......@@ -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();
});
import {
getInitialState, selectIsAdmin, selectIsSocial, selectUserId,
selectUsername, selectToken, sessionReducer, setSession
} from '../../../src/redux/session';
import {sessions} from '../../helpers/state-fixtures.json';
describe('session selectors', () => {
test('logged out', () => {
const state = {session: getInitialState()};
expect(selectIsAdmin(state)).toBe(false);
expect(selectIsSocial(state)).toBe(false);
expect(selectUserId(state)).toBeNaN();
expect(selectToken(state)).toBeNull();
expect(selectUsername(state)).toBeNull();
});
test('user data', () => {
let state = {session: getInitialState()};
const newSession = sessions.user1.session;
state.session = sessionReducer(state.session, setSession(newSession));
expect(selectUserId(state)).toBe(1);
expect(selectUsername(state)).toBe('user1-username');
expect(selectToken(state)).toBe('user1-token');
});
describe('permissions', () => {
test('selectIsAdmin', () => {
let state = {session: getInitialState()};
const newSession = sessions.user1Admin.session;
state.session = sessionReducer(state.session, setSession(newSession));
expect(selectIsAdmin(state)).toBe(true);
// Confirm that admin/social are totally separate and just read directly from the state
expect(selectIsSocial(state)).toBe(false);
});
test('selectIsSocial', () => {
let state = {session: getInitialState()};
const newSession = sessions.user1Social.session;
state.session = sessionReducer(state.session, setSession(newSession));
expect(selectIsSocial(state)).toBe(true);
// Confirm that admin/social are totally separate and just read directly from the state
expect(selectIsAdmin(state)).toBe(false);
});
});
});
import {
getInitialState as getInitialStudioState,
selectCanEditInfo,
selectCanAddProjects,
selectShowCommentComposer
} from '../../../src/redux/studio';
import {
getInitialState as getInitialSessionState
} from '../../../src/redux/session';
import {sessions, studios} from '../../helpers/state-fixtures.json';
describe('studio selectors', () => {
let state;
beforeEach(() => {
state = {
session: getInitialSessionState(),
studio: getInitialStudioState()
};
});
describe('studio info', () => {
test('is editable by admin', () => {
state.session = sessions.user1Admin;
expect(selectCanEditInfo(state)).toBe(true);
});
test('is editable by managers and studio creator', () => {
state.studio = studios.isManager;
expect(selectCanEditInfo(state)).toBe(true);
state.studio = studios.creator1;
state.session = sessions.user1;
expect(selectCanEditInfo(state)).toBe(true);
});
test('is not editable by curators', () => {
state.studio = studios.isCurator;
state.session = sessions.user1;
expect(selectCanEditInfo(state)).toBe(false);
});
test('is not editable by other logged in users', () => {
state.session = sessions.user1;
expect(selectCanEditInfo(state)).toBe(false);
});
test('is not editable by logged out users', () => {
expect(selectCanEditInfo(state)).toBe(false);
});
});
describe('studio projects', () => {
test('cannot be added by admin', () => {
state.session = sessions.user1Admin;
expect(selectCanAddProjects(state)).toBe(false);
});
test('can be added by managers and studio creator', () => {
state.studio = studios.isManager;
expect(selectCanAddProjects(state)).toBe(true);
state.studio = studios.creator1;
state.session = sessions.user1;
expect(selectCanAddProjects(state)).toBe(true);
});
test('can be added by curators', () => {
state.studio = studios.isCurator;
state.session = sessions.user1;
expect(selectCanAddProjects(state)).toBe(true);
});
test('can be added by social users if studio is openToAll', () => {
state.studio = studios.openToAll;
state.session = sessions.user1Social;
expect(selectCanAddProjects(state)).toBe(true);
});
test('cannot be added by social users if not openToAll', () => {
state.session = sessions.user1Social;
expect(selectCanAddProjects(state)).toBe(false);
});
});
describe('studio comments', () => {
test('show comment composer only for social users', () => {
expect(selectShowCommentComposer(state)).toBe(false);
state.session = sessions.user1;
expect(selectShowCommentComposer(state)).toBe(false);
state.session = sessions.user1Social;
expect(selectShowCommentComposer(state)).toBe(true);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment