mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2025-02-17 08:31:23 -05:00
Merge pull request #5959 from ericrosenbaum/transfer-modal3
Studio host transfer modal additional work
This commit is contained in:
commit
7f4ef784c2
8 changed files with 139 additions and 45 deletions
|
@ -100,6 +100,8 @@
|
|||
"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.transfer.alert.wasntTheRightPassword": "Hmm, that wasn’t the right password.",
|
||||
"studio.transfer.alert.thisUserCannotBecomeHost": "This user cannot become the host — try transfering to another manager",
|
||||
|
||||
"studio.remove": "Remove",
|
||||
"studio.promote": "Promote",
|
||||
|
|
|
@ -15,6 +15,7 @@ const Errors = keyMirror({
|
|||
UNKNOWN_USERNAME: null,
|
||||
RATE_LIMIT: null,
|
||||
MANAGER_LIMIT: null,
|
||||
CANNOT_BE_HOST: null,
|
||||
UNHANDLED: null
|
||||
});
|
||||
|
||||
|
@ -28,6 +29,7 @@ const normalizeError = (err, body, res) => {
|
|||
if (res.statusCode === 403 && body.mute_status) return Errors.USER_MUTED;
|
||||
if (res.statusCode === 401 || res.statusCode === 403) return Errors.PERMISSION;
|
||||
if (res.statusCode === 404) return Errors.UNKNOWN_USERNAME;
|
||||
if (res.statusCode === 409) return Errors.CANNOT_BE_HOST;
|
||||
if (res.statusCode === 429) return Errors.RATE_LIMIT;
|
||||
if (res.statusCode !== 200) return Errors.SERVER;
|
||||
if (body && body.status === 'error') {
|
||||
|
@ -194,11 +196,12 @@ const transferHost = (password, newHostName, newHostId) =>
|
|||
const token = selectToken(state);
|
||||
newHostName = newHostName.trim();
|
||||
api({
|
||||
uri: `/studios/${studioId}/transfer/${newHostName}?password=${password}`,
|
||||
uri: `/studios/${studioId}/transfer/${newHostName}`,
|
||||
method: 'PUT',
|
||||
authentication: token,
|
||||
withCredentials: true,
|
||||
useCsrf: true
|
||||
useCsrf: true,
|
||||
json: {password: password}
|
||||
}, (err, body, res) => {
|
||||
const error = normalizeError(err, body, res);
|
||||
if (error) return reject(error);
|
||||
|
|
|
@ -2,19 +2,26 @@ import React, {useState} from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import {connect} from 'react-redux';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
const {injectIntl, intlShape} = require('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 ValidationMessage from '../../../components/forms/validation-message.jsx';
|
||||
|
||||
import {managers} from '../lib/redux-modules';
|
||||
|
||||
import {useAlertContext} from '../../../components/alert/alert-context';
|
||||
import {Errors, transferHost} from '../lib/studio-member-actions';
|
||||
|
||||
import './transfer-host-modal.scss';
|
||||
|
||||
const TransferHostConfirmation = ({
|
||||
handleBack,
|
||||
handleTransfer,
|
||||
handleClose,
|
||||
handleTransferHost,
|
||||
intl,
|
||||
items,
|
||||
hostId,
|
||||
selectedId
|
||||
|
@ -24,12 +31,45 @@ const TransferHostConfirmation = ({
|
|||
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 [validationError, setValidationError] = useState(null);
|
||||
const {errorAlert, successAlert} = useAlertContext();
|
||||
|
||||
const errorToMessageId = error => {
|
||||
switch (error) {
|
||||
case Errors.RATE_LIMIT: return 'studio.alertTransferRateLimit';
|
||||
case Errors.CANNOT_BE_HOST: return 'studio.transfer.alert.thisUserCannotBecomeHost';
|
||||
default: return 'studio.transfer.alert.somethingWentWrong';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
handleTransferHost(passwordInputValue, newHostUsername, selectedId)
|
||||
.then(() => {
|
||||
handleClose();
|
||||
successAlert({
|
||||
id: 'studio.alertTransfer',
|
||||
values: {name: newHostUsername}
|
||||
});
|
||||
})
|
||||
.catch(e => {
|
||||
// For password errors, show validation alert without closing the modal
|
||||
if (e === Errors.PERMISSION) {
|
||||
setValidationError(e);
|
||||
return;
|
||||
}
|
||||
// For other errors, close the modal and show an alert
|
||||
handleClose();
|
||||
errorAlert({
|
||||
id: errorToMessageId(e)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleChangePasswordInput = e => {
|
||||
setPasswordInputValue(e.target.value);
|
||||
setValidationError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalInnerContent>
|
||||
<div className="transfer-outcome">
|
||||
|
@ -73,15 +113,22 @@ const TransferHostConfirmation = ({
|
|||
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-password-row">
|
||||
<input
|
||||
className="transfer-password-input"
|
||||
required
|
||||
key="passwordInput"
|
||||
name="password"
|
||||
type="password"
|
||||
value={passwordInputValue}
|
||||
onChange={handleChangePasswordInput} // eslint-disable-line react/jsx-no-bind
|
||||
/>
|
||||
{validationError && <ValidationMessage
|
||||
className="transfer-password-validation"
|
||||
message={intl.formatMessage({id: 'studio.transfer.alert.wasntTheRightPassword'})}
|
||||
mode="error"
|
||||
/>}
|
||||
</div>
|
||||
<div className="transfer-forgot-link">
|
||||
<a
|
||||
href="/accounts/password_reset/"
|
||||
|
@ -114,7 +161,8 @@ const TransferHostConfirmation = ({
|
|||
|
||||
TransferHostConfirmation.propTypes = {
|
||||
handleBack: PropTypes.func,
|
||||
handleTransfer: PropTypes.func,
|
||||
handleClose: PropTypes.func,
|
||||
intl: intlShape,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.id,
|
||||
username: PropTypes.string,
|
||||
|
@ -124,13 +172,18 @@ TransferHostConfirmation.propTypes = {
|
|||
})
|
||||
})
|
||||
})),
|
||||
handleTransferHost: PropTypes.func,
|
||||
selectedId: PropTypes.number,
|
||||
hostId: PropTypes.number
|
||||
};
|
||||
|
||||
export default connect(
|
||||
const connectedConfirmationStep = connect(
|
||||
state => ({
|
||||
hostId: state.studio.owner,
|
||||
...managers.selector(state)
|
||||
})
|
||||
}), {
|
||||
handleTransferHost: transferHost
|
||||
}
|
||||
)(TransferHostConfirmation);
|
||||
|
||||
export default injectIntl(connectedConfirmationStep);
|
||||
|
|
|
@ -19,8 +19,7 @@ const STEPS = keyMirror({
|
|||
});
|
||||
|
||||
const TransferHostModal = ({
|
||||
handleClose,
|
||||
handleTransfer
|
||||
handleClose
|
||||
}) => {
|
||||
const [step, setStep] = useState(STEPS.info);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
|
@ -47,15 +46,13 @@ const TransferHostModal = ({
|
|||
{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
|
||||
handleClose: PropTypes.func
|
||||
};
|
||||
|
||||
export default TransferHostModal;
|
||||
|
|
|
@ -140,9 +140,19 @@
|
|||
border-radius: .5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.transfer-password-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.transfer-password-validation {
|
||||
position: relative;
|
||||
transform: translate(1rem, 0);
|
||||
}
|
||||
|
||||
.col-sm-9 .input {
|
||||
font-size: 1.5rem;
|
||||
width: 50%;
|
||||
|
|
|
@ -18,8 +18,7 @@ import {
|
|||
Errors,
|
||||
promoteCurator,
|
||||
removeCurator,
|
||||
removeManager,
|
||||
transferHost
|
||||
removeManager
|
||||
} from './lib/studio-member-actions';
|
||||
|
||||
import {selectStudioHasReachedManagerLimit} from '../../redux/studio';
|
||||
|
@ -30,7 +29,7 @@ import removeIcon from './icons/remove-icon.svg';
|
|||
import promoteIcon from './icons/curator-icon.svg';
|
||||
|
||||
const StudioMemberTile = ({
|
||||
canRemove, canPromote, onRemove, canTransferHost, onPromote, onTransferHost,
|
||||
canRemove, canPromote, onRemove, canTransferHost, onPromote,
|
||||
isCreator, hasReachedManagerLimit, // mapState props
|
||||
username, image // own props
|
||||
}) => {
|
||||
|
@ -135,22 +134,6 @@ 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>
|
||||
|
@ -163,7 +146,6 @@ StudioMemberTile.propTypes = {
|
|||
canTransferHost: PropTypes.bool,
|
||||
onRemove: PropTypes.func,
|
||||
onPromote: PropTypes.func,
|
||||
onTransferHost: PropTypes.func,
|
||||
username: PropTypes.string,
|
||||
image: PropTypes.string,
|
||||
isCreator: PropTypes.bool,
|
||||
|
@ -179,8 +161,7 @@ const ManagerTile = connect(
|
|||
isCreator: state.studio.owner === ownProps.id
|
||||
}),
|
||||
{
|
||||
onRemove: removeManager,
|
||||
onTransferHost: transferHost
|
||||
onRemove: removeManager
|
||||
}
|
||||
)(StudioMemberTile);
|
||||
|
||||
|
|
|
@ -7,7 +7,8 @@ import {
|
|||
removeCurator,
|
||||
inviteCurator,
|
||||
promoteCurator,
|
||||
acceptInvitation
|
||||
acceptInvitation,
|
||||
transferHost
|
||||
} from '../../../src/views/studio/lib/studio-member-actions';
|
||||
import {managers, curators} from '../../../src/views/studio/lib/redux-modules';
|
||||
import {reducers, initialState} from '../../../src/views/studio/studio-redux';
|
||||
|
@ -399,4 +400,26 @@ describe('acceptInvitation', () => {
|
|||
expect(state.studio.invited).toBe(true);
|
||||
expect(state.studio.curator).toBe(false);
|
||||
});
|
||||
|
||||
describe('transferHost', () => {
|
||||
beforeEach(() => {
|
||||
store = configureStore(reducers, {
|
||||
...initialState,
|
||||
studio: {
|
||||
id: 123123,
|
||||
managers: 3
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('transfers the host on success', async () => {
|
||||
api.mockImplementation((opts, callback) => {
|
||||
callback(null, {}, {statusCode: 200});
|
||||
});
|
||||
await store.dispatch(transferHost('password', 'newHostName', 'newHostId'));
|
||||
const state = store.getState();
|
||||
expect(api.mock.calls[0][0].uri).toBe('/studios/123123/transfer/newHostName');
|
||||
expect(state.studio.owner).toBe('newHostId');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,6 +15,7 @@ import {
|
|||
selectCanRemoveManager,
|
||||
selectCanPromoteCurators,
|
||||
selectCanRemoveProject,
|
||||
selectCanTransfer,
|
||||
selectShowCommentsList,
|
||||
selectShowCommentsGloballyOffError,
|
||||
selectShowProjectMuteError,
|
||||
|
@ -432,6 +433,30 @@ describe('studio members', () => {
|
|||
expect(selectCanInviteCurators(state)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('can transfer host', () => {
|
||||
test.each([
|
||||
['admin', true],
|
||||
['curator', false],
|
||||
['manager', false],
|
||||
['creator', true],
|
||||
['logged in', false],
|
||||
['unconfirmed', false],
|
||||
['logged out', false],
|
||||
['muted creator', true], // Muted users do not see the transfer UI
|
||||
['muted logged in', false]
|
||||
])('%s: %s', (role, expected) => {
|
||||
setStateByRole(role);
|
||||
state.studio = {...state.studio, managers: 2, classroomId: null};
|
||||
// Only admin and host see the option to transfer the current host
|
||||
expect(selectCanTransfer(state, state.studio.owner)).toBe(expected);
|
||||
// Nobody sees the option to transfer a manager who is not the host
|
||||
expect(selectCanTransfer(state, 123)).toBe(false);
|
||||
// Nobody can transfer classroom studios
|
||||
state.studio = {...state.studio, classroomId: 1};
|
||||
expect(selectCanTransfer(state, state.studio.owner)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('studio mute errors', () => {
|
||||
|
|
Loading…
Reference in a new issue