Merge PR #106 add/masquerade-username
* Commits: Add "masquerade as specific student" support
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
36
src/instructor-toolbar/masquerade-widget/messages.js
Normal file
36
src/instructor-toolbar/masquerade-widget/messages.js
Normal 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;
|
||||
Reference in New Issue
Block a user