Unverified Commit 3cc39974 authored by Eric Rosenbaum's avatar Eric Rosenbaum Committed by GitHub

Merge pull request #5339 from LLK/develop

merge from develop to release branch
parents f3abaf5a cb7c60e8
......@@ -114,6 +114,16 @@ aliases:
npm run test:smoke:convertReportToXunit
- store_test_results:
path: test/results
- &update-translations
<<: *defaults
steps:
- checkout
- run:
name: "setup"
command: npm --production=false ci
- run:
name: "run i18n script"
command: npm run i18n:push
jobs:
build-staging:
......@@ -132,6 +142,8 @@ jobs:
<<: *integration_jest
integration-production-tap:
<<: *integration_tap
update-translations:
<<: *update-translations
workflows:
build-test-deploy:
......@@ -220,3 +232,19 @@ workflows:
branches:
only:
- master
Update-translations:
triggers:
- schedule: # every evening at 7pm EST (8pm EDT, Midnight UTC)
cron: "0 0 * * *"
filters:
branches:
only: develop
jobs:
- update-translations:
context:
- scratch-www-all
- scratch-www-staging
filters:
branches:
only:
- develop
This diff is collapsed.
......@@ -13,9 +13,10 @@ const Types = keyMirror({
});
const banWhitelistPaths = [
'/accounts/banned-response/',
'/community_guidelines/',
'/community_guidelines'
'/accounts/banned-response',
'/community_guidelines',
'/privacy_policy',
'/terms_of_use'
];
module.exports.Status = keyMirror({
......
const {selectUserId, selectIsAdmin, selectIsSocial, selectIsLoggedIn} = require('./session');
const {selectUserId, selectIsAdmin, selectIsSocial, selectIsLoggedIn, selectUsername} = require('./session');
// Fine-grain selector helpers - not exported, use the higher level selectors below
const isCreator = state => selectUserId(state) === state.studio.owner;
......@@ -29,6 +29,27 @@ const selectCanFollowStudio = state => selectIsLoggedIn(state);
const selectCanEditCommentsAllowed = state => selectIsAdmin(state) || isCreator(state);
const selectCanEditOpenToAll = state => isManager(state);
const selectShowCuratorInvite = state => !!state.studio.invited;
const selectCanInviteCurators = state => isManager(state);
const selectCanRemoveCurators = state => isManager(state) || selectIsAdmin(state);
const selectCanRemoveManager = (state, managerId) =>
(selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner;
const selectCanPromoteCurators = state => isManager(state);
const selectCanRemoveProject = (state, creatorUsername, actorId) => {
// Admins/managers can remove any projects
if (isManager(state) || selectIsAdmin(state)) return true;
// Project owners can always remove their projects
if (selectUsername(state) === creatorUsername) {
return true;
}
// Curators can remove projects they added
if (isCurator(state)) {
return selectUserId(state) === actorId;
}
return false;
};
export {
selectCanEditInfo,
selectCanAddProjects,
......@@ -39,5 +60,11 @@ export {
selectCanReportComment,
selectCanRestoreComment,
selectCanEditCommentsAllowed,
selectCanEditOpenToAll
selectCanEditOpenToAll,
selectShowCuratorInvite,
selectCanInviteCurators,
selectCanRemoveCurators,
selectCanRemoveManager,
selectCanPromoteCurators,
selectCanRemoveProject
};
const keyMirror = require('keymirror');
const api = require('../lib/api');
const {selectIsLoggedIn} = require('./session');
const {selectStudioId} = require('./studio');
const Actions = keyMirror({
OPEN_STUDIO_REPORT: null,
CLOSE_STUDIO_REPORT: null,
SET_STUDIO_REPORT_STATUS: null,
SET_STUDIO_REPORT_FIELD: null
});
const Status = keyMirror({
IDLE: null,
SUBMITTING: null,
SUBMITTED: null
});
const Fields = {
TITLE: 'title',
DESCRIPTION: 'description',
THUMBNAIL: 'thumbnail'
};
const Errors = keyMirror({
GENERIC: null
});
const getInitialState = () => ({
status: Status.IDLE,
field: Fields.TITLE,
error: null,
isOpen: false
});
const studioReportReducer = (state, action) => {
if (typeof state === 'undefined') {
state = getInitialState();
}
switch (action.type) {
case Actions.OPEN_STUDIO_REPORT:
return {
...state,
isOpen: true
};
case Actions.CLOSE_STUDIO_REPORT:
return {
...state, // Leaves the submitted status to prevent double submission
isOpen: false
};
case Actions.SET_STUDIO_REPORT_STATUS:
return {
...state,
status: action.status,
error: typeof action.error === 'undefined' ? null : action.error
};
case Actions.SET_STUDIO_REPORT_FIELD:
return {
...state,
field: action.field
};
default:
return state;
}
};
// Selectors
const selectStudioReportField = state => state.studioReport.field;
const selectStudioReportOpen = state => state.studioReport.isOpen;
const selectStudioReportSubmitting = state => state.studioReport.status === Status.SUBMITTING;
const selectStudioReportSubmitted = state => state.studioReport.status === Status.SUBMITTED;
const selectStudioReportError = state => state.studioReport.error;
const selectCanReportStudio = state => !!selectIsLoggedIn(state); // TODO selectIsLoggedIn isn't returning bool?
// Action Creators
const setReportStatus = (status, error) => ({
type: Actions.SET_STUDIO_REPORT_STATUS,
status,
error
});
const openStudioReport = () => ({
type: Actions.OPEN_STUDIO_REPORT
});
const closeStudioReport = () => ({
type: Actions.CLOSE_STUDIO_REPORT
});
const setStudioReportField = field => ({
type: Actions.SET_STUDIO_REPORT_FIELD,
field
});
const submitStudioReport = () => ((dispatch, getState) => {
dispatch(setReportStatus(Status.SUBMITTING));
const studioId = selectStudioId(getState());
const field = selectStudioReportField(getState());
api({
host: '',
uri: `/site-api/galleries/all/${studioId}/report/`,
method: 'POST',
useCsrf: true,
formData: {
selected_field: field
}
}, (err, body, res) => {
if (err || (body && body.success === false) || res.statusCode !== 200) {
dispatch(setReportStatus(Status.IDLE, Errors.GENERIC));
return;
}
dispatch(setReportStatus(Status.SUBMITTED));
});
});
module.exports = {
Errors,
Fields,
getInitialState,
studioReportReducer,
actions: {
openStudioReport,
closeStudioReport,
setStudioReportField,
submitStudioReport
},
selectors: {
selectStudioReportField,
selectStudioReportOpen,
selectCanReportStudio,
selectStudioReportSubmitting,
selectStudioReportSubmitted,
selectStudioReportError
}
};
......@@ -13,7 +13,7 @@ const Status = keyMirror({
});
const getInitialState = () => ({
infoStatus: Status.NOT_FETCHED,
infoStatus: Status.FETCHING,
title: '',
description: '',
openToAll: false,
......@@ -38,12 +38,14 @@ const studioReducer = (state, action) => {
case 'SET_INFO':
return {
...state,
...action.info
...action.info,
infoStatus: Status.FETCHED
};
case 'SET_ROLES':
return {
...state,
...action.roles
...action.roles,
rolesStatus: Status.FETCHED
};
case 'SET_FETCH_STATUS':
if (action.error) {
......@@ -95,14 +97,12 @@ const selectIsFetchingRoles = state => state.studio.rolesStatus === Status.FETCH
// Thunks
const getInfo = () => ((dispatch, getState) => {
dispatch(setFetchStatus('infoStatus', Status.FETCHING));
const studioId = selectStudioId(getState());
api({uri: `/studios/${studioId}`}, (err, body, res) => {
if (err || typeof body === 'undefined' || res.statusCode !== 200) {
dispatch(setFetchStatus('infoStatus', Status.ERROR, err));
return;
}
dispatch(setFetchStatus('infoStatus', Status.FETCHED));
dispatch(setInfo({
title: body.title,
description: body.description,
......@@ -130,7 +130,6 @@ const getRoles = () => ((dispatch, getState) => {
dispatch(setFetchStatus('rolesStatus', Status.ERROR, err));
return;
}
dispatch(setFetchStatus('rolesStatus', Status.FETCHED));
dispatch(setRoles({
manager: body.manager,
curator: body.curator,
......@@ -149,6 +148,7 @@ module.exports = {
getInfo,
getRoles,
setInfo,
setRoles,
// Selectors
selectStudioId,
......
const ITEM_LIMIT = 4;
const projectFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/projects?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const curatorFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/curators?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
const managerFetcher = (studioId, offset) =>
fetch(`${process.env.API_HOST}/studios/${studioId}/managers?limit=${ITEM_LIMIT}&offset=${offset}`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: data.length === ITEM_LIMIT}));
// TODO move this to studio-activity-actions, include pagination
const activityFetcher = studioId =>
fetch(`${process.env.API_HOST}/studios/${studioId}/activity`)
.then(response => response.json())
.then(data => ({items: data, moreToLoad: false})); // No pagination on the activity feed
export {
activityFetcher,
projectFetcher,
curatorFetcher,
managerFetcher
activityFetcher
};
import keyMirror from 'keymirror';
import api from '../../../lib/api';
import {curators, managers} from './redux-modules';
import {selectUsername} from '../../../redux/session';
import {selectStudioId, setRoles} from '../../../redux/studio';
const Errors = keyMirror({
NETWORK: null,
SERVER: null,
PERMISSION: 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;
return null;
};
const loadManagers = () => ((dispatch, getState) => {
const state = getState();
const studioId = selectStudioId(state);
const managerCount = managers.selector(state).items.length;
const managersPerPage = 20;
api({
uri: `/studios/${studioId}/managers/`,
params: {limit: managersPerPage, offset: managerCount}
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return dispatch(managers.actions.error(error));
dispatch(managers.actions.append(body, body.length === managersPerPage));
});
});
const loadCurators = () => ((dispatch, getState) => {
const state = getState();
const studioId = selectStudioId(state);
const curatorCount = curators.selector(state).items.length;
const curatorsPerPage = 20;
api({
uri: `/studios/${studioId}/curators/`,
params: {limit: curatorsPerPage, offset: curatorCount}
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return dispatch(curators.actions.error(error));
dispatch(curators.actions.append(body, body.length === curatorsPerPage));
});
});
const removeManager = username => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
api({
uri: `/site-api/users/curators-in/${studioId}/remove/`,
method: 'PUT',
withCredentials: true,
useCsrf: true,
params: {usernames: username}, // sic, ?usernames=<username>
host: '' // Not handled by the API, use existing infrastructure
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
// Note `body` is undefined, this endpoint returns an html fragment
const index = managers.selector(getState()).items
.findIndex(v => v.username === username);
if (index !== -1) dispatch(managers.actions.remove(index));
// If you are removing yourself, update roles so you stop seeing the manager UI
if (selectUsername(state) === username) {
dispatch(setRoles({manager: false}));
}
return resolve();
});
}));
const removeCurator = username => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
api({
uri: `/site-api/users/curators-in/${studioId}/remove/`,
method: 'PUT',
withCredentials: true,
useCsrf: true,
params: {usernames: username}, // sic, ?usernames=<username>
host: '' // Not handled by the API, use existing infrastructure
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
// Note `body` is undefined, this endpoint returns an html fragment
const index = curators.selector(getState()).items
.findIndex(v => v.username === username);
if (index !== -1) dispatch(curators.actions.remove(index));
return resolve();
});
}));
const inviteCurator = username => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
api({
uri: `/site-api/users/curators-in/${studioId}/invite_curator/`,
method: 'PUT',
withCredentials: true,
useCsrf: true,
params: {usernames: username}, // sic, ?usernames=<username>
host: '' // Not handled by the API, use existing infrastructure
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
// eslint-disable-next-line no-alert
alert(`successfully invited ${username}`);
return resolve(username);
});
}));
const promoteCurator = username => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
api({
uri: `/site-api/users/curators-in/${studioId}/promote/`,
method: 'PUT',
withCredentials: true,
useCsrf: true,
params: {usernames: username}, // sic, ?usernames=<username>
host: '' // Not handled by the API, use existing infrastructure
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
const curatorList = curators.selector(getState()).items;
const index = curatorList.findIndex(v => v.username === username);
const curatorItem = curatorList[index];
if (index !== -1) dispatch(curators.actions.remove(index));
dispatch(managers.actions.create(curatorItem));
return resolve();
});
}));
const acceptInvitation = () => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const username = selectUsername(state);
const studioId = selectStudioId(state);
api({
uri: `/site-api/users/curators-in/${studioId}/add/`,
method: 'PUT',
withCredentials: true,
useCsrf: true,
params: {usernames: username}, // sic, ?usernames=<username>
host: '' // Not handled by the API, use existing infrastructure
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
api({uri: `/users/${username}`}, (userErr, userBody, userRes) => {
const userError = normalizeError(userErr, userBody, userRes);
if (userError) return reject(userError);
// Note: this assumes that the user items from the curator endpoint
// are the same structure as the single user data returned from /users/:username
dispatch(curators.actions.create(userBody));
dispatch(setRoles({invited: false, curator: true}));
return resolve();
});
});
}));
export {
Errors,
loadManagers,
loadCurators,
inviteCurator,
acceptInvitation,
promoteCurator,
removeCurator,
removeManager
};
import keyMirror from 'keymirror';
import api from '../../../lib/api';
import {selectToken} from '../../../redux/session';
import {selectStudioId} from '../../../redux/studio';
import {projects} from './redux-modules';
const Errors = keyMirror({
NETWORK: null,
SERVER: null,
PERMISSION: 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;
return null;
};
const loadProjects = () => ((dispatch, getState) => {
const state = getState();
const studioId = selectStudioId(state);
const projectCount = projects.selector(state).items.length;
const projectsPerPage = 20;
api({
uri: `/studios/${studioId}/projects/`,
params: {limit: projectsPerPage, offset: projectCount}
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return dispatch(projects.actions.error(error));
dispatch(projects.actions.append(body, body.length === projectsPerPage));
});
});
/**
* Generate a project list item matching the shape of the initial
* project list request. The POST request that adds projects would
* ideally respond with this format directly. For now, merge data
* from the POST and a follow-up GET request for additional project data.
*
* @param {object} postBody - body of response to POST that adds the project
* @param {object} infoBody - body of the follow-up GET for more project data.
* @returns {object} project list item
*/
const generateProjectListItem = (postBody, infoBody) => ({
// Fields from the POST to add the project to the studio
id: postBody.projectId,
actor_id: postBody.actorId,
// Fields from followup GET for more project info
title: infoBody.title,
image: infoBody.image,
creator_id: infoBody.author.id,
username: infoBody.author.username,
avatar: infoBody.author.profile.images
});
const addProject = projectId => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
const token = selectToken(state);
api({
uri: `/studios/${studioId}/project/${projectId}`,
method: 'POST',
authentication: token
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
// Would prefer if the POST returned the exact data / format we want...
api({uri: `/projects/${projectId}`}, (infoErr, infoBody, infoRes) => {
const infoError = normalizeError(infoErr, infoBody, infoRes);
if (infoError) return reject(infoError);
const newItem = generateProjectListItem(body, infoBody);
dispatch(projects.actions.create(newItem));
return resolve(newItem);
});
});
}));
const removeProject = projectId => ((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
const token = selectToken(state);
api({
uri: `/studios/${studioId}/project/${projectId}`,
method: 'DELETE',
authentication: token
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
const index = projects.selector(getState()).items
.findIndex(v => v.id === projectId);
if (index !== -1) dispatch(projects.actions.remove(index));
return resolve();
});
}));
export {
Errors,
loadProjects,
addProject,
removeProject
};
import React, {useEffect} from 'react';
import React, {useEffect, useRef} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
......@@ -9,6 +9,7 @@ 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 {selectIsAdmin} from '../../redux/session';
import {
selectShowCommentComposer,
selectCanDeleteComment,
......@@ -22,6 +23,7 @@ import {selectStudioCommentsAllowed} from '../../redux/studio.js';
const StudioComments = ({
comments,
commentsAllowed,
isAdmin,
handleLoadMoreComments,
handleNewComment,
moreCommentsToLoad,
......@@ -35,12 +37,22 @@ const StudioComments = ({
canRestoreComment,
handleDeleteComment,
handleRestoreComment,
handleResetComments,
handleReportComment,
handleLoadMoreReplies
}) => {
useEffect(() => {
if (comments.length === 0) handleLoadMoreComments();
}, []); // Only runs once after the first render
}, [comments.length === 0]);
// The comments you see depend on your admin status
// so reset them if isAdmin changes.
const adminRef = useRef(isAdmin);
useEffect(() => {
const wasAdmin = adminRef.current;
adminRef.current = isAdmin;
if (isAdmin !== wasAdmin) handleResetComments();
}, [isAdmin]);
return (
<div>
......@@ -93,6 +105,7 @@ const StudioComments = ({
StudioComments.propTypes = {
comments: PropTypes.arrayOf(PropTypes.shape({})),
commentsAllowed: PropTypes.bool,
isAdmin: PropTypes.bool,
handleLoadMoreComments: PropTypes.func,
handleNewComment: PropTypes.func,
moreCommentsToLoad: PropTypes.bool,
......@@ -106,13 +119,19 @@ StudioComments.propTypes = {
handleDeleteComment: PropTypes.func,
handleRestoreComment: PropTypes.func,
handleReportComment: PropTypes.func,
handleResetComments: PropTypes.func,
handleLoadMoreReplies: PropTypes.func,
postURI: PropTypes.string
};
export {
StudioComments
};
export default connect(
state => ({
comments: state.comments.comments,
isAdmin: selectIsAdmin(state),
moreCommentsToLoad: state.comments.moreCommentsToLoad,
replies: state.comments.replies,
commentsAllowed: selectStudioCommentsAllowed(state),
......@@ -130,7 +149,7 @@ export default connect(
handleDeleteComment: studioCommentActions.deleteComment,
handleRestoreComment: studioCommentActions.restoreComment,
handleReportComment: studioCommentActions.reportComment,
handleLoadMoreReplies: studioCommentActions.getReplies
handleLoadMoreReplies: studioCommentActions.getReplies,
handleResetComments: studioCommentActions.resetComments
}
)(StudioComments);
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {acceptInvitation} from './lib/studio-member-actions';
const StudioCuratorInvite = ({onSubmit}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
return (
<div>
<button
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onSubmit()
.catch(e => {
setError(e);
setSubmitting(false);
});
}}
>Accept invite</button>
{error && <div>{error}</div>}
</div>
);
};
StudioCuratorInvite.propTypes = {
onSubmit: PropTypes.func
};
const mapStateToProps = () => ({});
const mapDispatchToProps = ({
onSubmit: acceptInvitation
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioCuratorInvite);
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {inviteCurator} from './lib/studio-member-actions';
const StudioCuratorInviter = ({onSubmit}) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
return (
<div className="studio-adder-section">
<h3>✦ Invite Curators</h3>
<input
disabled={submitting}
type="text"
placeholder="<username>"
value={value}
onChange={e => setValue(e.target.value)}
/>
<button
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => setSubmitting(false));
}}
>Invite</button>
{error && <div>{error}</div>}
</div>
);
};
StudioCuratorInviter.propTypes = {
onSubmit: PropTypes.func
};
const mapStateToProps = () => ({});
const mapDispatchToProps = ({
onSubmit: inviteCurator
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioCuratorInviter);
import React, {useEffect, useCallback} from 'react';
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {useParams} from 'react-router-dom';
import {connect} from 'react-redux';
import {curators, managers} from './lib/redux-modules';
import {curatorFetcher, managerFetcher} from './lib/fetchers';
import {curators} from './lib/redux-modules';
import Debug from './debug.jsx';
import {CuratorTile} from './studio-member-tile.jsx';
import CuratorInviter from './studio-curator-inviter.jsx';
import CuratorInvite from './studio-curator-invite.jsx';
import {loadCurators} from './lib/studio-member-actions';
import {selectCanInviteCurators, selectShowCuratorInvite} from '../../redux/studio-permissions';
const StudioCurators = () => {
const {studioId} = useParams();
return (
<div>
<h3>Managers</h3>
<ManagerList studioId={studioId} />
<hr />
<h3>Curators</h3>
<CuratorList studioId={studioId} />
</div>
);
};
const MemberList = ({studioId, items, error, loading, moreToLoad, onLoadMore}) => {
const StudioCurators = ({
canInviteCurators, showCuratorInvite, items, error, loading, moreToLoad, onLoadMore
}) => {
useEffect(() => {
if (studioId && items.length === 0) onLoadMore(studioId, 0);
}, [studioId]);
const handleLoadMore = useCallback(() => onLoadMore(studioId, items.length), [studioId, items.length]);
if (items.length === 0) onLoadMore();
}, []);
return (<React.Fragment>
return (<div className="studio-members">
<h2>Curators</h2>
{canInviteCurators && <CuratorInviter />}
{showCuratorInvite && <CuratorInvite />}
{error && <Debug
label="Error"
data={error}
/>}
{items.map((item, index) =>
(<Debug
label="Member"
data={item}
key={index}
/>)
)}
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={handleLoadMore}>
<div className="studio-members-grid">
{items.map(item =>
(<CuratorTile
key={item.username}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
<div className="studio-members-load-more">
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={onLoadMore}>
Load more
</button> :
<small>No more to load</small>
)}
</React.Fragment>);
</button> :
<small>No more to load</small>
)}
</div>
</div>
</div>);
};
MemberList.propTypes = {
studioId: PropTypes.string,
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
StudioCurators.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.id,
username: PropTypes.string,
profile: PropTypes.shape({
images: PropTypes.shape({
'90x90': PropTypes.string
})
})
})),
canInviteCurators: PropTypes.bool,
showCuratorInvite: PropTypes.bool,
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
};
const ManagerList = connect(
state => managers.selector(state),
dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
managers.actions.loadMore(managerFetcher.bind(null, studioId, offset)))
})
)(MemberList);
const CuratorList = connect(
state => curators.selector(state),
dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
curators.actions.loadMore(curatorFetcher.bind(null, studioId, offset)))
})
)(MemberList);
export default StudioCurators;
export default connect(
state => ({
...curators.selector(state),
canInviteCurators: selectCanInviteCurators(state),
showCuratorInvite: selectShowCuratorInvite(state)
}),
{
onLoadMore: loadCurators
}
)(StudioCurators);
......@@ -8,31 +8,29 @@ import {selectCanEditInfo} from '../../redux/studio-permissions';
import {
mutateStudioDescription, selectIsMutatingDescription, selectDescriptionMutationError
} from '../../redux/studio-mutations';
import classNames from 'classnames';
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>
);
}) => {
const fieldClassName = classNames('studio-description', {
'mod-fetching': isFetching,
'mod-mutating': isMutating
});
return (
<React.Fragment>
<textarea
rows="20"
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={description}
onBlur={e => e.target.value !== description &&
handleUpdate(e.target.value)}
/>
{descriptionError && <div>Error mutating description: {descriptionError}</div>}
</React.Fragment>
);
};
StudioDescription.propTypes = {
descriptionError: PropTypes.string,
......
......@@ -2,43 +2,42 @@
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {selectIsFollowing, selectIsFetchingRoles} from '../../redux/studio';
import {selectIsFollowing} from '../../redux/studio';
import {selectCanFollowStudio} from '../../redux/studio-permissions';
import {
mutateFollowingStudio, selectIsMutatingFollowing, selectFollowingMutationError
} from '../../redux/studio-mutations';
import classNames from 'classnames';
const StudioFollow = ({
canFollow,
isFetching,
isFollowing,
isMutating,
followingError,
handleFollow
}) => (
<div>
<h3>Following</h3>
<div>
}) => {
if (!canFollow) return null;
const fieldClassName = classNames('button', {
'mod-mutating': isMutating
});
return (
<React.Fragment>
<button
disabled={isFetching || isMutating || !canFollow}
className={fieldClassName}
disabled={isMutating}
onClick={() => handleFollow(!isFollowing)}
>
{isFetching ? (
'Fetching...'
) : (
isFollowing ? 'Unfollow' : 'Follow'
{isMutating ? '...' : (
isFollowing ? 'Unfollow Studio' : 'Follow Studio'
)}
</button>
{followingError && <div>Error mutating following: {followingError}</div>}
{!canFollow && <div>Must be logged in to follow</div>}
</div>
</div>
);
</React.Fragment >
);
};
StudioFollow.propTypes = {
canFollow: PropTypes.bool,
isFetching: PropTypes.bool,
isFollowing: PropTypes.bool,
isMutating: PropTypes.bool,
followingError: PropTypes.string,
......@@ -48,7 +47,6 @@ StudioFollow.propTypes = {
export default connect(
state => ({
canFollow: selectCanFollowStudio(state),
isFetching: selectIsFetchingRoles(state),
isMutating: selectIsMutatingFollowing(state),
isFollowing: selectIsFollowing(state),
followingError: selectFollowingMutationError(state)
......
......@@ -8,43 +8,40 @@ import {selectCanEditInfo} from '../../redux/studio-permissions';
import {
mutateStudioImage, selectIsMutatingImage, selectImageMutationError
} from '../../redux/studio-mutations';
import Spinner from '../../components/spinner/spinner.jsx';
import classNames from 'classnames';
const blankImage = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
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>
);
}) => {
const fieldClassName = classNames('studio-image', {
'mod-fetching': isFetching,
'mod-mutating': isMutating
});
const src = isMutating ? blankImage : (image || blankImage);
return (
<div className={fieldClassName}>
<img
style={{width: '300px', height: '225px', objectFit: 'cover'}}
src={src}
/>
{canEditInfo && !isFetching &&
<React.Fragment>
<input
disabled={isMutating}
type="file"
accept="image/*"
onChange={e => {
handleUpdate(e.target);
e.target.value = '';
}}
/>
{imageError && <div>Error mutating image: {imageError}</div>}
</React.Fragment>
}
</div>
);
};
StudioImage.propTypes = {
imageError: PropTypes.string,
......
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';
......@@ -9,9 +8,10 @@ import StudioImage from './studio-image.jsx';
import {selectIsLoggedIn} from '../../redux/session';
import {getInfo, getRoles} from '../../redux/studio';
import StudioReport from './studio-report.jsx';
const StudioInfo = ({
isLoggedIn, studio, onLoadInfo, onLoadRoles
isLoggedIn, onLoadInfo, onLoadRoles
}) => {
useEffect(() => { // Load studio info after first render
onLoadInfo();
......@@ -22,30 +22,24 @@ const StudioInfo = ({
}, [isLoggedIn]);
return (
<div>
<h2>Studio Info</h2>
<React.Fragment>
<StudioTitle />
<StudioDescription />
<StudioFollow />
<StudioImage />
<Debug
label="Studio Info"
data={studio}
/>
</div>
<StudioDescription />
<StudioReport />
</React.Fragment>
);
};
StudioInfo.propTypes = {
isLoggedIn: PropTypes.bool,
studio: PropTypes.shape({}), // TODO remove, just for <Debug />
onLoadInfo: PropTypes.func,
onLoadRoles: PropTypes.func
};
export default connect(
state => ({
studio: state.studio,
isLoggedIn: selectIsLoggedIn(state)
}),
{
......
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {managers} from './lib/redux-modules';
import {loadManagers} from './lib/studio-member-actions';
import Debug from './debug.jsx';
import {ManagerTile} from './studio-member-tile.jsx';
const StudioManagers = ({items, error, loading, moreToLoad, onLoadMore}) => {
useEffect(() => {
if (items.length === 0) onLoadMore();
}, []);
return (
<div className="studio-members">
<h2>Managers</h2>
{error && <Debug
label="Error"
data={error}
/>}
<div className="studio-members-grid">
{items.map(item =>
(<ManagerTile
key={item.username}
id={item.id}
username={item.username}
image={item.profile.images['90x90']}
/>)
)}
<div className="studio-members-load-more">
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={onLoadMore}>
Load more
</button> :
<small>No more to load</small>
)}
</div>
</div>
</div>
);
};
StudioManagers.propTypes = {
items: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.id,
username: PropTypes.string,
profile: PropTypes.shape({
images: PropTypes.shape({
'90x90': PropTypes.string
})
})
})),
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
};
export default connect(
state => managers.selector(state),
{
onLoadMore: loadManagers
}
)(StudioManagers);
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {
selectCanRemoveCurators, selectCanRemoveManager, selectCanPromoteCurators
} from '../../redux/studio-permissions';
import {
promoteCurator,
removeCurator,
removeManager
} from './lib/studio-member-actions';
const StudioMemberTile = ({
canRemove, canPromote, onRemove, onPromote, isCreator, // mapState props
username, image // own props
}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const userUrl = `/users/${username}`;
return (
<div className="studio-member-tile">
<a href={userUrl}>
<img
className="studio-member-image"
src={image}
/>
</a>
<div className="studio-member-info">
<a
href={userUrl}
className="studio-member-name"
>{username}</a>
{isCreator && <div className="studio-member-role">Studio Creator</div>}
</div>
{canRemove &&
<button
className={classNames('studio-member-remove', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onRemove(username).catch(e => {
setError(e);
setSubmitting(false);
});
}}
></button>
}
{canPromote &&
<button
className={classNames('studio-member-promote', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onPromote(username).catch(e => {
setError(e);
setSubmitting(false);
});
}}
>🆙</button>
}
{error && <div>{error}</div>}
</div>
);
};
StudioMemberTile.propTypes = {
canRemove: PropTypes.bool,
canPromote: PropTypes.bool,
onRemove: PropTypes.func,
onPromote: PropTypes.func,
username: PropTypes.string,
image: PropTypes.string,
isCreator: PropTypes.bool
};
const ManagerTile = connect(
(state, ownProps) => ({
canRemove: selectCanRemoveManager(state, ownProps.id),
canPromote: false,
isCreator: state.studio.owner === ownProps.id
}),
{
onRemove: removeManager
}
)(StudioMemberTile);
const CuratorTile = connect(
state => ({
canRemove: selectCanRemoveCurators(state),
canPromote: selectCanPromoteCurators(state)
}),
{
onRemove: removeCurator,
onPromote: promoteCurator
}
)(StudioMemberTile);
export {
ManagerTile,
CuratorTile
};
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {addProject} from './lib/studio-project-actions';
const StudioProjectAdder = ({onSubmit}) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
return (
<div className="studio-adder-section">
<h3>✦ Add Projects</h3>
<input
disabled={submitting}
type="text"
placeholder="<project id>"
value={value}
onChange={e => setValue(e.target.value)}
/>
<button
className={classNames('button', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onSubmit(value)
.then(() => setValue(''))
.catch(e => setError(e))
.then(() => setSubmitting(false));
}}
>Add</button>
{error && <div>{error}</div>}
</div>
);
};
StudioProjectAdder.propTypes = {
onSubmit: PropTypes.func
};
const mapStateToProps = () => ({});
const mapDispatchToProps = ({
onSubmit: addProject
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioProjectAdder);
/* eslint-disable react/jsx-no-bind */
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import classNames from 'classnames';
import {selectCanRemoveProject} from '../../redux/studio-permissions';
import {removeProject} from './lib/studio-project-actions';
const StudioProjectTile = ({
canRemove, onRemove, // mapState props
id, title, image, avatar, username // own props
}) => {
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState(null);
const projectUrl = `/projects/${id}`;
const userUrl = `/users/${username}`;
return (
<div className="studio-project-tile">
<a href={projectUrl}>
<img
className="studio-project-image"
src={image}
/>
</a>
<div className="studio-project-bottom">
<a href={userUrl}>
<img
className="studio-project-avatar"
src={avatar}
/>
</a>
<div className="studio-project-info">
<a
href={projectUrl}
className="studio-project-title"
>{title}</a>
<a
href={userUrl}
className="studio-project-username"
>{username}</a>
</div>
{canRemove &&
<button
className={classNames('studio-project-remove', {
'mod-mutating': submitting
})}
disabled={submitting}
onClick={() => {
setSubmitting(true);
setError(null);
onRemove(id)
.catch(e => {
setError(e);
setSubmitting(false);
});
}}
></button>
}
{error && <div>{error}</div>}
</div>
</div>
);
};
StudioProjectTile.propTypes = {
canRemove: PropTypes.bool,
onRemove: PropTypes.func,
id: PropTypes.number,
title: PropTypes.string,
username: PropTypes.string,
image: PropTypes.string,
avatar: PropTypes.string
};
const mapStateToProps = (state, ownProps) => ({
canRemove: selectCanRemoveProject(state, ownProps.username, ownProps.addedBy)
});
const mapDispatchToProps = ({
onRemove: removeProject
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioProjectTile);
import React, {useEffect, useCallback} from 'react';
import React, {useEffect} 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 StudioProjectAdder from './studio-project-adder.jsx';
import StudioProjectTile from './studio-project-tile.jsx';
import {loadProjects} from './lib/studio-project-actions.js';
const StudioProjects = ({
canAddProjects, canEditOpenToAll, items, error, loading, moreToLoad, onLoadMore
}) => {
const {studioId} = useParams();
useEffect(() => {
if (studioId && items.length === 0) onLoadMore(studioId, 0);
}, [studioId]);
const handleLoadMore = useCallback(() => onLoadMore(studioId, items.length), [studioId, items.length]);
if (items.length === 0) onLoadMore();
}, []);
return (
<div>
<div className="studio-projects">
<h2>Projects</h2>
{canEditOpenToAll && <StudioOpenToAll />}
{canAddProjects && <StudioProjectAdder />}
{error && <Debug
label="Error"
data={error}
/>}
<Debug
label="Project Permissions"
data={{canAddProjects}}
/>
<div>
{items.map((item, index) =>
(<Debug
label="Project"
data={item}
key={index}
<div className="studio-projects-grid">
{items.map(item =>
(<StudioProjectTile
fetching={loading}
key={item.id}
id={item.id}
title={item.title}
image={item.image}
avatar={item.avatar['90x90']}
username={item.username}
addedBy={item.actor_id}
/>)
)}
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={handleLoadMore}>
<div className="studio-projects-load-more">
{loading ? <small>Loading...</small> : (
moreToLoad ?
<button onClick={onLoadMore}>
Load more
</button> :
<small>No more to load</small>
)}
</button> :
<small>No more to load</small>
)}
</div>
</div>
</div>
);
......@@ -57,22 +56,27 @@ const StudioProjects = ({
StudioProjects.propTypes = {
canAddProjects: PropTypes.bool,
canEditOpenToAll: PropTypes.bool,
items: PropTypes.array, // eslint-disable-line react/forbid-prop-types
items: PropTypes.arrayOf(PropTypes.shape({
avatar: PropTypes.shape({
'90x90': PropTypes.string
}),
id: PropTypes.id,
title: PropTypes.string,
username: PropTypes.string
})),
loading: PropTypes.bool,
error: PropTypes.object, // eslint-disable-line react/forbid-prop-types
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func
};
const mapStateToProps = state => ({
...projectsSelector(state),
canAddProjects: selectCanAddProjects(state),
canEditOpenToAll: selectCanEditOpenToAll(state)
});
const mapDispatchToProps = dispatch => ({
onLoadMore: (studioId, offset) => dispatch(
actions.loadMore(projectFetcher.bind(null, studioId, offset))
)
});
export default connect(mapStateToProps, mapDispatchToProps)(StudioProjects);
export default connect(
state => ({
...projects.selector(state),
canAddProjects: selectCanAddProjects(state),
canEditOpenToAll: selectCanEditOpenToAll(state)
}),
{
onLoadMore: loadProjects
}
)(StudioProjects);
/* eslint-disable react/jsx-no-bind */
import React from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {
Fields,
actions,
selectors
} from '../../redux/studio-report';
const StudioReport = ({
canReport,
error,
field,
isOpen,
isSubmitting,
previouslyReported,
handleSetField,
handleOpen,
handleClose,
handleSubmit
}) => (
<div>
<h3>Reporting</h3>
{canReport && (
<button onClick={handleOpen}>Report</button>
)}
{isOpen && (
<div style={{padding: '1rem', margin: '1rem', border: '1px solid green'}}>
<div>Report Studio Modal</div>
{previouslyReported ? (
<React.Fragment>
<div>Submitted the report!</div>
<button onClick={handleClose}>Close</button>
</React.Fragment>
) : (
<React.Fragment>
<select
value={field}
onChange={e => handleSetField(e.target.value)}
>
<option value={Fields.TITLE}>Title</option>
<option value={Fields.DESCRIPTION}>Description</option>
<option value={Fields.THUMBNAIL}>Thumbnail</option>
</select>
{error && (
<div>
<div>There was an error. Try again later?</div>
<div><code><pre>{error}</pre></code></div>
</div>
)}
<button
disabled={isSubmitting}
onClick={handleSubmit}
>
Submit
</button>
<button onClick={handleClose}>Cancel</button>
</React.Fragment>
)}
</div>
)}
</div>
);
StudioReport.propTypes = {
canReport: PropTypes.bool,
error: PropTypes.string,
field: PropTypes.string,
isOpen: PropTypes.bool,
isSubmitting: PropTypes.bool,
previouslyReported: PropTypes.bool,
handleOpen: PropTypes.func,
handleClose: PropTypes.func,
handleSetField: PropTypes.func,
handleSubmit: PropTypes.func
};
export default connect(
state => ({
canReport: selectors.selectCanReportStudio(state),
error: selectors.selectStudioReportError(state),
field: selectors.selectStudioReportField(state),
isOpen: selectors.selectStudioReportOpen(state),
isSubmitting: selectors.selectStudioReportSubmitting(state),
previouslyReported: selectors.selectStudioReportSubmitted(state)
}),
{
handleOpen: actions.openStudioReport,
handleClose: actions.closeStudioReport,
handleSetField: actions.setStudioReportField,
handleSubmit: actions.submitStudioReport
}
)(StudioReport);
import React from 'react';
import {useRouteMatch, NavLink} from 'react-router-dom';
import SubNavigation from '../../components/subnavigation/subnavigation.jsx';
const StudioTabNav = () => {
const match = useRouteMatch();
return (
<div>
<SubNavigation
align="left"
className="studio-tab-nav"
>
<NavLink
activeStyle={{textDecoration: 'underline'}}
activeClassName="active"
to={`${match.url}`}
exact
>
Projects
<li>Projects</li>
</NavLink>
&nbsp;|&nbsp;
<NavLink
activeStyle={{textDecoration: 'underline'}}
activeClassName="active"
to={`${match.url}/curators`}
>
Curators
<li>Curators</li>
</NavLink>
&nbsp;|&nbsp;
<NavLink
activeStyle={{textDecoration: 'underline'}}
activeClassName="active"
to={`${match.url}/comments`}
>
Comments
<li> Comments</li>
</NavLink>
&nbsp;|&nbsp;
<NavLink
activeStyle={{textDecoration: 'underline'}}
activeClassName="active"
to={`${match.url}/activity`}
>
Activity
<li>Activity</li>
</NavLink>
</div>
</SubNavigation>
);
};
......
......@@ -6,29 +6,28 @@ 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';
import classNames from 'classnames';
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>
);
}) => {
const fieldClassName = classNames('studio-title', {
'mod-fetching': isFetching,
'mod-mutating': isMutating
});
return (
<React.Fragment>
<textarea
className={fieldClassName}
disabled={isMutating || !canEditInfo || isFetching}
defaultValue={title}
onBlur={e => e.target.value !== title &&
handleUpdate(e.target.value)}
/>
{titleError && <div>Error mutating title: {titleError}</div>}
</React.Fragment>
);
};
StudioTitle.propTypes = {
titleError: PropTypes.string,
......
......@@ -13,6 +13,7 @@ import render from '../../lib/render.jsx';
import StudioTabNav from './studio-tab-nav.jsx';
import StudioProjects from './studio-projects.jsx';
import StudioInfo from './studio-info.jsx';
import StudioManagers from './studio-managers.jsx';
import StudioCurators from './studio-curators.jsx';
import StudioComments from './studio-comments.jsx';
import StudioActivity from './studio-activity.jsx';
......@@ -25,43 +26,50 @@ import {
} from './lib/redux-modules';
const {getInitialState, studioReducer} = require('../../redux/studio');
const {studioReportReducer} = require('../../redux/studio-report');
const {commentsReducer} = require('../../redux/comments');
const {studioMutationsReducer} = require('../../redux/studio-mutations');
import './studio.scss';
const StudioShell = () => {
const match = useRouteMatch();
return (
<div style={{maxWidth: '960px', margin: 'auto'}}>
<StudioInfo />
<hr />
<StudioTabNav />
<div>
<Switch>
<Route path={`${match.path}/curators`}>
<StudioCurators />
</Route>
<Route path={`${match.path}/comments`}>
<StudioComments />
</Route>
<Route path={`${match.path}/activity`}>
<StudioActivity />
</Route>
<Route path={`${match.path}/projects`}>
{/* We can force /projects back to / this way */}
<Redirect to={match.url} />
</Route>
<Route path={match.path}>
<StudioProjects />
</Route>
</Switch>
<div className="studio-shell">
<div className="studio-info">
<StudioInfo />
</div>
<div className="studio-tabs">
<StudioTabNav />
<div>
<Switch>
<Route path={`${match.path}/curators`}>
<StudioManagers />
<StudioCurators />
</Route>
<Route path={`${match.path}/comments`}>
<StudioComments />
</Route>
<Route path={`${match.path}/activity`}>
<StudioActivity />
</Route>
<Route path={`${match.path}/projects`}>
{/* We can force /projects back to / this way */}
<Redirect to={match.url} />
</Route>
<Route path={match.path}>
<StudioProjects />
</Route>
</Switch>
</div>
</div>
</div>
);
};
render(
<Page>
<Page className="studio-page">
<Router>
<Switch>
{/* Use variable studioPath to support /studio-playground/ or future route */}
......@@ -77,9 +85,10 @@ render(
[curators.key]: curators.reducer,
[managers.key]: managers.reducer,
[activity.key]: activity.reducer,
comments: commentsReducer,
studio: studioReducer,
studioMutations: studioMutationsReducer,
comments: commentsReducer
studioReport: studioReportReducer
},
{
studio: {
......
@import "../../colors";
@import "../../frameless";
$radius: 8px;
.studio-page {
background-color: #E9F1FC;
#view {
/* Reset some defaults on width and margin */
background-color: transparent;
max-width: 1240px;
min-width: auto;
margin: 50px auto;
display: block;
.studio-shell {
padding: 0 20px;
display: grid;
gap: 40px;
/* Side-by-side with fixed width sidebar */
grid-template-columns: 300px minmax(0, 1fr);
/* Stack vertically at medium size and smaller */
@media #{$medium-and-smaller} {
& {
grid-template-columns: minmax(0, 1fr);
}
}
}
}
}
.studio-info {
justify-self: center;
width: 300px;
height: fit-content;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 20px;
.studio-title, .studio-description {
background: transparent;
margin: 0 -8px; /* Outset the border horizontally */
padding: 5px 8px;
border: 2px dashed $ui-blue-25percent;
border-radius: $radius;
resize: none;
&:disabled { border-color: transparent; }
}
.studio-title {
font-size: 28px;
font-weight: 500;
}
.studio-description:disabled {
background: $ui-blue-10percent;
}
}
.studio-tab-nav {
border-bottom: 1px solid $active-dark-gray;
padding-bottom: 8px;
li { background: $active-gray; }
.active > li { background: $ui-blue; }
}
.studio-projects {}
.studio-projects-grid {
margin-top: 20px;
display: grid;
grid-template-columns: minmax(0, 1fr);
@media #{$medium} {
& { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
@media #{$big} {
& { grid-template-columns: repeat(3, minmax(0,1fr)); }
}
column-gap: 30px;
row-gap: 20px;
.studio-projects-load-more {
grid-column: 1 / -1;
}
}
.studio-project-tile {
background: white;
border-radius: 8px;
border: 1px solid $ui-border;
.studio-project-image {
max-width: 100%;
background: #a0c6fc;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
.studio-project-bottom {
display: flex;
padding: 10px 6px 10px 12px;
justify-content: space-between;
}
.studio-project-avatar {
width: 42px;
height: 42px;
border-radius: 4px;
object-fit: cover;
}
.studio-project-info {
display: flex;
flex-direction: column;
justify-content: space-around;
overflow: hidden;
margin: 0 8px;
flex-grow: 1; /* Grow to fill available space */
min-width: 0; /* Prevents within from expanding beyond bounds */
}
.studio-project-title {
color: #4C97FF;
font-weight: 700;
font-size: 14px;
white-space: nowrap;
text-overflow: ellipsis;
}
.studio-project-username {
color: #575E75;
font-weight: 700;
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.studio-project-remove {
color: $ui-blue;
background: transparent;
border: none;
}
}
.studio-members {}
.studio-members-grid {
margin-top: 20px;
display: grid;
grid-template-columns: minmax(0, 1fr);
@media #{$medium} {
& { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
@media #{$big} {
& { grid-template-columns: repeat(3, minmax(0,1fr)); }
}
column-gap: 30px;
row-gap: 20px;
.studio-members-load-more {
grid-column: 1 / -1;
}
}
.studio-member-tile {
background: white;
border-radius: 8px;
border: 1px solid $ui-border;
display: flex;
padding: 10px 6px 10px 12px;
justify-content: space-between;
.studio-member-image {
width: 42px;
height: 42px;
border-radius: 4px;
object-fit: cover;
}
.studio-member-info {
display: flex;
flex-direction: column;
justify-content: space-around;
overflow: hidden;
margin: 0 8px;
flex-grow: 1; /* Grow to fill available space */
min-width: 0; /* Prevents within from expanding beyond bounds */
}
.studio-member-name {
color: #4C97FF;
font-weight: 700;
font-size: 14px;
white-space: nowrap;
text-overflow: ellipsis;
}
.studio-member-role {
color: #575E75;
font-weight: 400;
font-size: 12px;
white-space: nowrap;
text-overflow: ellipsis;
}
.studio-member-remove, .studio-member-promote {
color: $ui-blue;
background: transparent;
border: none;
}
}
.studio-members + .studio-members {
margin-top: 40px;
}
.studio-adder-section {
margin-top: 20px;
display: flex;
flex-wrap: wrap;
h3 {
color: #4C97FF;
}
input {
flex-basis: 80%;
flex-grow: 1;
display: inline-block;
margin: .5em 0;
border: 1px solid $ui-border;
border-radius: .5rem;
padding: 1em 1.25em;
font-size: .8rem;
}
button {
flex-grow: 1;
}
input + button {
margin-inline-start: 12px;
}
}
/* Modification classes for different interaction states */
.mod-fetching { /* When a field has no content to display yet */
position: relative;
min-height: 30px;
&::after {
content: '';
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
background: #a0c6fc;
border-radius: $radius;
}
/* For elements that can't use :after, force reset some internals
to get the same visual (e.g. for textareas)*/
border-radius: $radius;
background: #a0c6fc !important;
color: #a0c6fc !important;
border: none !important;
margin: 0 !important;
padding: 0 !important;
}
.mod-mutating { /* When a field has sent a change to the server */
cursor: wait !important;
opacity: .5;
}
......@@ -6,6 +6,9 @@
"isCurator": {
"curator": true
},
"isInvited": {
"invited": true
},
"creator1": {
"owner": 1
},
......@@ -27,7 +30,8 @@
"user1Social": {
"session": {
"user": {
"id": 1
"id": 1,
"username": "user1-username"
},
"permissions": {
"social": true
......
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import {StudioComments} from '../../../src/views/studio/studio-comments.jsx';
describe('Studio comments', () => {
test('if there are no comments, they get loaded', () => {
const loadComments = jest.fn();
const component = mountWithIntl(
<StudioComments
comments={[]}
handleLoadMoreComments={loadComments}
/>
);
expect(loadComments).toHaveBeenCalled();
// When updated to have comments, load is not called again
loadComments.mockClear();
component.setProps({comments: [{id: 123, author: {}}]});
component.update();
expect(loadComments).not.toHaveBeenCalled();
// When reset to have no comments again, load is called again
loadComments.mockClear();
component.setProps({comments: []});
component.update();
expect(loadComments).toHaveBeenCalled();
});
test('becoming an admin resets the comments', () => {
const resetComments = jest.fn();
const component = mountWithIntl(
<StudioComments
isAdmin={false}
comments={[{id: 123, author: {}}]}
handleResetComments={resetComments}
/>
);
expect(resetComments).not.toHaveBeenCalled();
// When updated to isAdmin=true, reset is called
resetComments.mockClear();
component.setProps({isAdmin: true});
component.update();
expect(resetComments).toHaveBeenCalled();
// If updated back to isAdmin=false, reset is also called
// not currently possible in the UI, but if it was, we'd want to clear comments
resetComments.mockClear();
component.setProps({isAdmin: false});
component.update();
expect(resetComments).toHaveBeenCalled();
});
test('being an admin on initial render doesnt reset comments', () => {
// This ensures that comments don't get reloaded when changing tabs
const resetComments = jest.fn();
mountWithIntl(
<StudioComments
isAdmin
comments={[{id: 123, author: {}}]}
handleResetComments={resetComments}
/>
);
expect(resetComments).not.toHaveBeenCalled();
});
});
......@@ -8,11 +8,17 @@ import {
selectCanRestoreComment,
selectCanFollowStudio,
selectCanEditCommentsAllowed,
selectCanEditOpenToAll
selectCanEditOpenToAll,
selectShowCuratorInvite,
selectCanInviteCurators,
selectCanRemoveCurators,
selectCanRemoveManager,
selectCanPromoteCurators,
selectCanRemoveProject
} from '../../../src/redux/studio-permissions';
import {getInitialState as getInitialStudioState} from '../../../src/redux/studio';
import {getInitialState as getInitialSessionState} from '../../../src/redux/session';
import {getInitialState as getInitialSessionState, selectUserId, selectUsername} from '../../../src/redux/session';
import {sessions, studios} from '../../helpers/state-fixtures.json';
let state;
......@@ -42,6 +48,9 @@ const setStateByRole = (role) => {
break;
case 'logged out': // Default state set in beforeEach
break;
case 'invited':
state.studio = studios.isInvited;
break;
default:
throw new Error('Unknown user role in test: ' + role);
}
......@@ -98,6 +107,39 @@ describe('studio projects', () => {
expect(selectCanAddProjects(state)).toBe(expected);
});
});
describe('can remove projects', () => {
test.each([
['admin', true],
['curator', false], // false for projects that were not added by them, see below
['manager', true],
['creator', true],
['logged in', false], // false for projects that are not theirs, see below
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveProject(state, 'not-me', 'not-me')).toBe(expected);
});
test('curators can remove projects they added', () => {
setStateByRole('curator');
const addedBy = selectUserId(state);
expect(selectCanRemoveProject(state, 'not-me', addedBy)).toBe(true);
});
test('curators can also remove projects they own that they did not add', () => {
setStateByRole('curator');
const creator = selectUsername(state);
expect(selectCanRemoveProject(state, creator, 'not-me')).toBe(true);
});
test('logged in users can only remove projects they own', () => {
setStateByRole('logged in');
const creator = selectUsername(state);
expect(selectCanRemoveProject(state, creator, 'not-me')).toBe(true);
});
});
});
describe('studio comments', () => {
......@@ -209,3 +251,97 @@ describe('studio comments', () => {
});
});
});
describe('studio members', () => {
describe('can accept invitation', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['invited', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectShowCuratorInvite(state)).toBe(expected);
});
});
describe('can promote curators', () => {
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(selectCanPromoteCurators(state)).toBe(expected);
});
});
describe('can remove curators', () => {
test.each([
['admin', true],
['curator', false],
['manager', true],
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveCurators(state)).toBe(expected);
});
});
describe('can remove managers', () => {
test.each([
['admin', true],
['curator', false],
['manager', true],
['creator', true],
['logged in', false],
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanRemoveManager(state, '123')).toBe(expected);
});
describe('nobody can remove the studio creator', () => {
test.each([
['admin', false],
['curator', false],
['manager', false],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
state.studio.owner = 'the creator';
expect(selectCanRemoveManager(state, 'the creator')).toBe(expected);
});
});
});
describe('can invite curators', () => {
test.each([
['admin', false],
['curator', false],
['manager', true],
['creator', false],
['logged in', false],
['unconfirmed', false],
['logged out', false]
])('%s: %s', (role, expected) => {
setStateByRole(role);
expect(selectCanInviteCurators(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