diff --git a/src/redux/studio-permissions.js b/src/redux/studio-permissions.js index 682ff0fa8..7ce9f4905 100644 --- a/src/redux/studio-permissions.js +++ b/src/redux/studio-permissions.js @@ -1,4 +1,4 @@ -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 }; diff --git a/src/redux/studio.js b/src/redux/studio.js index ded4c302e..bfe677399 100644 --- a/src/redux/studio.js +++ b/src/redux/studio.js @@ -148,6 +148,7 @@ module.exports = { getInfo, getRoles, setInfo, + setRoles, // Selectors selectStudioId, diff --git a/src/views/studio/lib/fetchers.js b/src/views/studio/lib/fetchers.js index 8262aea8c..0b11e2e08 100644 --- a/src/views/studio/lib/fetchers.js +++ b/src/views/studio/lib/fetchers.js @@ -1,28 +1,9 @@ -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 }; diff --git a/src/views/studio/lib/studio-member-actions.js b/src/views/studio/lib/studio-member-actions.js new file mode 100644 index 000000000..2e8749b7b --- /dev/null +++ b/src/views/studio/lib/studio-member-actions.js @@ -0,0 +1,175 @@ +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= + 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= + 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= + 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= + 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= + 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 +}; diff --git a/src/views/studio/lib/studio-project-actions.js b/src/views/studio/lib/studio-project-actions.js new file mode 100644 index 000000000..0de1015df --- /dev/null +++ b/src/views/studio/lib/studio-project-actions.js @@ -0,0 +1,105 @@ +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 +}; diff --git a/src/views/studio/studio-curator-invite.jsx b/src/views/studio/studio-curator-invite.jsx new file mode 100644 index 000000000..d3568555b --- /dev/null +++ b/src/views/studio/studio-curator-invite.jsx @@ -0,0 +1,45 @@ +/* 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 ( +
+ + {error &&
{error}
} +
+ ); +}; + +StudioCuratorInvite.propTypes = { + onSubmit: PropTypes.func +}; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = ({ + onSubmit: acceptInvitation +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StudioCuratorInvite); diff --git a/src/views/studio/studio-curator-inviter.jsx b/src/views/studio/studio-curator-inviter.jsx new file mode 100644 index 000000000..6c2541de0 --- /dev/null +++ b/src/views/studio/studio-curator-inviter.jsx @@ -0,0 +1,53 @@ +/* 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 ( +
+

✦ Invite Curators

+ setValue(e.target.value)} + /> + + {error &&
{error}
} +
+ ); +}; + +StudioCuratorInviter.propTypes = { + onSubmit: PropTypes.func +}; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = ({ + onSubmit: inviteCurator +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StudioCuratorInviter); diff --git a/src/views/studio/studio-curators.jsx b/src/views/studio/studio-curators.jsx index 37e1c7719..cddb42851 100644 --- a/src/views/studio/studio-curators.jsx +++ b/src/views/studio/studio-curators.jsx @@ -1,77 +1,76 @@ -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 ( -
-

Managers

- -
-

Curators

- -
- ); -}; - -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 ( + return (
+

Curators

+ {canInviteCurators && } + {showCuratorInvite && } {error && } - {items.map((item, index) => - () - )} - {loading ? Loading... : ( - moreToLoad ? - : - No more to load - )} - ); + : + No more to load + )} +
+ + ); }; -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); diff --git a/src/views/studio/studio-managers.jsx b/src/views/studio/studio-managers.jsx new file mode 100644 index 000000000..ffb750a97 --- /dev/null +++ b/src/views/studio/studio-managers.jsx @@ -0,0 +1,67 @@ +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 ( +
+

Managers

+ {error && } +
+ {items.map(item => + () + )} +
+ {loading ? Loading... : ( + moreToLoad ? + : + No more to load + )} +
+
+
+ ); +}; + +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); diff --git a/src/views/studio/studio-member-tile.jsx b/src/views/studio/studio-member-tile.jsx new file mode 100644 index 000000000..a6736b973 --- /dev/null +++ b/src/views/studio/studio-member-tile.jsx @@ -0,0 +1,110 @@ +/* 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 ( +
+ + + +
+ {username} + {isCreator &&
Studio Creator
} +
+ {canRemove && + + } + {canPromote && + + } + {error &&
{error}
} +
+ ); +}; + +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 +}; diff --git a/src/views/studio/studio-project-adder.jsx b/src/views/studio/studio-project-adder.jsx new file mode 100644 index 000000000..e57b2cd52 --- /dev/null +++ b/src/views/studio/studio-project-adder.jsx @@ -0,0 +1,53 @@ +/* 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 ( +
+

✦ Add Projects

+ setValue(e.target.value)} + /> + + {error &&
{error}
} +
+ ); +}; + +StudioProjectAdder.propTypes = { + onSubmit: PropTypes.func +}; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = ({ + onSubmit: addProject +}); + +export default connect(mapStateToProps, mapDispatchToProps)(StudioProjectAdder); diff --git a/src/views/studio/studio-project-tile.jsx b/src/views/studio/studio-project-tile.jsx new file mode 100644 index 000000000..8f0fd3574 --- /dev/null +++ b/src/views/studio/studio-project-tile.jsx @@ -0,0 +1,84 @@ +/* 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 ( +
+ + + +
+ + + + + {canRemove && + + } + {error &&
{error}
} +
+
+ ); +}; + +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); diff --git a/src/views/studio/studio-projects.jsx b/src/views/studio/studio-projects.jsx index c3ce6200c..db9c62e2a 100644 --- a/src/views/studio/studio-projects.jsx +++ b/src/views/studio/studio-projects.jsx @@ -1,54 +1,53 @@ -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 ( -
+

Projects

{canEditOpenToAll && } + {canAddProjects && } {error && } - -
- {items.map((item, index) => - ( + {items.map(item => + () )} - {loading ? Loading... : ( - moreToLoad ? - : - No more to load - )} + : + No more to load + )} +
); @@ -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); diff --git a/src/views/studio/studio.jsx b/src/views/studio/studio.jsx index 144deed77..627d1e331 100644 --- a/src/views/studio/studio.jsx +++ b/src/views/studio/studio.jsx @@ -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'; @@ -43,6 +44,7 @@ const StudioShell = () => {
+ diff --git a/src/views/studio/studio.scss b/src/views/studio/studio.scss index 89c6d6b7a..1b12c50f8 100644 --- a/src/views/studio/studio.scss +++ b/src/views/studio/studio.scss @@ -67,6 +67,174 @@ $radius: 8px; .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 */ @@ -93,6 +261,6 @@ $radius: 8px; } .mod-mutating { /* When a field has sent a change to the server */ - cursor: wait; + cursor: wait !important; opacity: .5; } diff --git a/test/helpers/state-fixtures.json b/test/helpers/state-fixtures.json index 0133746d6..4fd5dfaa7 100644 --- a/test/helpers/state-fixtures.json +++ b/test/helpers/state-fixtures.json @@ -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 diff --git a/test/unit/redux/studio-permissions.test.js b/test/unit/redux/studio-permissions.test.js index 1c5b59925..a1f0069ce 100644 --- a/test/unit/redux/studio-permissions.test.js +++ b/test/unit/redux/studio-permissions.test.js @@ -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); + }); + }); +});