Unverified Commit 296506ff authored by Eric Rosenbaum's avatar Eric Rosenbaum Committed by GitHub

Merge pull request #5920 from ericrosenbaum/transfer-modal

Studio host transfer modal (work in progress)
parents 4177f9b9 7df59213
...@@ -149,6 +149,8 @@ module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'pe ...@@ -149,6 +149,8 @@ module.exports.selectMuteStatus = state => get(state, ['session', 'session', 'pe
module.exports.selectIsMuted = state => (module.exports.selectMuteStatus(state).muteExpiresAt || 0) * 1000 > Date.now(); module.exports.selectIsMuted = state => (module.exports.selectMuteStatus(state).muteExpiresAt || 0) * 1000 > Date.now();
module.exports.selectNewStudiosLaunched = state => get(state, ['session', 'session', 'flags', 'new_studios_launched'], module.exports.selectNewStudiosLaunched = state => get(state, ['session', 'session', 'flags', 'new_studios_launched'],
false); false);
module.exports.selectStudioTransferLaunched = state => get(state, ['session', 'session', 'flags',
'studio_transfer_launched'], false);
module.exports.selectHasFetchedSession = state => state.session.status === module.exports.Status.FETCHED; module.exports.selectHasFetchedSession = state => state.session.status === module.exports.Status.FETCHED;
......
...@@ -3,12 +3,12 @@ const {selectUserId, selectIsAdmin, selectIsSocial, ...@@ -3,12 +3,12 @@ const {selectUserId, selectIsAdmin, selectIsSocial,
selectHasFetchedSession, selectStudioCommentsGloballyEnabled} = require('./session'); selectHasFetchedSession, selectStudioCommentsGloballyEnabled} = require('./session');
// Fine-grain selector helpers - not exported, use the higher level selectors below // Fine-grain selector helpers - not exported, use the higher level selectors below
const isCreator = state => selectUserId(state) === state.studio.owner; const isHost = state => selectUserId(state) === state.studio.owner;
const isCurator = state => state.studio.curator; const isCurator = state => state.studio.curator;
const isManager = state => state.studio.manager || isCreator(state); const isManager = state => state.studio.manager || isHost(state);
// Action-based permissions selectors // Action-based permissions selectors
const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state)); const selectCanEditInfo = state => !selectIsMuted(state) && (selectIsAdmin(state) || isHost(state));
const selectCanAddProjects = state => const selectCanAddProjects = state =>
!selectIsMuted(state) && !selectIsMuted(state) &&
(isManager(state) || (isManager(state) ||
...@@ -35,7 +35,7 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state); ...@@ -35,7 +35,7 @@ const selectCanDeleteCommentWithoutConfirm = state => selectIsAdmin(state);
const selectCanFollowStudio = state => selectIsLoggedIn(state); const selectCanFollowStudio = state => selectIsLoggedIn(state);
// Matching existing behavior, only admin/creator is allowed to toggle comments. // Matching existing behavior, only admin/creator is allowed to toggle comments.
const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isCreator(state)); const selectCanEditCommentsAllowed = state => !selectIsMuted(state) && (selectIsAdmin(state) || isHost(state));
const selectCanEditOpenToAll = state => !selectIsMuted(state) && isManager(state); const selectCanEditOpenToAll = state => !selectIsMuted(state) && isManager(state);
const selectShowCuratorInvite = state => !selectIsMuted(state) && !!state.studio.invited; const selectShowCuratorInvite = state => !selectIsMuted(state) && !!state.studio.invited;
...@@ -54,6 +54,20 @@ const selectCanRemoveManager = (state, managerId) => ...@@ -54,6 +54,20 @@ const selectCanRemoveManager = (state, managerId) =>
!selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner; !selectIsMuted(state) && (selectIsAdmin(state) || isManager(state)) && managerId !== state.studio.owner;
const selectCanPromoteCurators = state => !selectIsMuted(state) && isManager(state); const selectCanPromoteCurators = state => !selectIsMuted(state) && isManager(state);
const selectCanTransfer = (state, managerId) => {
// Nobody can transfer a class studio.
// classroomId is loaded only for educator and admin users. Only educators can create class studios,
// so educators and admins are the only users who otherwise would be able to transfer a class studio.
if (state.studio.classroomId !== null) return false;
if (state.studio.managers > 1) { // If there is more than one manager,
if (managerId === state.studio.owner) { // and the selected manager is the current owner/host,
if (isHost(state)) return true; // Owner/host can transfer
if (selectIsAdmin(state)) return true; // Admin can transfer
}
}
return false;
};
const selectCanRemoveProject = (state, creatorUsername, actorId) => { const selectCanRemoveProject = (state, creatorUsername, actorId) => {
if (selectIsMuted(state)) return false; if (selectIsMuted(state)) return false;
...@@ -73,7 +87,7 @@ const selectCanRemoveProject = (state, creatorUsername, actorId) => { ...@@ -73,7 +87,7 @@ const selectCanRemoveProject = (state, creatorUsername, actorId) => {
// We should only show the mute errors to muted users who have any permissions related to the content // We should only show the mute errors to muted users who have any permissions related to the content
// TODO these duplicate the behavior embedded in the non-muted parts of the selectors above, it would be good // TODO these duplicate the behavior embedded in the non-muted parts of the selectors above, it would be good
// to extract this. // to extract this.
const selectShowEditMuteError = state => selectIsMuted(state) && (isCreator(state) || selectIsAdmin(state)); const selectShowEditMuteError = state => selectIsMuted(state) && (isHost(state) || selectIsAdmin(state));
const selectShowProjectMuteError = state => selectIsMuted(state) && const selectShowProjectMuteError = state => selectIsMuted(state) &&
(selectIsAdmin(state) || (selectIsAdmin(state) ||
isManager(state) || isManager(state) ||
...@@ -99,6 +113,7 @@ export { ...@@ -99,6 +113,7 @@ export {
selectCanRemoveCurator, selectCanRemoveCurator,
selectCanRemoveManager, selectCanRemoveManager,
selectCanPromoteCurators, selectCanPromoteCurators,
selectCanTransfer,
selectCanRemoveProject, selectCanRemoveProject,
selectShowCommentsList, selectShowCommentsList,
selectShowCommentsGloballyOffError, selectShowCommentsGloballyOffError,
......
...@@ -4,7 +4,7 @@ const {withAdmin} = require('../lib/admin-requests'); ...@@ -4,7 +4,7 @@ const {withAdmin} = require('../lib/admin-requests');
const api = require('../lib/api'); const api = require('../lib/api');
const log = require('../lib/log'); const log = require('../lib/log');
const {selectUsername, selectToken, selectIsEducator} = require('./session'); const {selectUsername, selectToken, selectIsEducator, selectIsAdmin} = require('./session');
const Status = keyMirror({ const Status = keyMirror({
FETCHED: null, FETCHED: null,
...@@ -28,7 +28,7 @@ const getInitialState = () => ({ ...@@ -28,7 +28,7 @@ const getInitialState = () => ({
owner: null, owner: null,
public: null, public: null,
// BEWARE: classroomId is only loaded if the user is an educator // BEWARE: classroomId is only loaded if the user is an educator or admin
classroomId: null, classroomId: null,
rolesStatus: Status.NOT_FETCHED, rolesStatus: Status.NOT_FETCHED,
...@@ -164,7 +164,7 @@ const getRoles = () => ((dispatch, getState) => { ...@@ -164,7 +164,7 @@ const getRoles = () => ((dispatch, getState) => {
}); });
// Since the user is now loaded, it's a good time to check if the studio is part of a classroom // Since the user is now loaded, it's a good time to check if the studio is part of a classroom
if (selectIsEducator(state)) { if (selectIsEducator(state) || selectIsAdmin(state)) {
api({uri: `/studios/${studioId}/classroom`}, (err, body, res) => { api({uri: `/studios/${studioId}/classroom`}, (err, body, res) => {
// No error states for inability/problems loading classroom, just swallow them // No error states for inability/problems loading classroom, just swallow them
if (!err && res.statusCode === 200 && body) dispatch(setInfo({classroomId: body.id})); if (!err && res.statusCode === 200 && body) dispatch(setInfo({classroomId: body.id}));
......
...@@ -50,6 +50,7 @@ ...@@ -50,6 +50,7 @@
"studio.projectErrors.duplicate": "That project is already in this studio.", "studio.projectErrors.duplicate": "That project is already in this studio.",
"studio.creatorRole": "Studio Creator", "studio.creatorRole": "Studio Creator",
"studio.hostRole": "Studio Host",
"studio.managersHeader": "Managers", "studio.managersHeader": "Managers",
...@@ -88,10 +89,26 @@ ...@@ -88,10 +89,26 @@
"studio.managerThresholdInfo": "This studio has {numberOfManagers} managers. Studios can have a maximum of {managerLimit} managers.", "studio.managerThresholdInfo": "This studio has {numberOfManagers} managers. Studios can have a maximum of {managerLimit} managers.",
"studio.managerThresholdRemoveManagers": "Before you can add another manager, you will need to remove managers until there are fewer than {managerLimit}.", "studio.managerThresholdRemoveManagers": "Before you can add another manager, you will need to remove managers until there are fewer than {managerLimit}.",
"studio.transfer.youAreAboutTo": "You are about to make someone else the studio host.",
"studio.transfer.cannotUndo": "You cannot undo this.",
"studio.transfer.thisMeans": "This means...",
"studio.transfer.noLongerEdit": "You will no longer be able to edit the title, thumbnail, and description",
"studio.transfer.noLongerDelete": "You will no longer be able to delete the studio",
"studio.transfer.whichManager": "Which manager do you want to make the host?",
"studio.transfer.currentHost": "Current Host",
"studio.transfer.newHost": "New Host",
"studio.transfer.confirmWithPassword": "To confirm changing the studio host, please enter your password.",
"studio.transfer.forgotPassword": "Forgot password?",
"studio.transfer.alert.somethingWentWrong": "Something went wrong transferring this studio to a new host.",
"studio.remove": "Remove", "studio.remove": "Remove",
"studio.promote": "Promote", "studio.promote": "Promote",
"studio.transfer": "Change Studio Host",
"studio.cancel": "Cancel", "studio.cancel": "Cancel",
"studio.okay": "Okay", "studio.okay": "Okay",
"studio.next": "Next",
"studio.back": "Back",
"studio.confirm": "Confirm",
"studio.commentsHeader": "Comments", "studio.commentsHeader": "Comments",
"studio.commentsNotAllowed": "Commenting for this studio has been turned off.", "studio.commentsNotAllowed": "Commenting for this studio has been turned off.",
...@@ -138,5 +155,7 @@ ...@@ -138,5 +155,7 @@
"studio.alertCuratorInvited": "Curator invite sent to \"{name}\"", "studio.alertCuratorInvited": "Curator invite sent to \"{name}\"",
"studio.alertManagerPromote": "\"{name}\" is now a manager", "studio.alertManagerPromote": "\"{name}\" is now a manager",
"studio.alertManagerPromoteError": "Something went wrong promoting \"{name}\"", "studio.alertManagerPromoteError": "Something went wrong promoting \"{name}\"",
"studio.alertMemberRemoveError": "Something went wrong removing \"{name}\"" "studio.alertMemberRemoveError": "Something went wrong removing \"{name}\"",
"studio.alertTransfer": "\"{name}\" is now the host",
"studio.alertTransferRateLimit": "You can only change the host once a day. Try again tomorrow."
} }
...@@ -2,7 +2,7 @@ import keyMirror from 'keymirror'; ...@@ -2,7 +2,7 @@ import keyMirror from 'keymirror';
import api from '../../../lib/api'; import api from '../../../lib/api';
import {curators, managers} from './redux-modules'; import {curators, managers} from './redux-modules';
import {selectUsername} from '../../../redux/session'; import {selectUsername, selectToken} from '../../../redux/session';
import {selectStudioId, setRoles, setInfo} from '../../../redux/studio'; import {selectStudioId, setRoles, setInfo} from '../../../redux/studio';
import {withAdmin} from '../../../lib/admin-requests'; import {withAdmin} from '../../../lib/admin-requests';
...@@ -187,6 +187,26 @@ const acceptInvitation = () => ((dispatch, getState) => new Promise((resolve, re ...@@ -187,6 +187,26 @@ const acceptInvitation = () => ((dispatch, getState) => new Promise((resolve, re
}); });
})); }));
const transferHost = (password, newHostName, newHostId) =>
((dispatch, getState) => new Promise((resolve, reject) => {
const state = getState();
const studioId = selectStudioId(state);
const token = selectToken(state);
newHostName = newHostName.trim();
api({
uri: `/studios/${studioId}/transfer/${newHostName}?password=${password}`,
method: 'PUT',
authentication: token,
withCredentials: true,
useCsrf: true
}, (err, body, res) => {
const error = normalizeError(err, body, res);
if (error) return reject(error);
dispatch(setInfo({owner: newHostId}));
return resolve();
});
}));
export { export {
Errors, Errors,
loadManagers, loadManagers,
...@@ -195,5 +215,6 @@ export { ...@@ -195,5 +215,6 @@ export {
acceptInvitation, acceptInvitation,
promoteCurator, promoteCurator,
removeCurator, removeCurator,
removeManager removeManager,
transferHost
}; };
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx';
import TransferHostTile from './transfer-host-tile.jsx';
import Form from '../../../components/forms/form.jsx';
import {managers} from '../lib/redux-modules';
import './transfer-host-modal.scss';
const TransferHostConfirmation = ({
handleBack,
handleTransfer,
items,
hostId,
selectedId
}) => {
const currentHostUsername = items.find(item => item.id === hostId).username;
const currentHostImage = items.find(item => item.id === hostId).profile.images['90x90'];
const newHostUsername = items.find(item => item.id === selectedId).username;
const newHostImage = items.find(item => item.id === selectedId).profile.images['90x90'];
const [passwordInputValue, setPasswordInputValue] = useState('');
const handleSubmit = () => {
handleTransfer(passwordInputValue, newHostUsername, selectedId);
};
const handleChangePasswordInput = e => {
setPasswordInputValue(e.target.value);
};
return (
<ModalInnerContent>
<div className="transfer-outcome">
<div>
<div className="transfer-outcome-label">
<FormattedMessage id="studio.transfer.currentHost" />
</div>
<TransferHostTile
className="transfer-outcome-tile"
key={hostId}
id={hostId}
image={currentHostImage}
username={currentHostUsername}
isCreator={false}
/>
</div>
<img
className="transfer-outcome-arrow"
src="/svgs/studio/r-arrow.svg"
/>
<div>
<div className="transfer-outcome-label">
<FormattedMessage id="studio.transfer.newHost" />
</div>
<TransferHostTile
className="transfer-outcome-tile"
key={selectedId}
id={selectedId}
image={newHostImage}
username={newHostUsername}
isCreator={false}
/>
</div>
</div>
<div className="transfer-password-instruction">
<h2>
<FormattedMessage id="studio.transfer.confirmWithPassword" />
</h2>
</div>
<Form
className="transfer-form"
onSubmit={handleSubmit} // eslint-disable-line react/jsx-no-bind
>
<input
className="transfer-password-input"
required
key="passwordInput"
name="password"
type="password"
value={passwordInputValue}
onChange={handleChangePasswordInput} // eslint-disable-line react/jsx-no-bind
/>
<div className="transfer-forgot-link">
<a
href="/accounts/password_reset/"
>
<FormattedMessage id="studio.transfer.forgotPassword" />
</a>
</div>
<div
className="transfer-host-button-row transfer-host-button-row-split"
>
<button
className="button"
type="button"
onClick={handleBack}
>
<FormattedMessage id="studio.back" />
</button>
<button
className="button"
type="submit"
disabled={passwordInputValue === ''}
>
<FormattedMessage id="studio.confirm" />
</button>
</div>
</Form>
</ModalInnerContent>
);
};
TransferHostConfirmation.propTypes = {
handleBack: PropTypes.func,
handleTransfer: PropTypes.func,
items: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.id,
username: PropTypes.string,
profile: PropTypes.shape({
images: PropTypes.shape({
'90x90': PropTypes.string
})
})
})),
selectedId: PropTypes.number,
hostId: PropTypes.number
};
export default connect(
state => ({
hostId: state.studio.owner,
...managers.selector(state)
})
)(TransferHostConfirmation);
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx';
import AlertComponent from '../../../components/alert/alert-component.jsx';
import errorIcon from '../../../components/alert/icon-alert-error.svg';
import './transfer-host-modal.scss';
const TransferHostInfo = ({
handleClose,
handleNext
}) =>
(<div className="content">
<img
src="/svgs/studio/transfer-host.svg"
className="transfer-host-image"
/>
<ModalInnerContent
className="inner"
>
<div className="transfer-info-title">
<h2>
<FormattedMessage id="studio.transfer.youAreAboutTo" />
</h2>
</div>
<div className="transfer-host-alert-wrapper">
<AlertComponent
className="alert-error transfer-host-alert"
icon={errorIcon}
id="studio.transfer.cannotUndo"
/>
</div>
<span
className="list-header"
>
<FormattedMessage id="studio.transfer.thisMeans" />
</span>
<ul>
<li><FormattedMessage id="studio.transfer.noLongerEdit" /></li>
<li><FormattedMessage id="studio.transfer.noLongerDelete" /></li>
</ul>
<div
className="transfer-host-button-row"
>
<button
className="button cancel-button"
onClick={handleClose}
>
<FormattedMessage id="studio.cancel" />
</button>
<button
className="button next-button"
onClick={handleNext}
>
<FormattedMessage id="studio.next" />
</button>
</div>
</ModalInnerContent>
</div>);
TransferHostInfo.propTypes = {
handleClose: PropTypes.func,
handleNext: PropTypes.func
};
export default TransferHostInfo;
import React, {useState} from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
import keyMirror from 'keymirror';
import Modal from '../../../components/modal/base/modal.jsx';
import ModalTitle from '../../../components/modal/base/modal-title.jsx';
import TransferHostInfo from './transfer-host-info.jsx';
import TransferHostSelection from './transfer-host-selection.jsx';
import TransferHostConfirmation from './transfer-host-confirmation.jsx';
import './transfer-host-modal.scss';
const STEPS = keyMirror({
info: null,
selection: null,
confirmation: null
});
const TransferHostModal = ({
handleClose,
handleTransfer
}) => {
const [step, setStep] = useState(STEPS.info);
const [selectedId, setSelectedId] = useState(null);
return (<Modal
isOpen
className="transfer-host-modal"
onRequestClose={handleClose}
>
<ModalTitle
className="transfer-host-title"
title={<FormattedMessage id="studio.transfer" />}
/>
{step === STEPS.info && <TransferHostInfo
handleClose={handleClose}
handleNext={() => setStep(STEPS.selection)} // eslint-disable-line react/jsx-no-bind
/>}
{step === STEPS.selection && <TransferHostSelection
handleClose={handleClose}
handleNext={() => setStep(STEPS.confirmation)} // eslint-disable-line react/jsx-no-bind
handleBack={() => setStep(STEPS.info)} // eslint-disable-line react/jsx-no-bind
handleSelected={setSelectedId}
selectedId={selectedId}
/>}
{step === STEPS.confirmation && <TransferHostConfirmation
handleClose={handleClose}
handleBack={() => setStep(STEPS.selection)} // eslint-disable-line react/jsx-no-bind
handleTransfer={handleTransfer}
selectedId={selectedId}
/>}
</Modal>);
};
TransferHostModal.propTypes = {
handleClose: PropTypes.func,
handleTransfer: PropTypes.func
};
export default TransferHostModal;
@import "../../../colors";
@import "../../../frameless";
.transfer-host-modal {
.transfer-host-title {
background: $ui-blue;
border-top-left-radius: 12px;
border-top-right-radius: 12px;
padding-top: .75rem;
width: 100%;
height: 3rem;
cursor: pointer;
}
.transfer-info-title {
margin-top: 3rem;
}
h2 {
line-height: 2.5rem;
margin-bottom: 1rem;
}
.list-header {
font-weight: bold;
}
ul {
line-height: 1rem;
margin-top: 0px;
}
.content {
display: flex;
align-items: flex-start;
}
.transfer-host-image {
margin-top: 2rem;
}
.inner {
padding: 1rem;
}
.transfer-host-alert-wrapper {
margin-right: 2rem;
}
.transfer-host-alert-wrapper .alert-wrapper {
position: relative;
display: block;
margin-bottom: 2rem;
}
.transfer-host-alert .alert-msg {
font-size: 1rem;
}
.transfer-host-button-row {
display: flex;
justify-content: flex-end;
padding-top: 1.5rem;
}
.transfer-host-button-row-split {
justify-content: space-between;
}
.transfer-selection-buttons {
padding: 1rem;
}
.button {
margin: 0px;
}
.button:disabled {
background-color: $active-dark-gray;
}
.cancel-button {
background-color: $ui-white;
color: $ui-blue;
box-shadow: 0px 0px 0 1px $ui-blue;
margin-right: 1rem;
}
.next-button {
min-width: 5rem;
}
.transfer-selection-heading {
padding: 1rem;
background: $ui-blue-10percent;
}
.transfer-selection-scroll-pane {
height: 250px;
padding-left: 1rem;
padding-right: 1rem;
background: $ui-blue-10percent;
overflow: auto;
}
.transfer-host-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0,1fr));
@media #{$intermediate-and-smaller} {
& { grid-template-columns: repeat(2, minmax(0,1fr)); }
}
column-gap: 12px;
row-gap: 12px;
margin-bottom: 1rem;
}
.transfer-host-name-selected {
color: white !important;
}
.transfer-selection-icon {
margin: auto 8px;
}
.transfer-host-tile-selected {
background: $ui-aqua;
}
.transfer-password-instruction {
padding: 3rem 3rem 2rem;
}
.transfer-form {
padding: 0px 1rem 1rem;
}
.transfer-password-input {
margin-left: 2rem;
border: 1px solid $ui-border;
border-radius: .5rem;
padding: 0.5rem 1rem;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.col-sm-9 .input {
font-size: 1.5rem;
width: 50%;
}
.transfer-forgot-link {
margin: 0px 2rem 3rem;
}
.transfer-outcome {
background: $ui-blue-10percent;
padding: 2rem 3rem;
display: flex;
}
.transfer-outcome-tile {
width: 220px;
box-shadow: 0px 3px 5px $box-shadow-light-gray;
}
.transfer-outcome-label {
margin-bottom: 0.5rem;
font-weight: bold;
font-size: 12px;
}
.transfer-outcome-arrow {
width: 40px;
margin: auto 3rem 1rem 3rem;
}
}
\ No newline at end of file
import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
import {connect} from 'react-redux';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import ModalInnerContent from '../../../components/modal/base/modal-inner-content.jsx';
import TransferHostTile from './transfer-host-tile.jsx';
import {managers} from '../lib/redux-modules';
import {loadManagers} from '../lib/studio-member-actions';
import './transfer-host-modal.scss';
const TransferHostSelection = ({
handleSelected,
handleNext,
handleBack,
loading,
moreToLoad,
onLoadMore,
items,
hostId,
selectedId
}) => {
useEffect(() => {
if (items.length === 0) onLoadMore();
}, []);
return (
<ModalInnerContent>
<div className="transfer-selection-heading">
<h3>
<FormattedMessage id="studio.transfer.whichManager" />
</h3>
</div>
<div className="transfer-selection-scroll-pane">
<div className="transfer-host-grid">
{items.filter(item => hostId !== item.id).map(item =>
(<TransferHostTile
key={item.username}
// eslint-disable-next-line react/jsx-no-bind
handleSelected={() => handleSelected(item.id)}
id={item.id}
username={item.username}
image={item.profile.images['90x90']}
isCreator={false}
selected={item.id === selectedId}
/>)
)}
{moreToLoad &&
<div className="studio-grid-load-more">
<button
className={classNames('button', {
'mod-mutating': loading
})}
onClick={onLoadMore}
>
<FormattedMessage id="general.loadMore" />
</button>
</div>
}
</div>
</div>
<div
className="transfer-host-button-row transfer-host-button-row-split transfer-selection-buttons"
>
<button
className="button"
onClick={handleBack}
>
<FormattedMessage id="studio.back" />
</button>
<button
className="button next-button"
disabled={selectedId === null}
onClick={handleNext}
>
<FormattedMessage id="studio.next" />
</button>
</div>
</ModalInnerContent>
);
};
TransferHostSelection.propTypes = {
handleBack: PropTypes.func,
handleNext: PropTypes.func,
handleSelected: PropTypes.func,
items: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.id,
username: PropTypes.string,
profile: PropTypes.shape({
images: PropTypes.shape({
'90x90': PropTypes.string
})
})
})),
loading: PropTypes.bool,
moreToLoad: PropTypes.bool,
onLoadMore: PropTypes.func,
selectedId: PropTypes.number,
hostId: PropTypes.number
};
export default connect(
state => ({
hostId: state.studio.owner,
...managers.selector(state)
}),
{
onLoadMore: loadManagers
}
)(TransferHostSelection);
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
const TransferHostTile = ({
className, username, image, selected, handleSelected
}) => (
<div
className={classNames('studio-member-tile', className, {
'transfer-host-tile-selected': selected
})}
onClick={handleSelected}
>
<img
className="studio-member-image"
src={image}
/>
<div className="studio-member-info">
<div
className={classNames('studio-member-name',
{'transfer-host-name-selected': selected}
)}
>
{username}
</div>
</div>
{selected &&
<div className="transfer-selection-icon">
<img src="/svgs/studio/check-icon-white.svg" />
</div>}
</div>
);
TransferHostTile.propTypes = {
className: PropTypes.string,
username: PropTypes.string,
handleSelected: PropTypes.func,
image: PropTypes.string,
selected: PropTypes.bool
};
export default TransferHostTile;
...@@ -7,15 +7,19 @@ import {FormattedMessage} from 'react-intl'; ...@@ -7,15 +7,19 @@ import {FormattedMessage} from 'react-intl';
import PromoteModal from './modals/promote-modal.jsx'; import PromoteModal from './modals/promote-modal.jsx';
import ManagerLimitModal from './modals/manager-limit-modal.jsx'; import ManagerLimitModal from './modals/manager-limit-modal.jsx';
import TransferHostModal from './modals/transfer-host-modal.jsx';
import { import {
selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators selectCanRemoveCurator, selectCanRemoveManager, selectCanPromoteCurators,
selectCanTransfer
} from '../../redux/studio-permissions'; } from '../../redux/studio-permissions';
import {selectStudioTransferLaunched} from '../../redux/session.js';
import { import {
Errors, Errors,
promoteCurator, promoteCurator,
removeCurator, removeCurator,
removeManager removeManager,
transferHost
} from './lib/studio-member-actions'; } from './lib/studio-member-actions';
import {selectStudioHasReachedManagerLimit} from '../../redux/studio'; import {selectStudioHasReachedManagerLimit} from '../../redux/studio';
...@@ -26,11 +30,13 @@ import removeIcon from './icons/remove-icon.svg'; ...@@ -26,11 +30,13 @@ import removeIcon from './icons/remove-icon.svg';
import promoteIcon from './icons/curator-icon.svg'; import promoteIcon from './icons/curator-icon.svg';
const StudioMemberTile = ({ const StudioMemberTile = ({
canRemove, canPromote, onRemove, onPromote, isCreator, hasReachedManagerLimit, // mapState props canRemove, canPromote, onRemove, canTransferHost, onPromote, onTransferHost,
isCreator, hasReachedManagerLimit, // mapState props
username, image // own props username, image // own props
}) => { }) => {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [modalOpen, setModalOpen] = useState(false); const [promoteModalOpen, setPromoteModalOpen] = useState(false);
const [transferHostModalOpen, setTransferHostModalOpen] = useState(false);
const [managerLimitReached, setManagerLimitReached] = useState(false); const [managerLimitReached, setManagerLimitReached] = useState(false);
const {errorAlert, successAlert} = useAlertContext(); const {errorAlert, successAlert} = useAlertContext();
const userUrl = `/users/${username}`; const userUrl = `/users/${username}`;
...@@ -49,12 +55,12 @@ const StudioMemberTile = ({ ...@@ -49,12 +55,12 @@ const StudioMemberTile = ({
>{username}</a> >{username}</a>
{isCreator && <div className="studio-member-role"><FormattedMessage id="studio.creatorRole" /></div>} {isCreator && <div className="studio-member-role"><FormattedMessage id="studio.creatorRole" /></div>}
</div> </div>
{(canRemove || canPromote) && {(canRemove || canPromote || canTransferHost) &&
<OverflowMenu> <OverflowMenu>
{canPromote && <li> {canPromote && <li>
<button <button
onClick={() => { onClick={() => {
setModalOpen(true); setPromoteModalOpen(true);
}} }}
> >
<img src={promoteIcon} /> <img src={promoteIcon} />
...@@ -82,15 +88,26 @@ const StudioMemberTile = ({ ...@@ -82,15 +88,26 @@ const StudioMemberTile = ({
<FormattedMessage id="studio.remove" /> <FormattedMessage id="studio.remove" />
</button> </button>
</li>} </li>}
{canTransferHost && <li>
<button
className="studio-member-tile-menu-wide"
onClick={() => {
setTransferHostModalOpen(true);
}}
>
<img src={promoteIcon} />
<FormattedMessage id="studio.transfer" />
</button>
</li>}
</OverflowMenu> </OverflowMenu>
} }
{modalOpen && {promoteModalOpen &&
((hasReachedManagerLimit || managerLimitReached) ? ((hasReachedManagerLimit || managerLimitReached) ?
<ManagerLimitModal <ManagerLimitModal
handleClose={() => setModalOpen(false)} handleClose={() => setPromoteModalOpen(false)}
/> : /> :
<PromoteModal <PromoteModal
handleClose={() => setModalOpen(false)} handleClose={() => setPromoteModalOpen(false)}
handlePromote={() => { handlePromote={() => {
onPromote(username) onPromote(username)
.then(() => { .then(() => {
...@@ -102,7 +119,7 @@ const StudioMemberTile = ({ ...@@ -102,7 +119,7 @@ const StudioMemberTile = ({
.catch(error => { .catch(error => {
if (error === Errors.MANAGER_LIMIT) { if (error === Errors.MANAGER_LIMIT) {
setManagerLimitReached(true); setManagerLimitReached(true);
setModalOpen(true); setPromoteModalOpen(true);
} else { } else {
errorAlert({ errorAlert({
id: 'studio.alertManagerPromoteError', id: 'studio.alertManagerPromoteError',
...@@ -115,6 +132,27 @@ const StudioMemberTile = ({ ...@@ -115,6 +132,27 @@ const StudioMemberTile = ({
/> />
) )
} }
{transferHostModalOpen &&
<TransferHostModal
handleClose={() => setTransferHostModalOpen(false)}
handleTransfer={(password, newHostUsername, newHostUsernameId) => {
onTransferHost(password, newHostUsername, newHostUsernameId)
.then(() => {
setTransferHostModalOpen(false);
successAlert({
id: 'studio.alertTransfer',
values: {name: newHostUsername}
});
})
.catch(() => {
setTransferHostModalOpen(false);
errorAlert({
id: 'studio.transfer.alert.somethingWentWrong'
});
});
}}
/>
}
</div> </div>
); );
}; };
...@@ -122,8 +160,10 @@ const StudioMemberTile = ({ ...@@ -122,8 +160,10 @@ const StudioMemberTile = ({
StudioMemberTile.propTypes = { StudioMemberTile.propTypes = {
canRemove: PropTypes.bool, canRemove: PropTypes.bool,
canPromote: PropTypes.bool, canPromote: PropTypes.bool,
canTransferHost: PropTypes.bool,
onRemove: PropTypes.func, onRemove: PropTypes.func,
onPromote: PropTypes.func, onPromote: PropTypes.func,
onTransferHost: PropTypes.func,
username: PropTypes.string, username: PropTypes.string,
image: PropTypes.string, image: PropTypes.string,
isCreator: PropTypes.bool, isCreator: PropTypes.bool,
...@@ -134,10 +174,13 @@ const ManagerTile = connect( ...@@ -134,10 +174,13 @@ const ManagerTile = connect(
(state, ownProps) => ({ (state, ownProps) => ({
canRemove: selectCanRemoveManager(state, ownProps.id), canRemove: selectCanRemoveManager(state, ownProps.id),
canPromote: false, canPromote: false,
canTransferHost: selectCanTransfer(state, ownProps.id) &&
selectStudioTransferLaunched(state),
isCreator: state.studio.owner === ownProps.id isCreator: state.studio.owner === ownProps.id
}), }),
{ {
onRemove: removeManager onRemove: removeManager,
onTransferHost: transferHost
} }
)(StudioMemberTile); )(StudioMemberTile);
......
...@@ -399,6 +399,11 @@ $radius: 8px; ...@@ -399,6 +399,11 @@ $radius: 8px;
background: transparent; background: transparent;
border: none; border: none;
} }
.studio-member-tile-menu-wide {
white-space: nowrap;
padding-right: 2rem !important;
}
} }
.studio-members + .studio-members { .studio-members + .studio-members {
......
<svg width="28" height="20" viewBox="-2 -1 15 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.40571 6.50912C1.50472 6.50912 0.775271 7.23857 0.775271 8.13956C0.775271 9.04055 1.50472 9.77 2.40571 9.77C3.3067 9.77 4.03615 9.04055 4.03615 8.13956C4.03615 7.23857 3.3067 6.50912 2.40571 6.50912ZM3.34168 5.02359C2.92699 5.9523 1.88444 5.9523 1.46975 5.02359L0.145744 2.07519C-0.268945 1.15289 0.250665 0 1.08171 0H3.72972C4.56076 0 5.08037 1.15289 4.66568 2.07519L3.34168 5.02359Z" fill="#FF8C1A"/>
</svg>
<svg width="20" height="20" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><path d="M10.158 16.994c-.311 0-.602-.119-.818-.333L5 12.319a1.182 1.182 0 0 1-.24-1.287c.186-.43.595-.695 1.07-.695h1.646l.84-6.042a1.887 1.887 0 0 1 2.118-1.614c.854.134 1.514.8 1.617 1.62l.862 6.036h1.575c.48 0 .907.282 1.088.717.18.439.083.924-.259 1.265l-4.341 4.342a1.151 1.151 0 0 1-.817.333" id="a"/></defs><use fill="#4C97FF" transform="rotate(-90 10.164 9.83)" xlink:href="#a" fill-rule="evenodd"/></svg>
\ No newline at end of file
<svg width="206" height="323" viewBox="0 0 206 323" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="206" height="323">
<rect width="206" height="323" fill="#C4C4C4"/>
</mask>
<g mask="url(#mask0)">
<path opacity="0.45" fill-rule="evenodd" clip-rule="evenodd" d="M57.1698 75.3667C119.614 75.3667 180.669 157.21 162.54 212.897C144.41 268.583 110.255 301.467 47.8103 301.467C-14.6341 301.467 -67.2962 234.576 -77.5995 162.852C-87.9028 91.1287 -5.27449 75.3667 57.1698 75.3667Z" fill="#FFBF00"/>
<path opacity="0.45" d="M154.499 271.613C141.292 275.215 125.324 290.465 129.612 300.739C133.899 311.014 148.067 308.614 154.499 304.403C164.309 297.98 169.704 290.21 173.137 283.848C176.571 272.592 167.707 268.011 154.499 271.613Z" fill="#FFBF00"/>
<path d="M109.228 147.374C111.25 146.822 112.833 145.262 113.367 143.244L114.607 138.658C115.198 136.488 118.269 136.488 118.86 138.658L120.1 143.244C120.653 145.262 122.217 146.841 124.239 147.374L128.836 148.611C131.01 149.201 131.01 152.265 128.836 152.855L124.239 154.092C122.217 154.644 120.634 156.205 120.1 158.222L118.86 162.809C118.269 164.979 115.198 164.979 114.607 162.809L113.367 158.222C112.814 156.205 111.25 154.625 109.228 154.092L104.631 152.855C102.456 152.265 102.456 149.201 104.631 148.611L109.228 147.374Z" fill="#FF8C1A"/>
<path d="M166.619 101.223C167.847 100.887 168.808 99.94 169.132 98.7151L169.885 95.9303C170.244 94.6131 172.108 94.6131 172.467 95.9303L173.22 98.7151C173.556 99.94 174.505 100.899 175.733 101.223L178.524 101.974C179.844 102.332 179.844 104.192 178.524 104.55L175.733 105.302C174.505 105.637 173.544 106.584 173.22 107.809L172.467 110.594C172.108 111.911 170.244 111.911 169.885 110.594L169.132 107.809C168.796 106.584 167.847 105.625 166.619 105.302L163.828 104.55C162.508 104.192 162.508 102.332 163.828 101.974L166.619 101.223Z" fill="#FF8C1A"/>
<path d="M48.9051 37.6014C50.1327 37.2663 51.0939 36.3188 51.4181 35.0939L52.1709 32.3091C52.5299 30.9918 54.3944 30.9918 54.7534 32.3091L55.5061 35.0939C55.8419 36.3188 56.7916 37.2778 58.0191 37.6014L60.8101 38.3525C62.1303 38.7107 62.1303 40.571 60.8101 40.9293L58.0191 41.6803C56.7916 42.0154 55.8304 42.963 55.5061 44.1878L54.7534 46.9726C54.3944 48.2899 52.5299 48.2899 52.1709 46.9726L51.4181 44.1878C51.0823 42.963 50.1327 42.0039 48.9051 41.6803L46.1142 40.9293C44.794 40.571 44.794 38.7107 46.1142 38.3525L48.9051 37.6014Z" fill="#FF8C1A"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M127.045 21.2856C127.16 21.1631 127.254 21.0263 127.324 20.8756C128.125 19.1612 125.59 16.2891 121.663 14.4616C117.735 12.6341 113.901 12.5427 113.099 14.2571C113.029 14.4079 112.984 14.5673 112.964 14.7342L112.892 14.7007L43.781 162.583C42.0064 166.38 37.6087 168.12 33.5809 166.956C12.3419 160.817 -6.9133 164.521 -17.5507 187.283C-29.5732 213.008 -13.2696 234.633 13.4903 247.084C40.2502 259.535 68.1618 256.322 79.3551 232.371C89.8032 210.014 80.0619 192.274 61.2653 179.762C57.7657 177.432 56.2595 172.938 58.0399 169.128L108.047 62.1249L139.982 76.9837C141.985 77.9156 144.367 77.0486 145.302 75.0473L156.425 51.247C157.36 49.2456 156.495 46.8677 154.492 45.9358L122.557 31.077L127.117 21.3191L127.045 21.2856ZM21.012 223.698C23.5333 223.086 26.3479 224.396 27.4991 226.716C29.6332 231.017 33.1916 234.651 37.8904 236.838C48.8793 241.95 61.9422 237.206 67.0664 226.242C72.1906 215.277 67.4361 202.243 56.4472 197.13C51.7484 194.944 46.6712 194.56 41.9972 195.693C39.476 196.305 36.6614 194.995 35.5102 192.675C33.376 188.374 29.8176 184.74 25.1188 182.554C14.1299 177.441 1.06704 182.185 -4.05717 193.149C-9.18138 204.114 -4.42689 217.148 6.56202 222.261C11.2608 224.447 16.338 224.831 21.012 223.698Z" fill="#FFBF00"/>
<path d="M-6.37977 204.619C-6.63611 201.027 -6.00652 197.321 -4.37292 193.825C0.751294 182.86 13.8142 178.116 24.8031 183.229C29.533 185.43 33.1073 189.098 35.2366 193.436C36.3559 195.717 39.1183 197.002 41.5887 196.392C46.2882 195.231 51.4015 195.605 56.1315 197.806C62.4675 200.754 66.7309 206.335 68.2231 212.635C67.6754 204.959 63.0827 197.8 55.5971 194.317C50.8671 192.117 45.7539 191.742 41.0543 192.903C38.5839 193.514 35.8215 192.228 34.7022 189.948C32.5729 185.609 28.9986 181.942 24.2687 179.741C13.2798 174.628 0.216909 179.372 -4.9073 190.337C-7.07697 194.979 -7.47558 199.993 -6.37977 204.619Z" fill="#CC9900"/>
<path d="M-20.8038 208.14C-19.1334 224.807 -5.25479 238.599 14.0857 247.597C40.8456 260.048 68.7571 256.835 79.9505 232.884C84.1145 223.974 85.0717 215.797 83.5268 208.392C84.1648 214.854 82.9365 221.862 79.4161 229.395C68.2227 253.347 40.3112 256.559 13.5513 244.109C-4.42293 235.746 -17.6797 223.243 -20.8038 208.14Z" fill="#CC9900"/>
<path d="M-12.9281 187.813C-6.51721 174.095 10.7994 164.834 29.8539 171.54" stroke="white" stroke-width="3" stroke-linecap="round"/>
<line x1="1.5" y1="-1.5" x2="7.31257" y2="-1.5" transform="matrix(-0.423386 0.90595 -0.906665 -0.421852 114.315 18.6029)" stroke="white" stroke-width="3" stroke-linecap="round"/>
<line x1="1.5" y1="-1.5" x2="62.1464" y2="-1.5" transform="matrix(-0.423386 0.90595 -0.906665 -0.421852 108.512 31.0221)" stroke="white" stroke-width="3" stroke-linecap="round"/>
<path d="M116.048 65.8478L107.315 61.7843L105.38 60.884L119.89 29.8361L152.784 45.1412C149.606 49.4222 140.747 57.7921 130.741 57.0243C120.734 56.2565 115.818 62.1406 116.048 65.8478Z" fill="#E6AC00"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M127.315 20.8927C127.246 21.0367 127.155 21.1678 127.044 21.2856L127.116 21.3191L57.973 169.269C57.5544 170.165 57.3215 171.1 57.2568 172.034C56.495 170.052 56.1157 166.798 57.0671 164.762L125.789 17.2112C127.141 18.529 127.774 19.8796 127.331 20.8584L127.315 20.8927Z" fill="#CC9900"/>
</g>
</svg>
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