Merge PR #106 add/masquerade-username

* Commits:
  Add "masquerade as specific student" support
This commit is contained in:
stvn
2020-08-06 12:06:47 -07:00
6 changed files with 248 additions and 43 deletions

View File

@@ -51,23 +51,21 @@ export default function InstructorToolbar(props) {
return (
<div className="bg-primary text-light">
<div className="container-fluid py-3 d-md-flex justify-content-end align-items-center">
<div className="flex-grow-1">
<div className="align-items-center flex-grow-1 d-md-flex mx-1 my-1">
<MasqueradeWidget courseId={courseId} />
</div>
{urlLms && (
<div className="flex-shrink-0">
<div className="flex-shrink-0 mx-1 my-1">
<a className="btn d-block btn-outline-light" href={urlLms}>View in the existing experience</a>
</div>
)}
&nbsp;
{urlStudio && (
<div className="flex-shrink-0">
<div className="flex-shrink-0 mx-1 my-1">
<a className="btn d-block btn-outline-light" href={urlStudio}>View in Studio</a>
</div>
)}
&nbsp;
{urlInsights && (
<div className="flex-shrink-0">
<div className="flex-shrink-0 mx-1 my-1">
<a className="btn d-block btn-outline-light" href={urlInsights}>View in Insights</a>
</div>
)}

View File

@@ -0,0 +1,60 @@
import React, {
Component,
} from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Input } from '@edx/paragon';
import messages from './messages';
class MasqueradeUserNameInput extends Component {
onError(...args) {
return this.props.onError(...args);
}
onKeyPress(event) {
if (event.key === 'Enter') {
return this.onSubmit(event);
}
return true;
}
onSubmit(event) {
const payload = {
role: 'student',
user_name: event.target.value,
};
this.props.onSubmit(payload).then((data) => {
if (data && data.success) {
global.location.reload();
} else {
const error = (data && data.error) || '';
this.onError(error);
}
}).catch(() => {
const message = this.props.intl.formatMessage(messages['userName.error.generic']);
this.onError(message);
});
return true;
}
render() {
return (
<Input
autoFocus
className="flex-shrink-1"
defaultValue=""
label={this.props.intl.formatMessage(messages['userName.input.label'])}
onKeyPress={(event) => this.onKeyPress(event)}
placeholder={this.props.intl.formatMessage(messages['userName.input.placeholder'])}
type="text"
/>
);
}
}
MasqueradeUserNameInput.propTypes = {
intl: intlShape.isRequired,
onError: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
};
export default injectIntl(MasqueradeUserNameInput);

View File

@@ -2,10 +2,21 @@ import React, {
Component,
} from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import {
ALERT_TYPES,
UserMessagesContext,
} from '../../generic/user-messages';
import MasqueradeUserNameInput from './MasqueradeUserNameInput';
import MasqueradeWidgetOption from './MasqueradeWidgetOption';
import { getMasqueradeOptions } from './data/api';
import {
getMasqueradeOptions,
postMasqueradeOptions,
} from './data/api';
import messages from './messages';
class MasqueradeWidget extends Component {
constructor(props) {
@@ -13,33 +24,100 @@ class MasqueradeWidget extends Component {
this.courseId = props.courseId;
this.state = {
options: [],
shouldShowUserNameInput: false,
};
}
componentDidMount() {
getMasqueradeOptions(this.courseId).then((data) => {
if (data.success) {
const options = this.parseAvailableOptions(data);
this.setState({
options,
});
this.onSuccess(data);
} else {
// This was explicitly denied by the backend;
// assume it's disabled/unavailable.
// eslint-disable-next-line no-console
console.warn('Unable to get masquerade options', data);
this.onError('Unable to get masquerade options');
}
}).catch((response) => {
// There's not much we can do to recover;
// if we can't fetch masquerade options,
// assume it's disabled/unavailable.
// eslint-disable-next-line no-console
console.error('Unable to get masquerade options', response);
});
}
parseAvailableOptions(payload) {
const data = payload || {};
onError(message) {
if (message) {
this.errorAlertId = this.context.add({
text: message,
topic: 'course',
type: ALERT_TYPES.ERROR,
dismissible: false,
});
}
}
async onSubmit(payload) {
this.context.remove(this.errorAlertId);
const options = await postMasqueradeOptions(this.courseId, payload);
return options;
}
onSuccess(data) {
const options = this.parseAvailableOptions(data);
this.setState({
options,
});
const active = data.active || {};
const message = this.getStatusMessage(active);
if (message) {
this.context.add({
text: message,
topic: 'course',
type: ALERT_TYPES.INFO,
dismissible: false,
});
}
}
getStatusMessage(active) {
const {
groupName,
} = active;
let message = '';
if (active.userName) {
message = this.props.intl.formatMessage(messages['status.userName'], {
userName: active.userName,
});
} else if (groupName) {
message = this.props.intl.formatMessage(messages['status.groupName'], {
groupName,
});
} else if (active.role === 'student') {
message = this.props.intl.formatMessage(messages['status.learner']);
}
return message;
}
toggle(show) {
let shouldShow;
if (show === undefined) {
shouldShow = !this.state.shouldShowUserNameInput;
} else {
shouldShow = show;
}
this.setState({
shouldShowUserNameInput: shouldShow,
});
}
parseAvailableOptions(postData) {
const data = postData || {};
const active = data.active || {};
const available = data.available || [];
const options = available.map((group) => (
<MasqueradeWidgetOption
courseId={this.courseId}
groupId={group.groupId}
groupName={group.name}
key={group.name}
@@ -47,6 +125,9 @@ class MasqueradeWidget extends Component {
selected={active}
userName={group.userName}
userPartitionId={group.userPartitionId}
userNameInput={this.userNameInput}
userNameInputToggle={(...args) => this.toggle(...args)}
onSubmit={(payload) => this.onSubmit(payload)}
/>
));
return options;
@@ -57,18 +138,34 @@ class MasqueradeWidget extends Component {
options,
} = this.state;
return (
<Dropdown>
<Dropdown.Button>
View this course as
</Dropdown.Button>
<Dropdown.Menu>
{options}
</Dropdown.Menu>
</Dropdown>
<>
<Dropdown
className="flex-shrink-1 mx-1 my-1"
style={{ textAlign: 'center' }}
>
<Dropdown.Button>
View this course as
</Dropdown.Button>
<Dropdown.Menu>
{options}
</Dropdown.Menu>
</Dropdown>
{this.state.shouldShowUserNameInput && (
<MasqueradeUserNameInput
className="flex-shrink-0 mx-1 my-1"
label="test"
onError={(errorMessage) => this.onError(errorMessage)}
onSubmit={(payload) => this.onSubmit(payload)}
ref={(input) => { this.userNameInput = input; }}
/>
)}
</>
);
}
}
MasqueradeWidget.propTypes = {
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default MasqueradeWidget;
MasqueradeWidget.contextType = UserMessagesContext;
export default injectIntl(MasqueradeWidget);

View File

@@ -4,18 +4,28 @@ import React, {
import PropTypes from 'prop-types';
import { Dropdown } from '@edx/paragon';
import { postMasqueradeOptions } from './data/api';
class MasqueradeWidgetOption extends Component {
handleClick() {
onClick(event) {
// TODO: Remove this hack when we upgrade Paragon
// Note: The current version of Paragon does _not_ close dropdown components
// automatically (or easily programmatically) when you click on an item.
// We can simulate this behavior by programmatically clicking the
// toggle button on behalf of the user.
// The newest version of Paragon already contains this behavior,
// so we can remove this when we upgrade to that point.
event.target.parentNode.parentNode.click();
const {
courseId,
groupId,
role,
userName,
userPartitionId,
userNameInputToggle,
} = this.props;
const payload = {};
if (userName || userName === '') {
userNameInputToggle(true);
return false;
}
if (role) {
payload.role = role;
}
@@ -23,21 +33,24 @@ class MasqueradeWidgetOption extends Component {
payload.group_id = parseInt(groupId, 10);
payload.user_partition_id = parseInt(userPartitionId, 10);
}
if (userName) {
payload.user_name = userName;
}
postMasqueradeOptions(courseId, payload).then(() => {
this.props.onSubmit(payload).then(() => {
global.location.reload();
});
return true;
}
isSelected() {
const selected = this.props.selected || {};
const isEqual = (
selected.userPartitionId === (this.props.userPartitionId || null)
&& selected.groupId === (this.props.groupId || null)
&& selected.role === this.props.role
);
/* eslint-disable arrow-body-style */
const isEqual = [
'groupId',
'role',
'userName',
'userPartitionId',
].reduce((accumulator, currentValue) => {
return accumulator && (
this.props[currentValue] === this.props.selected[currentValue]
);
}, true);
return isEqual;
}
@@ -57,7 +70,7 @@ class MasqueradeWidgetOption extends Component {
<Dropdown.Item
className={className}
href="#"
onClick={(event) => this.handleClick(event)}
onClick={(event) => this.onClick(event)}
>
{groupName}
</Dropdown.Item>
@@ -65,9 +78,9 @@ class MasqueradeWidgetOption extends Component {
}
}
MasqueradeWidgetOption.propTypes = {
courseId: PropTypes.string.isRequired,
groupId: PropTypes.number,
groupName: PropTypes.string.isRequired,
onSubmit: PropTypes.func.isRequired,
role: PropTypes.string,
selected: PropTypes.shape({
courseKey: PropTypes.string.isRequired,
@@ -77,6 +90,7 @@ MasqueradeWidgetOption.propTypes = {
userPartitionId: PropTypes.number,
}),
userName: PropTypes.string,
userNameInputToggle: PropTypes.func.isRequired,
userPartitionId: PropTypes.number,
};
MasqueradeWidgetOption.defaultProps = {

View File

@@ -7,8 +7,8 @@ export async function getMasqueradeOptions(courseId) {
return camelCaseObject(data);
}
export async function postMasqueradeOptions(courseId, data) {
export async function postMasqueradeOptions(courseId, payload) {
const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`);
const { response } = await getAuthenticatedHttpClient().post(url.href, data);
return camelCaseObject(response);
const { data } = await getAuthenticatedHttpClient().post(url.href, payload);
return camelCaseObject(data);
}

View File

@@ -0,0 +1,36 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'status.groupName': {
id: 'masquerade-widget.status.groupName',
defaultMessage: 'You are masquerading as a learner in the {groupName} group.',
description: 'Message when masquerading as a generic user in a specific track',
},
'status.learner': {
id: 'masquerade-widget.status.learner',
defaultMessage: 'You are masquerading as a learner.',
description: 'Message when masquerading as a specific user',
},
'status.userName': {
id: 'masquerade-widget.status.userName',
defaultMessage: 'You are masquerading as the following user: {userName}',
description: 'Message when masquerading as a specific user',
},
'userName.input.label': {
id: 'masquerade-widget.userName.input.label',
defaultMessage: 'Masquerade as this user',
description: 'Label for the masquerade user input',
},
'userName.error.generic': {
id: 'masquerade-widget.userName.error.generic',
defaultMessage: 'An error has occurred; please try again.',
description: 'Message shown after a general error when attempting to masquerade',
},
'userName.input.placeholder': {
id: 'masquerade-widget.userName.input.placeholder',
defaultMessage: 'username or email',
description: 'Placeholder text to prompt for a user to masquerade as',
},
});
export default messages;