Merge pull request #5959 from ericrosenbaum/transfer-modal3

Studio host transfer modal additional work
This commit is contained in:
Eric Rosenbaum 2021-08-24 10:40:53 -04:00 committed by GitHub
commit 7f4ef784c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 139 additions and 45 deletions

View file

@ -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 wasnt the right password.",
"studio.transfer.alert.thisUserCannotBecomeHost": "This user cannot become the host — try transfering to another manager",
"studio.remove": "Remove",
"studio.promote": "Promote",

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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%;

View file

@ -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);

View file

@ -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');
});
});
});

View file

@ -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', () => {