From c6c60e0a847811eb5ba7c64dcf43ddbf93bced0a Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 4 Nov 2024 09:42:45 -0800 Subject: [PATCH] refactor: convert masquerade UI widgets to TypeScript --- .../MasqueradeUserNameInput.jsx | 64 ------- .../MasqueradeUserNameInput.tsx | 52 ++++++ .../masquerade-widget/MasqueradeWidget.jsx | 163 ------------------ .../masquerade-widget/MasqueradeWidget.tsx | 131 ++++++++++++++ .../MasqueradeWidgetOption.jsx | 105 ----------- .../MasqueradeWidgetOption.tsx | 80 +++++++++ .../masquerade-widget/data/api.js | 14 -- .../masquerade-widget/data/api.ts | 47 +++++ .../masquerade-widget/index.js | 3 - .../masquerade-widget/index.ts | 3 + 10 files changed, 313 insertions(+), 349 deletions(-) delete mode 100644 src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx create mode 100644 src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.tsx delete mode 100644 src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx create mode 100644 src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx delete mode 100644 src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx create mode 100644 src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.tsx delete mode 100644 src/instructor-toolbar/masquerade-widget/data/api.js create mode 100644 src/instructor-toolbar/masquerade-widget/data/api.ts delete mode 100644 src/instructor-toolbar/masquerade-widget/index.js create mode 100644 src/instructor-toolbar/masquerade-widget/index.ts diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx deleted file mode 100644 index eb29bb2559..0000000000 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { - Component, -} from 'react'; -import PropTypes from 'prop-types'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import { Input } from '@openedx/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.genericError); - this.onError(message); - }); - return true; - } - - render() { - const { - intl, - onError, - onSubmit, - ...rest - } = this.props; - return ( - this.onKeyPress(event)} - type="text" - {...rest} - /> - ); - } -} -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/MasqueradeUserNameInput.tsx b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.tsx new file mode 100644 index 0000000000..b6f84c37e8 --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeUserNameInput.tsx @@ -0,0 +1,52 @@ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Input } from '@openedx/paragon'; + +import { MasqueradeStatus, Payload } from './data/api'; +import messages from './messages'; + +interface Props extends Omit, 'onSubmit' | 'onError'> { + onError: (error: string) => void; + onSubmit: (payload: Payload) => Promise; +} + +export const MasqueradeUserNameInput: React.FC = ({ onSubmit, onError, ...otherProps }) => { + const intl = useIntl(); + + const handleSubmit = React.useCallback((userIdentifier: string) => { + const payload: Payload = { + role: 'student', + user_name: userIdentifier, // user name or email + }; + onSubmit(payload).then((data) => { + if (data && data.success) { + global.location.reload(); + } else { + const error = (data && data.error) || ''; + onError(error); + } + }).catch(() => { + const message = intl.formatMessage(messages.genericError); + onError(message); + }); + return true; + }, [onError]); + + const handleKeyPress = React.useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + return handleSubmit(event.currentTarget.value); + } + return true; + }, [handleSubmit]); + + return ( + + ); +}; diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx deleted file mode 100644 index a4edd5d6fb..0000000000 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.jsx +++ /dev/null @@ -1,163 +0,0 @@ -import React, { - Component, -} from 'react'; -import PropTypes from 'prop-types'; -import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Dropdown } from '@openedx/paragon'; - -import { UserMessagesContext } from '../../generic/user-messages'; -import MasqueradeUserNameInput from './MasqueradeUserNameInput'; -import MasqueradeWidgetOption from './MasqueradeWidgetOption'; -import { - getMasqueradeOptions, - postMasqueradeOptions, -} from './data/api'; -import messages from './messages'; - -class MasqueradeWidget extends Component { - constructor(props) { - super(props); - this.courseId = props.courseId; - this.state = { - autoFocus: false, - masquerade: this.props.intl.formatMessage(messages.titleStaff), - active: {}, - available: [], - shouldShowUserNameInput: false, - masqueradeUsername: null, - }; - } - - componentDidMount() { - getMasqueradeOptions(this.courseId).then((data) => { - if (data.success) { - this.onSuccess(data); - } else { - // This was explicitly denied by the backend; - // assume it's disabled/unavailable. - // eslint-disable-next-line no-console - 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); - }); - } - - onError(message) { - this.props.onError(message); - } - - async onSubmit(payload) { - this.clearError(); - const options = await postMasqueradeOptions(this.courseId, payload); - return options; - } - - onSuccess(data) { - const { active, available } = this.parseAvailableOptions(data); - this.setState({ - active, - available, - }); - } - - getOptions() { - const options = this.state.available.map((group) => ( - this.toggle(...args)} - onSubmit={(payload) => this.onSubmit(payload)} - /> - )); - return options; - } - - clearError() { - this.props.onError(''); - } - - toggle(show, groupId, groupName, role, userName, userPartitionId) { - this.setState(prevState => ({ - autoFocus: true, - masquerade: groupName, - shouldShowUserNameInput: show === undefined ? !prevState.shouldShowUserNameInput : show, - active: { - ...prevState.active, groupId, role, userName, userPartitionId, - }, - })); - } - - parseAvailableOptions(postData) { - const data = postData || {}; - const active = data.active || {}; - const available = data.available || []; - if (active.userName) { - this.setState({ - autoFocus: false, - masquerade: 'Specific Student...', - masqueradeUsername: active.userName, - shouldShowUserNameInput: true, - }); - } else if (active.groupName) { - this.setState({ masquerade: active.groupName }); - } else if (active.role === 'student') { - this.setState({ masquerade: 'Learner' }); - } - return { active, available }; - } - - render() { - const { - autoFocus, - masquerade, - shouldShowUserNameInput, - masqueradeUsername, - } = this.state; - const specificLearnerInputText = this.props.intl.formatMessage(messages.placeholder); - return ( -
-
- - - - {masquerade} - - - {this.getOptions()} - - -
- {shouldShowUserNameInput && ( -
- {`${specificLearnerInputText}:`} - this.onError(errorMessage)} - onSubmit={(payload) => this.onSubmit(payload)} - /> -
- )} -
- ); - } -} -MasqueradeWidget.propTypes = { - courseId: PropTypes.string.isRequired, - intl: intlShape.isRequired, - onError: PropTypes.func.isRequired, -}; -MasqueradeWidget.contextType = UserMessagesContext; -export default injectIntl(MasqueradeWidget); diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx new file mode 100644 index 0000000000..372e7fcca6 --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidget.tsx @@ -0,0 +1,131 @@ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Dropdown } from '@openedx/paragon'; + +import { MasqueradeUserNameInput } from './MasqueradeUserNameInput'; +import { MasqueradeWidgetOption } from './MasqueradeWidgetOption'; +import { + ActiveMasqueradeData, + getMasqueradeOptions, + MasqueradeOption, + Payload, + postMasqueradeOptions, +} from './data/api'; +import messages from './messages'; + +interface Props { + courseId: string; + onError: (error: string) => void; +} + +export const MasqueradeWidget: React.FC = ({ courseId, onError }) => { + const intl = useIntl(); + const [autoFocus, setAutoFocus] = React.useState(false); + const [active, setActive] = React.useState({ + courseKey: '', + role: 'staff', + groupId: null, + groupName: null, + userName: null, + userPartitionId: null, + }); + const [available, setAvailable] = React.useState([]); + const [shouldShowUserNameInput, setShouldShowUserNameInput] = React.useState(false); + + React.useEffect(() => { + if (active.courseKey === courseId) { + return; // Already fetched. + } + getMasqueradeOptions(courseId).then((data) => { + if (data.success) { + const newActive = data.active || {}; + const newAvailable = data.available || []; + if (newActive.userName) { + setAutoFocus(false); + setShouldShowUserNameInput(true); + } + setActive(newActive); + setAvailable(newAvailable); + } else { + // This was explicitly denied by the backend; + // assume it's disabled/unavailable. + 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); + }); + }, [courseId, onError]); + + const handleSubmit = React.useCallback(async (payload: Payload) => { + onError(''); // Clear any error + return postMasqueradeOptions(courseId, payload); + }, [courseId]); + + const toggle = React.useCallback(( + show: boolean | undefined, + groupId: number | null, + groupName: string, + role: 'staff' | 'student', + userName: string, + userPartitionId: number | null, + ) => { + setAutoFocus(true); + // set masquerade: groupName + setShouldShowUserNameInput((prev) => (show === undefined ? !prev : show)); + setActive(prev => ({ + ...prev, + groupId, + groupName, + role, + userName, + userPartitionId, + })); + }, []); + + const specificLearnerInputText = intl.formatMessage(messages.placeholder); + return ( +
+
+ + + + {active.groupName ?? active.userName ?? intl.formatMessage(messages.titleStaff)} + + + {available.map(group => ( + + ))} + + +
+ {shouldShowUserNameInput && ( +
+ {`${specificLearnerInputText}:`} + +
+ )} +
+ ); +}; diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx deleted file mode 100644 index 5a7107a6b8..0000000000 --- a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.jsx +++ /dev/null @@ -1,105 +0,0 @@ -import React, { - Component, -} from 'react'; -import PropTypes from 'prop-types'; -import { Dropdown } from '@openedx/paragon'; - -class MasqueradeWidgetOption extends Component { - 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 { - groupId, - groupName, - role, - userName, - userPartitionId, - userNameInputToggle, - } = this.props; - const payload = {}; - if (userName || userName === '') { - userNameInputToggle(true, groupId, groupName, role, userName, userPartitionId); - return false; - } - if (role) { - payload.role = role; - } - if (groupId) { - payload.group_id = parseInt(groupId, 10); - payload.user_partition_id = parseInt(userPartitionId, 10); - } - this.props.onSubmit(payload).then(() => { - global.location.reload(); - }); - return true; - } - - isSelected() { - /* 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; - } - - render() { - const { - groupName, - } = this.props; - if (!groupName) { - return null; - } - const selected = this.isSelected(); - let className; - if (selected) { - className = 'active'; - } - return ( - this.onClick(event)} - > - {groupName} - - ); - } -} -MasqueradeWidgetOption.propTypes = { - groupId: PropTypes.number, - groupName: PropTypes.string.isRequired, - onSubmit: PropTypes.func.isRequired, - role: PropTypes.string, - selected: PropTypes.shape({ - courseKey: PropTypes.string.isRequired, - groupId: PropTypes.number, - role: PropTypes.string, - userName: PropTypes.string, - userPartitionId: PropTypes.number, - }), - userName: PropTypes.string, - userNameInputToggle: PropTypes.func.isRequired, - userPartitionId: PropTypes.number, -}; -MasqueradeWidgetOption.defaultProps = { - groupId: null, - role: null, - selected: null, - userName: null, - userPartitionId: null, -}; - -export default MasqueradeWidgetOption; diff --git a/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.tsx b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.tsx new file mode 100644 index 0000000000..8300164d2d --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/MasqueradeWidgetOption.tsx @@ -0,0 +1,80 @@ +/* eslint-disable import/prefer-default-export */ +import React from 'react'; +import { Dropdown } from '@openedx/paragon'; +import { ActiveMasqueradeData } from './data/api'; + +interface Payload { + role?: string; + user_name?: string; + group_id?: number; + user_partition_id?: number; +} + +interface Props { + groupId?: number; + groupName: string; + onSubmit: (payload: Payload) => Promise>; + role?: string; + selected?: ActiveMasqueradeData; + userName?: string; + userNameInputToggle?: ( + show: boolean, + groupId: number | null, + groupName: string, + role: string | null, + userName: string, + userPartitionId: number | null, + ) => void; + userPartitionId?: number; +} + +export const MasqueradeWidgetOption: React.FC = ({ + groupId = null, + groupName, + role = null, + selected = null, + userName = null, + userPartitionId = null, + ...props +}) => { + const handleClick = React.useCallback(() => { + if (userName || userName === '') { + props.userNameInputToggle?.(true, groupId, groupName, role, userName, userPartitionId); + return false; + } + const payload: Payload = {}; + if (role) { + payload.role = role; + } + if (groupId) { + payload.group_id = groupId; + payload.user_partition_id = userPartitionId!; + } + props.onSubmit(payload).then(() => { + global.location.reload(); + }); + return true; + }, []); + + const isSelected = ( + groupId === selected?.groupId + && role === selected?.role + && userName === selected?.userName + && userPartitionId === selected?.userPartitionId + ); + + if (!groupName) { + return null; + } + + const className = isSelected ? 'active' : ''; + return ( + + {groupName} + + ); +}; diff --git a/src/instructor-toolbar/masquerade-widget/data/api.js b/src/instructor-toolbar/masquerade-widget/data/api.js deleted file mode 100644 index ccc81a8a72..0000000000 --- a/src/instructor-toolbar/masquerade-widget/data/api.js +++ /dev/null @@ -1,14 +0,0 @@ -import { getConfig, camelCaseObject } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -export async function getMasqueradeOptions(courseId) { - const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`); - const { data } = await getAuthenticatedHttpClient().get(url.href, {}); - return camelCaseObject(data); -} - -export async function postMasqueradeOptions(courseId, payload) { - const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`); - const { data } = await getAuthenticatedHttpClient().post(url.href, payload); - return camelCaseObject(data); -} diff --git a/src/instructor-toolbar/masquerade-widget/data/api.ts b/src/instructor-toolbar/masquerade-widget/data/api.ts new file mode 100644 index 0000000000..0d9aa2fb1d --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/data/api.ts @@ -0,0 +1,47 @@ +import { getConfig, camelCaseObject } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +export type Role = 'staff' | 'student'; + +export interface ActiveMasqueradeData { + courseKey: string; + role: Role; + userName: string | null; + userPartitionId: number | null; + groupId: number | null; + groupName: string | null; +} + +export interface MasqueradeOption { + name: string; + role: Role; + userName?: string; + groupId?: number; + userPartitionId?: number; +} + +export interface MasqueradeStatus { + success: boolean; + error?: string; + active: ActiveMasqueradeData; + available: MasqueradeOption[]; +} + +export interface Payload { + role?: Role; + user_name?: string; + group_id?: number; + user_partition_id?: number; +} + +export async function getMasqueradeOptions(courseId: string): Promise { + const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`); + const { data } = await getAuthenticatedHttpClient().get(url.href, {}); + return camelCaseObject(data); +} + +export async function postMasqueradeOptions(courseId: string, payload: Payload): Promise { + const url = new URL(`${getConfig().LMS_BASE_URL}/courses/${courseId}/masquerade`); + const { data } = await getAuthenticatedHttpClient().post(url.href, payload); + return camelCaseObject(data); +} diff --git a/src/instructor-toolbar/masquerade-widget/index.js b/src/instructor-toolbar/masquerade-widget/index.js deleted file mode 100644 index f3dcff43bf..0000000000 --- a/src/instructor-toolbar/masquerade-widget/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import MasqueradeWidget from './MasqueradeWidget'; - -export default MasqueradeWidget; diff --git a/src/instructor-toolbar/masquerade-widget/index.ts b/src/instructor-toolbar/masquerade-widget/index.ts new file mode 100644 index 0000000000..bbaab1d4ed --- /dev/null +++ b/src/instructor-toolbar/masquerade-widget/index.ts @@ -0,0 +1,3 @@ +import { MasqueradeWidget } from './MasqueradeWidget'; + +export default MasqueradeWidget;