Commit af7e8979 authored by Karishma Chadha's avatar Karishma Chadha

Merge branch 'develop' into release/2021-04-22

parents 15b31ae2 2736b94a
......@@ -123,15 +123,15 @@ jobs:
deploy-staging:
<<: *deploy
deploy-production:
# <<: *deploy
<<: *deploy
integration-staging-jest:
<<: *integration_jest
integration-staging-tap:
<<: *integration_tap
integration-production-jest:
# <<: *integration_jest
<<: *integration_jest
integration-production-tap:
# <<: *integration_tap
<<: *integration_tap
workflows:
build-test-deploy:
......@@ -166,6 +166,16 @@ workflows:
- develop
- /^hotfix\/.*/
- /^release\/.*/
- deploy-production:
context:
- scratch-www-all
- scratch-www-production
requires:
- build-production
filters:
branches:
only:
- master
- integration-staging-jest:
context:
- scratch-www-all
......@@ -190,3 +200,23 @@ workflows:
- develop
- /^hotfix\/.*/
- /^release\/.*/
- integration-production-jest:
context:
- scratch-www-all
- scratch-www-production
requires:
- deploy-production
filters:
branches:
only:
- master
- integration-production-tap:
context:
- scratch-www-all
- scratch-www-production
requires:
- deploy-production
filters:
branches:
only:
- master
......@@ -113,22 +113,10 @@ install:
jobs:
include:
- stage: test
deploy:
- provider: script
skip_cleanup: $SKIP_CLEANUP
script: npm run deploy
on:
repo: LLK/scratch-www
branch:
- master
- stage: smoke
script: npm run test:integration:remote
- stage: update translations
script: npm run i18n:push
stages:
- name: test
if: type != cron
- name: smoke
if: type NOT IN (cron, pull_request) AND (branch =~ /^(master)/)
- name: update translations
if: branch == develop AND type == cron
This diff is collapsed.
......@@ -367,6 +367,32 @@
"comments.muted.characterLimit": "500 characters max",
"comments.muted.feedbackEmpty": "Can't be empty",
"comment.type.general": "It appears that your most recent comment didn't follow the Scratch Community Guidelines.",
"comment.type.general.past": "It appears that one of your recent comments didn’t follow the Scratch Community Guidelines.",
"comment.general.header": "We encourage you to post comments that follow the Scratch Community Guidelines.",
"comment.general.content1": "On Scratch, it's important for comments to be kind, to be appropriate for all ages, and to not contain spam.",
"comment.type.pii": "Your most recent comment appeared to be sharing or asking for private information.",
"comment.type.pii.past": "It appears that one of your recent comments was sharing or asking for private information.",
"comment.pii.header": "Please be sure not to share private information on Scratch.",
"comment.pii.content1": "It appears that you were sharing or asking for private information.",
"comment.pii.content2": "Things you share on Scratch can be seen by everyone, and can appear in search engines. Private information can be used by other people in harmful ways, so it’s important to keep it private.",
"comment.pii.content3": "This is a serious safety issue.",
"comment.type.unconstructive": "It appears that your most recent comment was saying something that might have been hurtful.",
"comment.type.unconstructive.past": "It appears that one of your recent comments was saying something that might have been hurtful.",
"comment.unconstructive.header": "We encourage you to be supportive when commenting on other people’s projects",
"comment.unconstructive.content1": "It appears that your comment was saying something that might have been hurtful.",
"comment.unconstructive.content2": "If you think something could be better, you can say something you like about the project, and make a suggestion about how to improve it.",
"comment.type.vulgarity": "Your most recent comment appeared to include a bad word.",
"comment.type.vulgarity.past": "It appears that one of your recent comments contained a bad word.",
"comment.vulgarity.header": "We encourage you to use language that’s appropriate for all ages.",
"comment.vulgarity.content1": "It appears that your comment contains a bad word.",
"comment.vulgarity.content2": "Scratch has users of all ages, so it’s important to use language that is appropriate for all Scratchers.",
"comment.type.spam": "Your most recent comment appeared to contain advertising, text art, or a chain message.",
"comment.type.spam.past": "It appears that one of your recent comments contained advertising, text art, or a chain message.",
"comment.spam.header": "We encourage you not to advertise, copy and paste text art, or ask others to copy comments.",
"comment.spam.content1": "Even though advertisements, text art, and chain mail can be fun, they start to fill up the website, and we want to make sure there is room for other comments.",
"comment.spam.content2": "Thank you for helping us keep Scratch a friendly, creative community!",
"social.embedLabel": "Embed",
"social.copyEmbedLinkText": "Copy embed",
"social.linkLabel": "Link",
......
......@@ -121,7 +121,7 @@ module.exports.refreshSessionWithRetry = () => (dispatch => {
});
// Selectors
module.exports.selectIsLoggedIn = state => get(state, ['session', 'session', 'user'], false);
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);
......
/**
* Studio Mutation Reducer - Responsible for client-side modifications
* to studio info / roles. Stores in-progress and error states for updates,
* and handles the network requests.
*
* This reducer DOES NOT store the value of the field being mutated,
* or deal with loading that value initially from the server. That is
* handled by the studio info and roles reducer.
*/
const keyMirror = require('keymirror');
const api = require('../lib/api');
const {selectUsername} = require('./session');
const {selectStudioId, selectStudioImage, selectStudioOpenToAll, selectStudioCommentsAllowed} = require('./studio');
const Errors = keyMirror({
NETWORK: null,
SERVER: null,
INAPPROPRIATE: null,
PERMISSION: null,
THUMBNAIL_TOO_LARGE: null,
THUMBNAIL_MISSING: null,
TEXT_TOO_LONG: null,
REQUIRED_FIELD: null,
UNHANDLED: null
});
const getInitialState = () => ({
mutationErrors: {}, // { [field]: <error>, ... }
isMutating: {} // { [field]: <boolean>, ... }
});
const studioMutationsReducer = (state, action) => {
if (typeof state === 'undefined') {
state = getInitialState();
}
switch (action.type) {
case 'START_STUDIO_MUTATION':
return {
...state,
isMutating: {
...state.isMutating,
[action.field]: true
},
mutationErrors: {
...state.mutationErrors,
[action.field]: null
}
};
case 'COMPLETE_STUDIO_MUTATION':
return {
...state,
isMutating: {
...state.isMutating,
[action.field]: false
},
mutationErrors: {
...state.mutationErrors,
[action.field]: action.error
}
};
default:
return state;
}
};
// Action Creators
const startMutation = field => ({
type: 'START_STUDIO_MUTATION',
field
});
const completeMutation = (field, value, error = null) => ({
type: 'COMPLETE_STUDIO_MUTATION',
field,
value, // Value is used by other reducers listening for this action
error
});
// Selectors
const selectIsMutatingTitle = state => state.studioMutations.isMutating.title;
const selectIsMutatingDescription = state => state.studioMutations.isMutating.description;
const selectIsMutatingFollowing = state => state.studioMutations.isMutating.following;
const selectTitleMutationError = state => state.studioMutations.mutationErrors.title;
const selectDescriptionMutationError = state => state.studioMutations.mutationErrors.description;
const selectFollowingMutationError = state => state.studioMutations.mutationErrors.following;
const selectIsMutatingImage = state => state.studioMutations.isMutating.image;
const selectImageMutationError = state => state.studioMutations.mutationErrors.image;
const selectIsMutatingOpenToAll = state => state.studioMutations.isMutating.openToAll;
const selectOpenToAllMutationError = state => state.studioMutations.mutationErrors.openToAll;
const selectIsMutatingCommentsAllowed = state => state.studioMutations.isMutating.commentsAllowed;
const selectCommentsAllowedMutationError = state => state.studioMutations.mutationErrors.commentsAllowed;
// Thunks
/**
* Given a response from `api.js`, normalize the possible
* error conditions using the `Errors` object.
* @param {object} err - error from api.js
* @param {object} body - parsed body
* @param {object} res - raw response from api.js
* @returns {string} one of Errors.<TYPE> or null
*/
const normalizeError = (err, body, res) => {
if (err) return Errors.NETWORK;
if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION;
if (res.statusCode !== 200) return Errors.SERVER;
try {
if (body.errors.length > 0) {
switch (body.errors[0]) {
case 'inappropriate-generic': return Errors.INAPPROPRIATE;
case 'thumbnail-too-large': return Errors.THUMBNAIL_TOO_LARGE;
case 'thumbnail-missing': return Errors.THUMBNAIL_MISSING;
case 'editable-text-too-long': return Errors.TEXT_TOO_LONG;
case 'This field is required.': return Errors.REQUIRED_FIELD;
default: return Errors.UNHANDLED;
}
}
} catch (_) { /* No body.errors[], continue */ }
return null;
};
const mutateStudioTitle = value => ((dispatch, getState) => {
dispatch(startMutation('title'));
api({
host: '',
uri: `/site-api/galleries/all/${selectStudioId(getState())}/`,
method: 'PUT',
useCsrf: true,
json: {
title: value
}
}, (err, body, res) => {
const error = normalizeError(err, body, res);
dispatch(completeMutation('title', value, error));
});
});
const mutateStudioDescription = value => ((dispatch, getState) => {
dispatch(startMutation('description'));
api({
host: '',
uri: `/site-api/galleries/all/${selectStudioId(getState())}/`,
method: 'PUT',
useCsrf: true,
json: {
description: value
}
}, (err, body, res) => {
const error = normalizeError(err, body, res);
dispatch(completeMutation('description', value, error));
});
});
const mutateFollowingStudio = shouldFollow => ((dispatch, getState) => {
dispatch(startMutation('following'));
const state = getState();
const studioId = selectStudioId(state);
const username = selectUsername(state);
let uri = `/site-api/users/bookmarkers/${studioId}/`;
uri += shouldFollow ? 'add/' : 'remove/';
uri += `?usernames=${username}`;
api({
host: '',
uri: uri,
method: 'PUT',
useCsrf: true
}, (err, body, res) => {
const error = normalizeError(err, body, res);
dispatch(completeMutation('following', error ? !shouldFollow : shouldFollow, error));
});
});
const mutateStudioImage = input => ((dispatch, getState) => {
const state = getState();
const studioId = selectStudioId(state);
const currentImage = selectStudioImage(state);
dispatch(startMutation('image'));
const formData = new FormData();
formData.append('file', input.files[0]);
api({
host: '',
uri: `/site-api/galleries/all/${studioId}/`,
method: 'POST',
withCredentials: true,
useCsrf: true,
body: formData
}, (err, body, res) => {
const error = normalizeError(err, body, res);
dispatch(completeMutation('image', error ? currentImage : body.thumbnail_url, error));
});
});
const mutateStudioCommentsAllowed = shouldAllow => ((dispatch, getState) => {
dispatch(startMutation('commentsAllowed'));
const state = getState();
const studioId = selectStudioId(state);
api({
host: '',
uri: `/site-api/comments/gallery/${studioId}/toggle-comments/`,
method: 'POST',
useCsrf: true
}, (err, body, res) => {
const error = normalizeError(err, body, res);
const wasAllowed = selectStudioCommentsAllowed(state);
dispatch(completeMutation('commentsAllowed', error ? wasAllowed : shouldAllow, error));
});
});
const mutateStudioOpenToAll = shouldBeOpen => ((dispatch, getState) => {
dispatch(startMutation('openToAll'));
const state = getState();
const studioId = selectStudioId(state);
api({
host: '',
uri: `/site-api/galleries/${studioId}/mark/${shouldBeOpen ? 'open' : 'closed'}/`,
method: 'PUT',
useCsrf: true
}, (err, body, res) => {
const error = normalizeError(err, body, res);
const wasOpen = selectStudioOpenToAll(getState());
dispatch(completeMutation('openToAll', error ? wasOpen : shouldBeOpen, error));
});
});
module.exports = {
getInitialState,
studioMutationsReducer,
Errors,
// Thunks
mutateStudioTitle,
mutateStudioDescription,
mutateFollowingStudio,
mutateStudioImage,
mutateStudioCommentsAllowed,
mutateStudioOpenToAll,
// Selectors
selectIsMutatingTitle,
selectIsMutatingDescription,
selectIsMutatingFollowing,
selectTitleMutationError,
selectDescriptionMutationError,
selectFollowingMutationError,
selectIsMutatingImage,
selectImageMutationError,
selectIsMutatingCommentsAllowed,
selectCommentsAllowedMutationError,
selectIsMutatingOpenToAll,
selectOpenToAllMutationError
};
const {selectUserId, selectIsAdmin, selectIsSocial, selectIsLoggedIn} = require('./session');
// 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);
const selectCanReportComment = state => selectIsSocial(state);
const selectCanRestoreComment = state => selectIsAdmin(state);
// On the project page, project owners can delete comments with a confirmation,
// and admins can delete comments without a confirmation. For now, only admins
// can delete studio comments, so the following two are the same.
const selectCanDeleteComment = state => selectIsAdmin(state);
const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state);
const selectCanFollowStudio = state => selectIsLoggedIn(state);
// Matching existing behavior, only admin/creator is allowed to toggle comments.
const selectCanEditCommentsAllowed = state => selectIsAdmin(state) || isCreator(state);
const selectCanEditOpenToAll = state => isManager(state);
export {
selectCanEditInfo,
selectCanAddProjects,
selectCanFollowStudio,
selectShowCommentComposer,
selectCanDeleteComment,
selectCanDeleteCommentWithoutConfirm,
selectCanReportComment,
selectCanRestoreComment,
selectCanEditCommentsAllowed,
selectCanEditOpenToAll
};
......@@ -3,7 +3,7 @@ const keyMirror = require('keymirror');
const api = require('../lib/api');
const log = require('../lib/log');
const {selectUserId, selectIsAdmin, selectIsSocial, selectUsername, selectToken} = require('./session');
const {selectUsername, selectToken} = require('./session');
const Status = keyMirror({
FETCHED: null,
......@@ -17,15 +17,15 @@ const getInitialState = () => ({
title: '',
description: '',
openToAll: false,
commentingAllowed: false,
thumbnail: '',
commentsAllowed: false,
image: '',
followers: 0,
owner: null,
rolesStatus: Status.NOT_FETCHED,
manager: false,
curator: false,
follower: false,
following: false,
invited: false
});
......@@ -53,13 +53,18 @@ const studioReducer = (state, action) => {
...state,
[action.fetchType]: action.fetchStatus
};
case 'COMPLETE_STUDIO_MUTATION':
if (typeof state[action.field] === 'undefined') return state;
return {
...state,
[action.field]: action.value
};
default:
return state;
}
};
// Action Creators
const setFetchStatus = (fetchType, fetchStatus, error) => ({
type: 'SET_FETCH_STATUS',
fetchType,
......@@ -77,32 +82,16 @@ const setRoles = roles => ({
roles: roles
});
// 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);
const selectShowCommentComposer = state => selectIsSocial(state);
const selectCanReportComment = state => selectIsSocial(state);
const selectCanRestoreComment = state => selectIsAdmin(state);
// On the project page, project owners can delete comments with a confirmation,
// and admins can delete comments without a confirmation. For now, only admins
// can delete studio comments, so the following two are the same.
const selectCanDeleteComment = state => selectIsAdmin(state);
const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state);
// Data selectors
const selectStudioId = state => state.studio.id;
const selectStudioTitle = state => state.studio.title;
const selectStudioDescription = state => state.studio.description;
const selectStudioImage = state => state.studio.image;
const selectStudioOpenToAll = state => state.studio.openToAll;
const selectStudioCommentsAllowed = state => state.studio.commentsAllowed;
const selectIsFetchingInfo = state => state.studio.infoStatus === Status.FETCHING;
const selectIsFollowing = state => state.studio.following;
const selectIsFetchingRoles = state => state.studio.rolesStatus === Status.FETCHING;
// Thunks
const getInfo = () => ((dispatch, getState) => {
......@@ -117,8 +106,9 @@ const getInfo = () => ((dispatch, getState) => {
dispatch(setInfo({
title: body.title,
description: body.description,
image: body.image,
openToAll: body.open_to_all,
commentingAllowed: body.commenting_allowed,
commentsAllowed: body.comments_allowed,
updated: new Date(body.history.modified),
followers: body.stats.followers,
owner: body.owner
......@@ -158,14 +148,16 @@ module.exports = {
// Thunks
getInfo,
getRoles,
setInfo,
// Selectors
selectStudioId,
selectCanEditInfo,
selectCanAddProjects,
selectShowCommentComposer,
selectCanDeleteComment,
selectCanDeleteCommentWithoutConfirm,
selectCanReportComment,
selectCanRestoreComment
selectStudioTitle,
selectStudioDescription,
selectStudioImage,
selectStudioOpenToAll,
selectStudioCommentsAllowed,
selectIsFetchingInfo,
selectIsFetchingRoles,
selectIsFollowing
};
......@@ -45,30 +45,5 @@
"project.cloudVariables": "Cloud Variables",
"project.cloudDataLink": "See Data",
"project.usernameBlockAlert": "This project can detect who is using it, through the \"username\" block. To hide your identity, sign out before using the project.",
"project.inappropriateUpdate": "Hmm...the bad word detector thinks there is a problem with your text. Please change it and remember to be respectful.",
"comment.type.general": "It appears that your most recent comment didn't follow the Scratch Community Guidelines.",
"comment.type.general.past": "It appears that one of your recent comments didn’t follow the Scratch Community Guidelines.",
"comment.general.header": "We encourage you to post comments that follow the Scratch Community Guidelines.",
"comment.general.content1": "On Scratch, it's important for comments to be kind, to be appropriate for all ages, and to not contain spam.",
"comment.type.pii": "Your most recent comment appeared to be sharing or asking for private information.",
"comment.type.pii.past": "It appears that one of your recent comments was sharing or asking for private information.",
"comment.pii.header": "Please be sure not to share private information on Scratch.",
"comment.pii.content1": "It appears that you were sharing or asking for private information.",
"comment.pii.content2": "Things you share on Scratch can be seen by everyone, and can appear in search engines. Private information can be used by other people in harmful ways, so it’s important to keep it private.",
"comment.pii.content3": "This is a serious safety issue.",
"comment.type.unconstructive": "It appears that your most recent comment was saying something that might have been hurtful.",
"comment.type.unconstructive.past": "It appears that one of your recent comments was saying something that might have been hurtful.",
"comment.unconstructive.header": "We encourage you to be supportive when commenting on other people’s projects",
"comment.unconstructive.content1": "It appears that your comment was saying something that might have been hurtful.",
"comment.unconstructive.content2": "If you think something could be better, you can say something you like about the project, and make a suggestion about how to improve it.",
"comment.type.vulgarity": "Your most recent comment appeared to include a bad word.",
"comment.type.vulgarity.past": "It appears that one of your recent comments contained a bad word.",
"comment.vulgarity.header": "We encourage you to use language that’s appropriate for all ages.",
"comment.vulgarity.content1": "It appears that your comment contains a bad word.",
"comment.vulgarity.content2": "Scratch has users of all ages, so it’s important to use language that is appropriate for all Scratchers.",
"comment.type.spam": "Your most recent comment appeared to contain advertising, text art, or a chain message.",
"comment.type.spam.past": "It appears that one of your recent comments contained advertising, text art, or a chain message.",
"comment.spam.header": "We encourage you not to advertise, copy and paste text art, or ask others to copy comments.",
"comment.spam.content1": "Even though advertisements, text art, and chain mail can be fun, they start to fill up the website, and we want to make sure there is room for other comments.",
"comment.spam.content2": "Thank you for helping us keep Scratch a friendly, creative community!"
"project.inappropriateUpdate": "Hmm...the bad word detector thinks there is a problem with your text. Please change it and remember to be respectful."
}
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectStudioCommentsAllowed, selectIsFetchingInfo} from '../../redux/studio';
import {
mutateStudioCommentsAllowed, selectIsMutatingCommentsAllowed, selectCommentsAllowedMutationError
} from '../../redux/studio-mutations';
const StudioCommentsAllowed = ({
commentsAllowedError, isFetching, isMutating, commentsAllowed, handleUpdate
}) => (
<div>
{isFetching ? (
<h4>Fetching...</h4>
) : (
<div>
<label>
<input
disabled={isMutating}
type="checkbox"
checked={commentsAllowed}
onChange={e => handleUpdate(e.target.checked)}
/>
<span>{commentsAllowed ? 'Comments allowed' : 'Comments not allowed'}</span>
{commentsAllowedError && <div>Error mutating commentsAllowed: {commentsAllowedError}</div>}
</label>
</div>
)}
</div>
);
StudioCommentsAllowed.propTypes = {
commentsAllowedError: PropTypes.string,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
commentsAllowed: PropTypes.bool,
handleUpdate: PropTypes.func
};
export default connect(
state => ({
commentsAllowed: selectStudioCommentsAllowed(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingCommentsAllowed(state),
commentsAllowedError: selectCommentsAllowedMutationError(state)
}),
{
handleUpdate: mutateStudioCommentsAllowed
}
)(StudioCommentsAllowed);
......@@ -7,17 +7,21 @@ 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 StudioCommentsAllowed from './studio-comments-allowed.jsx';
import {
selectShowCommentComposer,
selectCanDeleteComment,
selectCanDeleteCommentWithoutConfirm,
selectCanReportComment,
selectCanRestoreComment
} from '../../redux/studio.js';
selectCanRestoreComment,
selectCanEditCommentsAllowed
} from '../../redux/studio-permissions';
import {selectStudioCommentsAllowed} from '../../redux/studio.js';
const StudioComments = ({
comments,
commentsAllowed,
handleLoadMoreComments,
handleNewComment,
moreCommentsToLoad,
......@@ -26,6 +30,7 @@ const StudioComments = ({
shouldShowCommentComposer,
canDeleteComment,
canDeleteCommentWithoutConfirm,
canEditCommentsAllowed,
canReportComment,
canRestoreComment,
handleDeleteComment,
......@@ -40,8 +45,9 @@ const StudioComments = ({
return (
<div>
<h2>Comments</h2>
{canEditCommentsAllowed && <StudioCommentsAllowed />}
<div>
{shouldShowCommentComposer &&
{shouldShowCommentComposer && commentsAllowed &&
<ComposeComment
postURI={postURI}
onAddComment={handleNewComment}
......@@ -86,6 +92,7 @@ const StudioComments = ({
StudioComments.propTypes = {
comments: PropTypes.arrayOf(PropTypes.shape({})),
commentsAllowed: PropTypes.bool,
handleLoadMoreComments: PropTypes.func,
handleNewComment: PropTypes.func,
moreCommentsToLoad: PropTypes.bool,
......@@ -93,6 +100,7 @@ StudioComments.propTypes = {
shouldShowCommentComposer: PropTypes.bool,
canDeleteComment: PropTypes.bool,
canDeleteCommentWithoutConfirm: PropTypes.bool,
canEditCommentsAllowed: PropTypes.bool,
canReportComment: PropTypes.bool,
canRestoreComment: PropTypes.bool,
handleDeleteComment: PropTypes.func,
......@@ -107,9 +115,11 @@ export default connect(
comments: state.comments.comments,
moreCommentsToLoad: state.comments.moreCommentsToLoad,
replies: state.comments.replies,
commentsAllowed: selectStudioCommentsAllowed(state),
shouldShowCommentComposer: selectShowCommentComposer(state),
canDeleteComment: selectCanDeleteComment(state),
canDeleteCommentWithoutConfirm: selectCanDeleteCommentWithoutConfirm(state),
canEditCommentsAllowed: selectCanEditCommentsAllowed(state),
canReportComment: selectCanReportComment(state),
canRestoreComment: selectCanRestoreComment(state),
postURI: `/proxy/comments/studio/${state.studio.id}`
......
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectStudioDescription, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {
mutateStudioDescription, selectIsMutatingDescription, selectDescriptionMutationError
} from '../../redux/studio-mutations';
const StudioDescription = ({
descriptionError, isFetching, isMutating, description, canEditInfo, handleUpdate
}) => (
<div>
<h3>Description</h3>
{isFetching ? (
<h4>Fetching...</h4>
) : (canEditInfo ? (
<label>
<textarea
rows="5"
cols="100"
disabled={isMutating}
defaultValue={description}
onBlur={e => e.target.value !== description &&
handleUpdate(e.target.value)}
/>
{descriptionError && <div>Error mutating description: {descriptionError}</div>}
</label>
) : (
<div>{description}</div>
))}
</div>
);
StudioDescription.propTypes = {
descriptionError: PropTypes.string,
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
description: PropTypes.string,
handleUpdate: PropTypes.func
};
export default connect(
state => ({
description: selectStudioDescription(state),
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingDescription(state),
descriptionError: selectDescriptionMutationError(state)
}),
{
handleUpdate: mutateStudioDescription
}
)(StudioDescription);
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectIsFollowing, selectIsFetchingRoles} from '../../redux/studio';
import {selectCanFollowStudio} from '../../redux/studio-permissions';
import {
mutateFollowingStudio, selectIsMutatingFollowing, selectFollowingMutationError
} from '../../redux/studio-mutations';
const StudioFollow = ({
canFollow,
isFetching,
isFollowing,
isMutating,
followingError,
handleFollow
}) => (
<div>
<h3>Following</h3>
<div>
<button
disabled={isFetching || isMutating || !canFollow}
onClick={() => handleFollow(!isFollowing)}
>
{isFetching ? (
'Fetching...'
) : (
isFollowing ? 'Unfollow' : 'Follow'
)}
</button>
{followingError && <div>Error mutating following: {followingError}</div>}
{!canFollow && <div>Must be logged in to follow</div>}
</div>
</div>
);
StudioFollow.propTypes = {
canFollow: PropTypes.bool,
isFetching: PropTypes.bool,
isFollowing: PropTypes.bool,
isMutating: PropTypes.bool,
followingError: PropTypes.string,
handleFollow: PropTypes.func
};
export default connect(
state => ({
canFollow: selectCanFollowStudio(state),
isFetching: selectIsFetchingRoles(state),
isMutating: selectIsMutatingFollowing(state),
isFollowing: selectIsFollowing(state),
followingError: selectFollowingMutationError(state)
}),
{
handleFollow: mutateFollowingStudio
}
)(StudioFollow);
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectStudioImage, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {
mutateStudioImage, selectIsMutatingImage, selectImageMutationError
} from '../../redux/studio-mutations';
import Spinner from '../../components/spinner/spinner.jsx';
const StudioImage = ({
imageError, isFetching, isMutating, image, canEditInfo, handleUpdate
}) => (
<div>
<h3>Image</h3>
{isFetching ? (
<h4>Fetching...</h4>
) : (
<div>
<div style={{width: '200px', height: '150px', border: '1px solid green'}}>
{isMutating ?
<Spinner color="blue" /> :
<img
style={{objectFit: 'contain'}}
src={image}
/>}
</div>
{canEditInfo &&
<label>
<input
disabled={isMutating}
type="file"
accept="image/*"
onChange={e => {
handleUpdate(e.target);
e.target.value = '';
}}
/>
{imageError && <div>Error mutating image: {imageError}</div>}
</label>
}
</div>
)}
</div>
);
StudioImage.propTypes = {
imageError: PropTypes.string,
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
image: PropTypes.string,
handleUpdate: PropTypes.func
};
export default connect(
state => ({
image: selectStudioImage(state),
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingImage(state),
imageError: selectImageMutationError(state)
}),
{
handleUpdate: mutateStudioImage
}
)(StudioImage);
......@@ -2,11 +2,17 @@ import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import Debug from './debug.jsx';
import StudioDescription from './studio-description.jsx';
import StudioFollow from './studio-follow.jsx';
import StudioTitle from './studio-title.jsx';
import StudioImage from './studio-image.jsx';
import {selectIsLoggedIn} from '../../redux/session';
import {getInfo, getRoles, selectCanEditInfo} from '../../redux/studio';
import {getInfo, getRoles} from '../../redux/studio';
const StudioInfo = ({isLoggedIn, studio, canEditInfo, onLoadInfo, onLoadRoles}) => {
const StudioInfo = ({
isLoggedIn, studio, onLoadInfo, onLoadRoles
}) => {
useEffect(() => { // Load studio info after first render
onLoadInfo();
}, []);
......@@ -18,24 +24,21 @@ const StudioInfo = ({isLoggedIn, studio, canEditInfo, onLoadInfo, onLoadRoles})
return (
<div>
<h2>Studio Info</h2>
<StudioTitle />
<StudioDescription />
<StudioFollow />
<StudioImage />
<Debug
label="Studio Info"
data={studio}
/>
<Debug
label="Studio Info Permissions"
data={{canEditInfo}}
/>
</div>
);
};
StudioInfo.propTypes = {
canEditInfo: PropTypes.bool,
isLoggedIn: PropTypes.bool,
studio: PropTypes.shape({
// Fill this in as the data is used, just for demo now
}),
studio: PropTypes.shape({}), // TODO remove, just for <Debug />
onLoadInfo: PropTypes.func,
onLoadRoles: PropTypes.func
};
......@@ -43,8 +46,7 @@ StudioInfo.propTypes = {
export default connect(
state => ({
studio: state.studio,
isLoggedIn: selectIsLoggedIn(state),
canEditInfo: selectCanEditInfo(state)
isLoggedIn: selectIsLoggedIn(state)
}),
{
onLoadInfo: getInfo,
......
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectStudioOpenToAll, selectIsFetchingInfo} from '../../redux/studio';
import {
mutateStudioOpenToAll, selectIsMutatingOpenToAll, selectOpenToAllMutationError
} from '../../redux/studio-mutations';
const StudioOpenToAll = ({
openToAllError, isFetching, isMutating, openToAll, handleUpdate
}) => (
<div>
{isFetching ? (
<h4>Fetching...</h4>
) : (
<div>
<label>
<input
disabled={isMutating}
type="checkbox"
checked={openToAll}
onChange={e => handleUpdate(e.target.checked)}
/>
<span>{openToAll ? 'Open to all' : 'Not open to all'}</span>
{openToAllError && <div>Error mutating openToAll: {openToAllError}</div>}
</label>
</div>
)}
</div>
);
StudioOpenToAll.propTypes = {
openToAllError: PropTypes.string,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
openToAll: PropTypes.bool,
handleUpdate: PropTypes.func
};
export default connect(
state => ({
openToAll: selectStudioOpenToAll(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingOpenToAll(state),
openToAllError: selectOpenToAllMutationError(state)
}),
{
handleUpdate: mutateStudioOpenToAll
}
)(StudioOpenToAll);
......@@ -2,16 +2,17 @@ import React, {useEffect, useCallback} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import StudioOpenToAll from './studio-open-to-all.jsx';
import {projectFetcher} from './lib/fetchers';
import {projects} from './lib/redux-modules';
import {selectCanAddProjects, selectCanEditOpenToAll} from '../../redux/studio-permissions';
import Debug from './debug.jsx';
const {actions, selector: projectsSelector} = projects;
import {selectCanAddProjects} from '../../redux/studio';
const StudioProjects = ({
canAddProjects, items, error, loading, moreToLoad, onLoadMore
canAddProjects, canEditOpenToAll, items, error, loading, moreToLoad, onLoadMore
}) => {
const {studioId} = useParams();
......@@ -24,6 +25,7 @@ const StudioProjects = ({
return (
<div>
<h2>Projects</h2>
{canEditOpenToAll && <StudioOpenToAll />}
{error && <Debug
label="Error"
data={error}
......@@ -54,6 +56,7 @@ const StudioProjects = ({
StudioProjects.propTypes = {
canAddProjects: PropTypes.bool,
canEditOpenToAll: 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
......@@ -63,7 +66,8 @@ StudioProjects.propTypes = {
const mapStateToProps = state => ({
...projectsSelector(state),
canAddProjects: selectCanAddProjects(state)
canAddProjects: selectCanAddProjects(state),
canEditOpenToAll: selectCanEditOpenToAll(state)
});
const mapDispatchToProps = dispatch => ({
......
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectStudioTitle, selectIsFetchingInfo} from '../../redux/studio';
import {selectCanEditInfo} from '../../redux/studio-permissions';
import {mutateStudioTitle, selectIsMutatingTitle, selectTitleMutationError} from '../../redux/studio-mutations';
const StudioTitle = ({
titleError, isFetching, isMutating, title, canEditInfo, handleUpdate
}) => (
<div>
<h3>Title</h3>
{isFetching ? (
<h4>Fetching...</h4>
) : (canEditInfo ? (
<label>
<input
disabled={isMutating}
defaultValue={title}
onBlur={e => e.target.value !== title &&
handleUpdate(e.target.value)}
/>
{titleError && <div>Error mutating title: {titleError}</div>}
</label>
) : (
<div>{title}</div>
))}
</div>
);
StudioTitle.propTypes = {
titleError: PropTypes.string,
canEditInfo: PropTypes.bool,
isFetching: PropTypes.bool,
isMutating: PropTypes.bool,
title: PropTypes.string,
handleUpdate: PropTypes.func
};
export default connect(
state => ({
title: selectStudioTitle(state),
canEditInfo: selectCanEditInfo(state),
isFetching: selectIsFetchingInfo(state),
isMutating: selectIsMutatingTitle(state),
titleError: selectTitleMutationError(state)
}),
{
handleUpdate: mutateStudioTitle
}
)(StudioTitle);
......@@ -24,8 +24,9 @@ import {
activity
} from './lib/redux-modules';
const {studioReducer} = require('../../redux/studio');
const {getInitialState, studioReducer} = require('../../redux/studio');
const {commentsReducer} = require('../../redux/comments');
const {studioMutationsReducer} = require('../../redux/studio-mutations');
const StudioShell = () => {
const match = useRouteMatch();
......@@ -77,10 +78,12 @@ render(
[managers.key]: managers.reducer,
[activity.key]: activity.reducer,
studio: studioReducer,
studioMutations: studioMutationsReducer,
comments: commentsReducer
},
{
studio: {
...getInitialState(),
// Include the studio id in the initial state to allow us
// to stop passing around the studio id in components
// when it is only needed for data fetching, not for rendering.
......
import {
getInitialState, selectIsAdmin, selectIsSocial, selectUserId,
selectUsername, selectToken, sessionReducer, setSession
selectIsLoggedIn, selectUsername, selectToken, sessionReducer, setSession
} from '../../../src/redux/session';
import {sessions} from '../../helpers/state-fixtures.json';
......@@ -10,6 +10,7 @@ describe('session selectors', () => {
const state = {session: getInitialState()};
expect(selectIsAdmin(state)).toBe(false);
expect(selectIsSocial(state)).toBe(false);
expect(selectIsLoggedIn(state)).toBe(false);
expect(selectUserId(state)).toBeNaN();
expect(selectToken(state)).toBeNull();
expect(selectUsername(state)).toBeNull();
......@@ -22,6 +23,7 @@ describe('session selectors', () => {
expect(selectUserId(state)).toBe(1);
expect(selectUsername(state)).toBe('user1-username');
expect(selectToken(state)).toBe('user1-token');
expect(selectIsLoggedIn(state)).toBe(true);
});
describe('permissions', () => {
......
import {
getInitialState as getInitialStudioState,
selectCanEditInfo,
selectCanAddProjects,
selectShowCommentComposer,
selectCanDeleteComment,
selectCanDeleteCommentWithoutConfirm,
selectCanReportComment,
selectCanRestoreComment
} from '../../../src/redux/studio';
import {
getInitialState as getInitialSessionState
} from '../../../src/redux/session';
selectCanRestoreComment,
selectCanFollowStudio,
selectCanEditCommentsAllowed,
selectCanEditOpenToAll
} from '../../../src/redux/studio-permissions';
import {getInitialState as getInitialStudioState} from '../../../src/redux/studio';
import {getInitialState as getInitialSessionState} from '../../../src/redux/session';
import {sessions, studios} from '../../helpers/state-fixtures.json';
let state;
......@@ -167,4 +167,45 @@ describe('studio comments', () => {
expect(selectCanRestoreComment(state)).toBe(expected);
});
});
describe('can follow a studio', () => {
test.each([
['logged in', true],
['unconfirmed', true],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanFollowStudio(state)).toBe(expected);
});
});
describe('can set "comments allowed" on a studio', () => {
test.each([
['admin', true],
['curator', false],
['manager', false],
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditCommentsAllowed(state)).toBe(expected);
});
});
describe('can set "open to all" on a studio', () => {
test.each([
['admin', false],
['curator', false],
['manager', true],
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanEditOpenToAll(state)).toBe(expected);
});
});
});
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