From c8961d3777fb733076487fb25986b6df52179173 Mon Sep 17 00:00:00 2001 From: stvn Date: Mon, 13 Jul 2020 02:24:56 -0700 Subject: [PATCH] Add "masquerade as specific student" support --- src/instructor-toolbar/InstructorToolbar.jsx | 10 +- .../MasqueradeUserNameInput.jsx | 60 ++++++++ .../masquerade-widget/MasqueradeWidget.jsx | 133 +++++++++++++++--- .../MasqueradeWidgetOption.jsx | 46 +++--- .../masquerade-widget/data/api.js | 6 +- .../masquerade-widget/messages.js | 36 +++++ 6 files changed, 248 insertions(+), 43 deletions(-) create mode 100644 src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx create mode 100644 src/instructor-toolbar/masquerade-widget/messages.js diff --git a/src/instructor-toolbar/InstructorToolbar.jsx b/src/instructor-toolbar/InstructorToolbar.jsx index 6d4198a4..a4759e6a 100644 --- a/src/instructor-toolbar/InstructorToolbar.jsx +++ b/src/instructor-toolbar/InstructorToolbar.jsx @@ -51,23 +51,21 @@ export default function InstructorToolbar(props) { return (
-
+
{urlLms && ( -
+ )} -   {urlStudio && ( -
+ )} -   {urlInsights && ( -
+ )} diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx new file mode 100644 index 00000000..d9fc3e75 --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx @@ -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 ( + 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); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx index 938e161b..c8f22bd9 100644 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx @@ -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) => ( this.toggle(...args)} + onSubmit={(payload) => this.onSubmit(payload)} /> )); return options; @@ -57,18 +138,34 @@ class MasqueradeWidget extends Component { options, } = this.state; return ( - - - View this course as - - - {options} - - + <> + + + View this course as + + + {options} + + + {this.state.shouldShowUserNameInput && ( + 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); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx index e57c76c1..5e4fa0a7 100644 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx @@ -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 { this.handleClick(event)} + onClick={(event) => this.onClick(event)} > {groupName} @@ -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 = { diff --git a/src/instructor-toolbar/masquerade-widget/data/api.js b/src/instructor-toolbar/masquerade-widget/data/api.js index e88d1f1e..ccc81a8a 100644 --- a/src/instructor-toolbar/masquerade-widget/data/api.js +++ b/src/instructor-toolbar/masquerade-widget/data/api.js @@ -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); } diff --git a/src/instructor-toolbar/masquerade-widget/messages.js b/src/instructor-toolbar/masquerade-widget/messages.js new file mode 100644 index 00000000..ca6358e7 --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/messages.js @@ -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;