chore: Remove Demographics frontend components from edx-platform (#35121)

[APER-2823]

Removes React compoents and functionality tied to a private 2U/edx.org-specific Demographics IDA from edx-platform.

This PR attempts to remove everything added from this PR: https://github.com/openedx/edx-platform/pull/24956/. This includes the React components created to collect and transmit Demographics data, as well as functionality for managing JWT and CSRF tokens copied from `frontend-platform` to edx-platform when originally implementing the CTA and modal components.
This commit is contained in:
Justin Hynes
2024-07-16 08:23:29 -04:00
committed by GitHub
parent 69b4f6963d
commit b243b8d369
14 changed files with 1 additions and 1434 deletions

View File

@@ -1,99 +0,0 @@
/* global gettext */
import React from 'react';
import Cookies from 'js-cookie';
import {DemographicsCollectionModal} from './DemographicsCollectionModal';
// eslint-disable-next-line import/prefer-default-export
export class DemographicsCollectionBanner extends React.Component {
constructor(props) {
super(props);
this.state = {
modalOpen: false,
hideBanner: false
};
this.dismissBanner = this.dismissBanner.bind(this);
}
/**
* Utility function that controls hiding the CTA from the Course Dashboard where appropriate.
* This can be called one of two ways - when a user clicks the "dismiss" button on the CTA
* itself, or when the learner completes all of the questions within the modal.
*
* The dismiss button itself is nested inside of an <a>, so we need to call stopPropagation()
* here to prevent the Modal from _also_ opening when the Dismiss button is clicked.
*/
async dismissBanner(e) {
// Since this function also doubles as a callback in the Modal, we check if e is null/undefined
// before calling stopPropagation()
if (e) {
e.stopPropagation();
}
const requestOptions = {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFTOKEN': Cookies.get('csrftoken'),
},
body: JSON.stringify({
show_call_to_action: false,
})
};
await fetch(`${this.props.lmsRootUrl}/api/demographics/v1/demographics/status/`, requestOptions);
// No matter what the response is from the API call we always allow the learner to dismiss the
// banner when clicking the dismiss button
this.setState({hideBanner: true});
}
render() {
if (!(this.state.hideBanner)) {
return (
<div>
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<a id="demographics-banner-link" className="btn" onClick={() => this.setState({modalOpen: true})}>
<div
className="demographics-banner d-flex justify-content-lg-between flex-row py-1 px-2 mb-2 mb-lg-4"
role="dialog"
aria-modal="false"
aria-label="demographics questionnaire pitch"
>
<div className="d-flex justify-content-left align-items-lg-center flex-column flex-lg-row w-100">
<img className="demographics-banner-icon d-none d-lg-inline-block" src={this.props.bannerLogo} alt="" aria-hidden="true" />
<div className="demographics-banner-prompt d-inline-block font-weight-bold text-white mr-4 py-3 px-2 px-lg-3">
{gettext('Want to make edX better for everyone?')}
</div>
{/* eslint-disable-next-line react/button-has-type */}
<button className="demographics-banner-btn d-flex align-items-center bg-white font-weight-bold border-0 py-2 px-3 mx-2 mb-3 m-lg-0 shadow justify-content-center">
<span className="fa fa-thumbs-up px-2" aria-hidden="true" />
{gettext('Get started')}
</button>
</div>
<div className="demographics-dismiss-container md-flex justify-content-right align-self-start align-self-lg-center ml-lg-auto">
<button type="button" className="demographics-dismiss-btn btn btn-default px-0" id="demographics-dismiss" aria-label="close">
<i className="fa fa-times-circle text-white px-2" aria-hidden="true" onClick={this.dismissBanner} />
</button>
</div>
</div>
</a>
<div>
{this.state.modalOpen
&& (
<DemographicsCollectionModal
{...this.props}
user={this.props.user}
open={this.state.modalOpen}
closeModal={() => this.setState({modalOpen: false})}
dismissBanner={this.dismissBanner}
/>
)}
</div>
</div>
);
} else {
return null;
}
}
}

View File

@@ -1,517 +0,0 @@
/* global gettext */
import React from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import get from 'lodash/get';
import Cookies from 'js-cookie';
import StringUtils from 'edx-ui-toolkit/js/utils/string-utils';
import FocusLock from 'react-focus-lock';
import Wizard from './Wizard';
import {SelectWithInput} from './SelectWithInput';
import {MultiselectDropdown} from './MultiselectDropdown';
import AxiosJwtTokenService from '../jwt_auth/AxiosJwtTokenService';
import AxiosCsrfTokenService from '../jwt_auth/AxiosCsrfTokenService';
const FIELD_NAMES = {
CURRENT_WORK: 'current_work_sector',
FUTURE_WORK: 'future_work_sector',
GENDER: 'gender',
GENDER_DESCRIPTION: 'gender_description',
INCOME: 'income',
EDUCATION_LEVEL: 'learner_education_level',
MILITARY: 'military_history',
PARENT_EDUCATION: 'parent_education_level',
// For some reason, ethnicity has the really long property chain to get to the choices
ETHNICITY_OPTIONS: 'user_ethnicity.child.children.ethnicity',
ETHNICITY: 'user_ethnicity',
WORK_STATUS: 'work_status',
WORK_STATUS_DESCRIPTION: 'work_status_description',
};
class DemographicsCollectionModal extends React.Component {
constructor(props) {
super(props);
this.state = {
options: {},
// a general error something goes really wrong
error: false,
// an error for when a specific demographics question fails to save
fieldError: false,
// eslint-disable-next-line react/no-unused-state
errorMessage: '',
loading: true,
// eslint-disable-next-line react/no-unused-state
open: this.props.open,
selected: {
[FIELD_NAMES.CURRENT_WORK]: '',
[FIELD_NAMES.FUTURE_WORK]: '',
[FIELD_NAMES.GENDER]: '',
[FIELD_NAMES.GENDER_DESCRIPTION]: '',
[FIELD_NAMES.INCOME]: '',
[FIELD_NAMES.EDUCATION_LEVEL]: '',
[FIELD_NAMES.MILITARY]: '',
[FIELD_NAMES.PARENT_EDUCATION]: '',
[FIELD_NAMES.ETHNICITY]: [],
[FIELD_NAMES.WORK_STATUS]: '',
[FIELD_NAMES.WORK_STATUS_DESCRIPTION]: '',
}
};
this.handleSelectChange = this.handleSelectChange.bind(this);
this.handleMultiselectChange = this.handleMultiselectChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.loadOptions = this.loadOptions.bind(this);
this.getDemographicsQuestionOptions = this.getDemographicsQuestionOptions.bind(this);
this.getDemographicsData = this.getDemographicsData.bind(this);
// Get JWT token service to ensure the JWT token refreshes if needed
const accessToken = this.props.jwtAuthToken;
const refreshUrl = `${this.props.lmsRootUrl}/login_refresh`;
this.jwtTokenService = new AxiosJwtTokenService(
accessToken,
refreshUrl,
);
this.csrfTokenService = new AxiosCsrfTokenService(this.props.csrfTokenPath);
}
async componentDidMount() {
// we add a class here to prevent scrolling on anything that is not the modal
document.body.classList.add('modal-open');
const options = await this.getDemographicsQuestionOptions();
// gather previously answers questions
const data = await this.getDemographicsData();
this.setState({options: options.actions.POST, loading: false, selected: data});
}
componentWillUnmount() {
// remove the class to allow the dashboard content to scroll
document.body.classList.remove('modal-open');
}
// eslint-disable-next-line react/sort-comp
loadOptions(field) {
const {choices} = get(this.state.options, field, {choices: []});
if (choices.length) {
// eslint-disable-next-line react/no-array-index-key
return choices.map((choice, i) => <option value={choice.value} key={choice.value + i}>{choice.display_name}</option>);
}
}
async handleSelectChange(e) {
const url = `${this.props.demographicsBaseUrl}/demographics/api/v1/demographics/${this.props.user}/`;
const name = e.target.name;
const value = e.target.value;
const options = {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'USE-JWT-COOKIE': true,
'X-CSRFToken': await this.retrieveDemographicsCsrfToken(url),
},
body: JSON.stringify({
[name]: value === 'default' ? null : value,
}),
};
try {
await this.jwtTokenService.getJwtToken();
await fetch(url, options);
} catch (error) {
// eslint-disable-next-line react/no-unused-state
this.setState({loading: false, fieldError: true, errorMessage: error});
}
if (name === 'user_ethnicity') {
return this.reduceEthnicityArray(value);
}
this.setState(prevState => ({
selected: {
...prevState.selected,
[name]: value,
}
}));
}
handleMultiselectChange(values) {
const decline = values.find(i => i === 'declined');
this.setState(({selected}) => {
// decline was previously selected
if (selected[FIELD_NAMES.ETHNICITY].find(i => i === 'declined')) {
return {selected: {...selected, [FIELD_NAMES.ETHNICITY]: values.filter(value => value !== 'declined')}};
// decline was just selected
} else if (decline) {
return {selected: {...selected, [FIELD_NAMES.ETHNICITY]: [decline]}};
// anything else was selected
} else {
return {selected: {...selected, [FIELD_NAMES.ETHNICITY]: values}};
}
});
}
handleInputChange(e) {
const name = e.target.name;
const value = e.target.value;
this.setState(prevState => ({
selected: {
...prevState.selected,
[name]: value,
}
}));
}
// We need to transform the ethnicity array before we POST or after GET the data to match
// from [{ethnicity: 'example}] => to ['example']
// the format the UI requires the data to be in.
reduceEthnicityArray(ethnicityArray) {
return ethnicityArray.map((o) => o.ethnicity);
}
// Sets the CSRF token cookie to be used before each request that needs it.
// if the cookie is already set, return it instead. We don't have to worry
// about the cookie expiring, as it is tied to the session.
async retrieveDemographicsCsrfToken(url) {
let csrfToken = Cookies.get('demographics_csrftoken');
if (!csrfToken) {
// set the csrf token cookie if not already set
csrfToken = await this.csrfTokenService.getCsrfToken(url);
Cookies.set('demographics_csrftoken', csrfToken);
}
return csrfToken;
}
// We gather the possible answers to any demographics questions from the OPTIONS of the api
async getDemographicsQuestionOptions() {
try {
const optionsResponse = await fetch(`${this.props.demographicsBaseUrl}/demographics/api/v1/demographics/`, {method: 'OPTIONS'});
const demographicsOptions = await optionsResponse.json();
return demographicsOptions;
} catch (error) {
// eslint-disable-next-line react/no-unused-state
this.setState({loading: false, error: true, errorMessage: error});
}
}
async getDemographicsData() {
const requestOptions = {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'USE-JWT-COOKIE': true
},
};
let response;
let data;
try {
await this.jwtTokenService.getJwtToken();
response = await fetch(`${this.props.demographicsBaseUrl}/demographics/api/v1/demographics/${this.props.user}/`, requestOptions);
} catch (e) {
// an error other than "no entry found" occured
// eslint-disable-next-line react/no-unused-state
this.setState({loading: false, error: true, errorMessage: e});
}
// an entry was not found in demographics, so we need to create one
if (response.status === 404) {
data = await this.createDemographicsEntry();
return data;
}
// Otherwise, just return the data found
data = await response.json();
if (data[FIELD_NAMES.ETHNICITY]) {
// map ethnicity data to match what the UI requires
data[FIELD_NAMES.ETHNICITY] = this.reduceEthnicityArray(data[FIELD_NAMES.ETHNICITY]);
}
return data;
}
async createDemographicsEntry() {
const postUrl = `${this.props.demographicsBaseUrl}/demographics/api/v1/demographics/`;
const postOptions = {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'USE-JWT-COOKIE': true,
'X-CSRFToken': await this.retrieveDemographicsCsrfToken(postUrl),
},
body: JSON.stringify({
user: this.props.user,
}),
};
// Create the entry for the user
try {
const postResponse = await fetch(postUrl, postOptions);
const data = await postResponse.json();
return data;
} catch (e) {
// eslint-disable-next-line react/no-unused-state
this.setState({loading: false, error: true, errorMessage: e});
}
}
render() {
if (this.state.loading) {
return <div className="demographics-collection-modal d-flex justify-content-center align-items-start" />;
}
return (
<FocusLock>
<div className="demographics-collection-modal d-flex justify-content-center align-items-start">
<Wizard
onWizardComplete={this.props.closeModal}
dismissBanner={this.props.dismissBanner}
wizardContext={{...this.state.selected, options: this.state.options}}
error={this.state.error}
>
<Wizard.Header>
{({currentPage, totalPages}) => (
<div>
<p className="font-weight-light">
{StringUtils.interpolate(
gettext('Section {currentPage} of {totalPages}'),
{
currentPage: currentPage,
totalPages: totalPages
}
)}
</p>
<h2 className="mb-1 mt-4 font-weight-bold text-secondary">
{gettext('Help make edX better for everyone!')}
</h2>
<p className="message">
{gettext('Welcome to edX! Before you get started, please take a few minutes to fill-in the additional information below to help us understand a bit more about your background. You can always edit this information later in Account Settings.')}
</p>
<br />
<span aria-hidden="true" className="fa fa-info-circle" />
{/* Need to strip out extra '"' characters in the marketingSiteBaseUrl prop or it tries to setup the href as a relative URL */}
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<a className="pl-3" target="_blank" rel="noopener" href={`${this.props.marketingSiteBaseUrl}/demographics`.replace(/"/g, '')}>
{gettext('Why does edX collect this information?')}
</a>
<br />
{this.state.fieldError && <p className="field-error">{gettext('An error occurred while attempting to retrieve or save the information below. Please try again later.')}</p>}
</div>
)}
</Wizard.Header>
<Wizard.Page>
{({wizardConsumer}) => (
<div className="demographics-form-container" data-hj-suppress>
{/* Gender Identity */}
<SelectWithInput
selectName={FIELD_NAMES.GENDER}
selectId={FIELD_NAMES.GENDER}
selectValue={wizardConsumer[FIELD_NAMES.GENDER]}
selectOnChange={this.handleSelectChange}
labelText={gettext('What is your gender identity?')}
options={[
<option value="default" key="default">{gettext('Select gender')}</option>,
this.loadOptions(FIELD_NAMES.GENDER)
]}
showInput={wizardConsumer[FIELD_NAMES.GENDER] == 'self-describe'}
inputName={FIELD_NAMES.GENDER_DESCRIPTION}
inputId={FIELD_NAMES.GENDER_DESCRIPTION}
inputType="text"
inputValue={wizardConsumer[FIELD_NAMES.GENDER_DESCRIPTION]}
inputOnChange={this.handleInputChange}
inputOnBlur={this.handleSelectChange}
disabled={this.state.fieldError}
/>
{/* Ethnicity */}
<MultiselectDropdown
label={gettext('Which of the following describes you best?')}
emptyLabel={gettext('Check all that apply')}
options={get(this.state.options, FIELD_NAMES.ETHNICITY_OPTIONS, {choices: []}).choices}
selected={wizardConsumer[FIELD_NAMES.ETHNICITY]}
onChange={this.handleMultiselectChange}
disabled={this.state.fieldError}
onBlur={() => {
// we create a fake "event", and then use it to call our normal selection handler function that
// is used by the other dropdowns.
const e = {
target: {
name: FIELD_NAMES.ETHNICITY,
value: wizardConsumer[FIELD_NAMES.ETHNICITY].map(ethnicity => ({ethnicity, value: ethnicity})),
}
};
this.handleSelectChange(e);
}}
/>
{/* Family Income */}
<div className="d-flex flex-column pb-3">
<label htmlFor={FIELD_NAMES.INCOME}>
{gettext('What was the total combined income, during the last 12 months, of all members of your family? ')}
</label>
<select
onChange={this.handleSelectChange}
className="form-control"
name={FIELD_NAMES.INCOME}
id={FIELD_NAMES.INCOME}
value={wizardConsumer[FIELD_NAMES.INCOME]}
disabled={this.state.fieldError}
>
<option value="default">{gettext('Select income')}</option>
{
this.loadOptions(FIELD_NAMES.INCOME)
}
</select>
</div>
</div>
)}
</Wizard.Page>
<Wizard.Page>
{({wizardConsumer}) => (
<div className="demographics-form-container" data-hj-suppress>
{/* Military History */}
<div className="d-flex flex-column pb-3">
<label htmlFor={FIELD_NAMES.MILITARY}>
{gettext('Have you ever served on active duty in the U.S. Armed Forces, Reserves, or National Guard?')}
</label>
<select
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="form-control"
onChange={this.handleSelectChange}
name={FIELD_NAMES.MILITARY}
id={FIELD_NAMES.MILITARY}
value={wizardConsumer[FIELD_NAMES.MILITARY]}
disabled={this.state.fieldError}
>
<option value="default">{gettext('Select military status')}</option>
{
this.loadOptions(FIELD_NAMES.MILITARY)
}
</select>
</div>
</div>
)}
</Wizard.Page>
<Wizard.Page>
{({wizardConsumer}) => (
<div className="demographics-form-container" data-hj-suppress>
{/* Learner Education Level */}
<div className="d-flex flex-column pb-3">
<label htmlFor={FIELD_NAMES.EDUCATION_LEVEL}>
{gettext('What is the highest level of education that you have achieved so far?')}
</label>
<select
className="form-control"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onChange={this.handleSelectChange}
key="self-education"
name={FIELD_NAMES.EDUCATION_LEVEL}
id={FIELD_NAMES.EDUCATION_LEVEL}
value={wizardConsumer[FIELD_NAMES.EDUCATION_LEVEL]}
disabled={this.state.fieldError}
>
<option value="default">{gettext('Select level of education')}</option>
{
this.loadOptions(FIELD_NAMES.EDUCATION_LEVEL)
}
</select>
</div>
{/* Parent/Guardian Education Level */}
<div className="d-flex flex-column pb-3">
<label htmlFor={FIELD_NAMES.PARENT_EDUCATION}>
{gettext('What is the highest level of education that any of your parents or guardians have achieved?')}
</label>
<select
className="form-control"
onChange={this.handleSelectChange}
name={FIELD_NAMES.PARENT_EDUCATION}
id={FIELD_NAMES.PARENT_EDUCATION}
value={wizardConsumer[FIELD_NAMES.PARENT_EDUCATION]}
disabled={this.state.fieldError}
>
<option value="default">{gettext('Select guardian education')}</option>
{
this.loadOptions(FIELD_NAMES.PARENT_EDUCATION)
}
</select>
</div>
</div>
)}
</Wizard.Page>
<Wizard.Page>
{({wizardConsumer}) => (
<div className="demographics-form-container" data-hj-suppress>
{/* Employment Status */}
<SelectWithInput
selectName={FIELD_NAMES.WORK_STATUS}
selectId={FIELD_NAMES.WORK_STATUS}
selectValue={wizardConsumer[FIELD_NAMES.WORK_STATUS]}
selectOnChange={this.handleSelectChange}
labelText="What is your current employment status?"
options={[
<option value="default" key="default">{gettext('Select employment status')}</option>,
this.loadOptions(FIELD_NAMES.WORK_STATUS)
]}
showInput={wizardConsumer[FIELD_NAMES.WORK_STATUS] == 'other'}
inputName={FIELD_NAMES.WORK_STATUS_DESCRIPTION}
inputId={FIELD_NAMES.WORK_STATUS_DESCRIPTION}
inputType="text"
inputValue={wizardConsumer[FIELD_NAMES.WORK_STATUS_DESCRIPTION]}
inputOnChange={this.handleInputChange}
inputOnBlur={this.handleSelectChange}
disabled={this.state.fieldError}
/>
{/* Current Work Industry */}
<div className="d-flex flex-column pb-3">
<label htmlFor={FIELD_NAMES.CURRENT_WORK}>
{gettext('What industry do you currently work in?')}
</label>
<select
className="form-control"
onChange={this.handleSelectChange}
name={FIELD_NAMES.CURRENT_WORK}
id={FIELD_NAMES.CURRENT_WORK}
value={wizardConsumer[FIELD_NAMES.CURRENT_WORK]}
disabled={this.state.fieldError}
>
<option value="default">{gettext('Select current industry')}</option>
{
this.loadOptions(FIELD_NAMES.CURRENT_WORK)
}
</select>
</div>
{/* Future Work Industry */}
<div className="d-flex flex-column pb-3">
<label htmlFor={FIELD_NAMES.FUTURE_WORK}>
{gettext('What industry do you want to work in?')}
</label>
<select
className="form-control"
onChange={this.handleSelectChange}
name={FIELD_NAMES.FUTURE_WORK}
id={FIELD_NAMES.FUTURE_WORK}
value={wizardConsumer[FIELD_NAMES.FUTURE_WORK]}
disabled={this.state.fieldError}
>
<option value="default">{gettext('Select prospective industry')}</option>
{
this.loadOptions(FIELD_NAMES.FUTURE_WORK)
}
</select>
</div>
</div>
)}
</Wizard.Page>
<Wizard.Closer>
<div className="demographics-modal-closer m-sm-0">
<i className="fa fa-check" aria-hidden="true" />
<h3>
{gettext('Thank you! Youre helping make edX better for everyone.')}
</h3>
</div>
</Wizard.Closer>
<Wizard.ErrorPage>
<div>
{this.state.error.length ? this.state.error : gettext('An error occurred while attempting to retrieve or save the information below. Please try again later.')}
</div>
</Wizard.ErrorPage>
</Wizard>
</div>
</FocusLock>
);
}
}
// eslint-disable-next-line import/prefer-default-export
export {DemographicsCollectionModal};

View File

@@ -1,176 +0,0 @@
/* global gettext */
import React from 'react';
import PropTypes from 'prop-types';
class MultiselectDropdown extends React.Component {
constructor(props) {
super(props);
this.state = {
open: false,
};
// this version of React does not support React.createRef()
this.buttonRef = null;
this.setButtonRef = (element) => {
this.buttonRef = element;
};
this.focusButton = this.focusButton.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
this.handleButtonClick = this.handleButtonClick.bind(this);
this.handleRemoveAllClick = this.handleRemoveAllClick.bind(this);
this.handleOptionClick = this.handleOptionClick.bind(this);
}
componentDidMount() {
document.addEventListener('keydown', this.handleKeydown, false);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.handleKeydown, false);
}
// eslint-disable-next-line react/sort-comp
findOption(data) {
return this.props.options.find((o) => o.value == data || o.display_name == data);
}
focusButton() {
if (this.buttonRef) { this.buttonRef.focus(); }
}
handleKeydown(event) {
if (this.state.open && event.keyCode == 27) {
this.setState({open: false}, this.focusButton);
}
}
handleButtonClick(e) {
// eslint-disable-next-line react/no-access-state-in-setstate
this.setState({open: !this.state.open});
}
handleRemoveAllClick(e) {
this.props.onChange([]);
this.focusButton();
e.stopPropagation();
}
handleOptionClick(e) {
const value = e.target.value;
const inSelected = this.props.selected.includes(value);
let newSelected = [...this.props.selected];
// if the option has its own onChange, trigger that instead
if (this.findOption(value).onChange) {
this.findOption(value).onChange(e.target.checked, value);
return;
}
// if checked, add value to selected list
if (e.target.checked && !inSelected) {
newSelected = newSelected.concat(value);
}
// if unchecked, remove value from selected list
if (!e.target.checked && inSelected) {
newSelected = newSelected.filter(i => i !== value);
}
this.props.onChange(newSelected);
}
renderSelected() {
if (this.props.selected.length == 0) {
return this.props.emptyLabel;
}
const selectedList = this.props.selected
.map(selected => this.findOption(selected).display_name)
.join(', ');
if (selectedList.length > 60) {
return selectedList.substring(0, 55) + '...';
}
return selectedList;
}
renderUnselect() {
return this.props.selected.length > 0 && (
// eslint-disable-next-line react/button-has-type
<button id="unselect-button" disabled={this.props.disabled} aria-label="Clear all selected" onClick={this.handleRemoveAllClick}>{gettext('Clear all')}</button>
);
}
renderMenu() {
if (!this.state.open) {
return;
}
const options = this.props.options.map((option, index) => {
const checked = this.props.selected.includes(option.value);
return (
// eslint-disable-next-line react/no-array-index-key
<div key={index} id={`${option.value}-option-container`} className="option-container">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label className="option-label">
<input id={`${option.value}-option-checkbox`} className="option-checkbox" type="checkbox" value={option.value} checked={checked} onChange={this.handleOptionClick} />
<span className="pl-2">{option.display_name}</span>
</label>
</div>
);
});
return (
<fieldset id="multiselect-dropdown-fieldset" disabled={this.props.disabled}>
<legend className="sr-only">{this.props.label}</legend>
{options}
</fieldset>
);
}
render() {
return (
<div
className="multiselect-dropdown pb-3"
tabIndex={-1}
onBlur={e => {
// We need to make sure we only close and save the dropdown when
// the user blurs on the parent to an element other than it's children.
// essentially what this if statement is saying:
// if the newly focused target is NOT a child of the this element, THEN fire the onBlur function
// and close the dropdown.
if (!e.currentTarget.contains(e.relatedTarget)) {
this.props.onBlur(e);
this.setState({open: false});
}
}}
>
<label id="multiselect-dropdown-label" htmlFor="multiselect-dropdown">{this.props.label}</label>
<div className="form-control d-flex">
{/* eslint-disable-next-line react/button-has-type */}
<button className="multiselect-dropdown-button" disabled={this.props.disabled} id="multiselect-dropdown-button" ref={this.setButtonRef} aria-haspopup="true" aria-expanded={this.state.open} aria-labelledby="multiselect-dropdown-label multiselect-dropdown-button" onClick={this.handleButtonClick}>
{this.renderSelected()}
</button>
{this.renderUnselect()}
</div>
<div>
{this.renderMenu()}
</div>
</div>
);
}
}
// eslint-disable-next-line import/prefer-default-export
export {MultiselectDropdown};
MultiselectDropdown.propTypes = {
// eslint-disable-next-line react/require-default-props
label: PropTypes.string,
// eslint-disable-next-line react/require-default-props
emptyLabel: PropTypes.string,
// eslint-disable-next-line react/forbid-prop-types
options: PropTypes.array.isRequired,
// eslint-disable-next-line react/forbid-prop-types
selected: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired,
};

View File

@@ -1,53 +0,0 @@
import React from 'react';
// eslint-disable-next-line import/prefer-default-export, react/function-component-definition
export const SelectWithInput = (props) => {
const {
selectName,
selectId,
selectValue,
options,
inputName,
inputId,
inputType,
inputValue,
selectOnChange,
inputOnChange,
showInput,
inputOnBlur,
labelText,
disabled,
} = props;
return (
<div className="d-flex flex-column pb-3">
<label htmlFor={selectName}>{labelText}</label>
<select
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="form-control"
name={selectName}
id={selectId}
onChange={selectOnChange}
value={selectValue}
disabled={disabled}
>
{options}
</select>
{showInput
&& (
<input
className="form-control"
aria-label={`${selectName} description field`}
type={inputType}
name={inputName}
id={inputId}
onChange={inputOnChange}
onBlur={inputOnBlur}
value={inputValue}
disabled={disabled}
maxLength={255}
/>
)}
</div>
);
};

View File

@@ -1,134 +0,0 @@
/* global gettext */
import React from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import isFunction from 'lodash/isFunction';
const Page = ({children}) => children;
// eslint-disable-next-line react/function-component-definition
const Header = () => null;
// eslint-disable-next-line react/function-component-definition
const Closer = () => null;
// eslint-disable-next-line react/function-component-definition
const ErrorPage = () => null;
export default class Wizard extends React.Component {
constructor(props) {
super(props);
this.findSubComponentByType = this.findSubComponentByType.bind(this);
this.handleNext = this.handleNext.bind(this);
this.state = {
currentPage: 1,
totalPages: 0,
pages: [],
// eslint-disable-next-line react/no-unused-state
wizardContext: {},
};
this.wizardComplete = this.wizardComplete.bind(this);
}
componentDidMount() {
const pages = this.findSubComponentByType(Wizard.Page.name);
const totalPages = pages.length;
const wizardContext = this.props.wizardContext;
const closer = this.findSubComponentByType(Wizard.Closer.name)[0];
pages.push(closer);
// eslint-disable-next-line react/no-unused-state
this.setState({pages, totalPages, wizardContext});
}
handleNext() {
if (this.state.currentPage < this.props.children.length) {
this.setState(prevState => ({currentPage: prevState.currentPage + 1}));
}
}
findSubComponentByType(type) {
return React.Children.toArray(this.props.children).filter(child => child.type.name === type);
}
// this needs to handle the case of no provided header
// eslint-disable-next-line react/sort-comp
renderHeader() {
const header = this.findSubComponentByType(Wizard.Header.name)[0];
return header.props.children({currentPage: this.state.currentPage, totalPages: this.state.totalPages});
}
renderPage() {
if (this.state.totalPages) {
const page = this.state.pages[this.state.currentPage - 1];
if (page.type.name === Wizard.Closer.name) {
return page.props.children;
}
if (isFunction(page.props.children)) {
return page.props.children({wizardConsumer: this.props.wizardContext});
} else {
return page.props.children;
}
}
return null;
}
// this needs to handle the case of no provided errorPage
renderError() {
const errorPage = this.findSubComponentByType(Wizard.ErrorPage.name)[0];
return (
<div className="wizard-container" role="dialog" aria-label={gettext('demographics questionnaire')}>
<div className="wizard-header">
{errorPage.props.children}
</div>
<div className="wizard-footer justify-content-end h-100 d-flex flex-column">
{/* eslint-disable-next-line react/button-has-type, react/no-unknown-property */}
<button className="wizard-button colored" arial-label={gettext('close questionnaire')} onClick={this.props.onWizardComplete}>{gettext('Close')}</button>
</div>
</div>
);
}
/**
* Utility method that helps determine if the learner is on the final page of the modal.
*/
onFinalPage() {
return this.state.pages.length === this.state.currentPage;
}
/**
* Utility method for closing the modal and returning the learner back to the Course Dashboard.
* If a learner is on the final page of the modal, meaning they have answered all of the
* questions, clicking the "Return to my dashboard" button will also dismiss the CTA from the
* course dashboard.
*/
async wizardComplete() {
if (this.onFinalPage()) {
this.props.dismissBanner();
}
this.props.onWizardComplete();
}
render() {
const finalPage = this.onFinalPage();
if (this.props.error) {
return this.renderError();
}
return (
<div className="wizard-container" role="dialog" aria-label={gettext('demographics questionnaire')}>
<div className="wizard-header mb-4">
{this.state.totalPages >= this.state.currentPage && this.renderHeader()}
</div>
{this.renderPage()}
<div className="wizard-footer justify-content-end h-100 d-flex flex-column">
{/* eslint-disable-next-line react/button-has-type */}
<button className={`wizard-button ${finalPage && 'colored'}`} onClick={this.wizardComplete} aria-label={gettext('finish later')}>{finalPage ? gettext('Return to my dashboard') : gettext('Finish later')}</button>
{/* eslint-disable-next-line react/button-has-type */}
<button className="wizard-button colored" hidden={finalPage} onClick={this.handleNext} aria-label={gettext('next page')}>{gettext('Next')}</button>
</div>
</div>
);
}
}
Wizard.Page = Page;
Wizard.Header = Header;
Wizard.Closer = Closer;
Wizard.ErrorPage = ErrorPage;

View File

@@ -1,18 +0,0 @@
module.exports = {
extends: '@edx/eslint-config',
root: true,
settings: {
'import/resolver': {
webpack: {
config: 'webpack.dev.config.js',
},
},
},
rules: {
indent: ['error', 4],
'react/jsx-indent': ['error', 4],
'react/jsx-indent-props': ['error', 4],
'import/extensions': 'off',
'import/no-unresolved': 'off',
},
};

View File

@@ -1,64 +0,0 @@
/**
* Service class to support CSRF.
*
* Temporarily copied from the edx/frontend-platform
*/
import axios from 'axios';
import { getUrlParts, processAxiosErrorAndThrow } from './utils';
export default class AxiosCsrfTokenService {
constructor(csrfTokenApiPath) {
this.csrfTokenApiPath = csrfTokenApiPath;
this.httpClient = axios.create();
// Set withCredentials to true. Enables cross-site Access-Control requests
// to be made using cookies, authorization headers or TLS client
// certificates. More on MDN:
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
this.httpClient.defaults.withCredentials = true;
this.httpClient.defaults.headers.common['USE-JWT-COOKIE'] = true;
this.csrfTokenCache = {};
this.csrfTokenRequestPromises = {};
}
async getCsrfToken(url) {
let urlParts;
try {
urlParts = getUrlParts(url);
} catch (e) {
// If the url is not parsable it's likely because a relative
// path was supplied as the url. This is acceptable and in
// this case we should use the current origin of the page.
urlParts = getUrlParts(global.location.origin);
}
const { protocol, domain } = urlParts;
const csrfToken = this.csrfTokenCache[domain];
if (csrfToken) {
return csrfToken;
}
if (!this.csrfTokenRequestPromises[domain]) {
this.csrfTokenRequestPromises[domain] = this.httpClient
.get(`${protocol}://${domain}${this.csrfTokenApiPath}`)
.then((response) => {
this.csrfTokenCache[domain] = response.data.csrfToken;
return this.csrfTokenCache[domain];
})
.catch(processAxiosErrorAndThrow)
.finally(() => {
delete this.csrfTokenRequestPromises[domain];
});
}
return this.csrfTokenRequestPromises[domain];
}
clearCsrfTokenCache() {
this.csrfTokenCache = {};
}
getHttpClient() {
return this.httpClient;
}
}

View File

@@ -1,126 +0,0 @@
/**
* Service class to support JWT Token Authentication.
*
* Temporarily copied from the edx/frontend-platform
*/
import Cookies from 'universal-cookie';
import jwtDecode from 'jwt-decode';
import axios from 'axios';
import createRetryInterceptor from './interceptors/createRetryInterceptor';
import { processAxiosErrorAndThrow } from './utils';
export default class AxiosJwtTokenService {
static isTokenExpired(token) {
return !token || token.exp < Date.now() / 1000;
}
constructor(tokenCookieName, tokenRefreshEndpoint) {
this.tokenCookieName = tokenCookieName;
this.tokenRefreshEndpoint = tokenRefreshEndpoint;
this.httpClient = axios.create();
// Set withCredentials to true. Enables cross-site Access-Control requests
// to be made using cookies, authorization headers or TLS client
// certificates. More on MDN:
// https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials
this.httpClient.defaults.withCredentials = true;
// Add retries to this axios instance
this.httpClient.interceptors.response.use(
response => response,
createRetryInterceptor({ httpClient: this.httpClient }),
);
this.cookies = new Cookies();
this.refreshRequestPromises = {};
}
getHttpClient() {
return this.httpClient;
}
decodeJwtCookie() {
const cookieValue = this.cookies.get(this.tokenCookieName);
if (cookieValue) {
try {
return jwtDecode(cookieValue);
} catch (e) {
const error = Object.create(e);
error.message = 'Error decoding JWT token';
error.customAttributes = { cookieValue };
throw error;
}
}
return null;
}
refresh() {
if (this.refreshRequestPromises[this.tokenCookieName] === undefined) {
const makeRefreshRequest = async () => {
let axiosResponse;
try {
try {
axiosResponse = await this.httpClient.post(this.tokenRefreshEndpoint);
} catch (error) {
processAxiosErrorAndThrow(error);
}
} catch (error) {
const userIsUnauthenticated = error.response && error.response.status === 401;
if (userIsUnauthenticated) {
// Clean up the cookie if it exists to eliminate any situation
// where the cookie is not expired but the jwt is expired.
this.cookies.remove(this.tokenCookieName);
const decodedJwtToken = null;
return decodedJwtToken;
}
// TODO: Network timeouts and other problems will end up in
// this block of code. We could add logic for retrying token
// refreshes if we wanted to.
throw error;
}
const decodedJwtToken = this.decodeJwtCookie();
if (!decodedJwtToken) {
// This is an unexpected case. The refresh endpoint should
// set the cookie that is needed. See ARCH-948 for more
// information on a similar situation that was happening
// prior to this refactor in Oct 2019.
const error = new Error('Access token is still null after successful refresh.');
error.customAttributes = { axiosResponse };
throw error;
}
return decodedJwtToken;
};
this.refreshRequestPromises[this.tokenCookieName] = makeRefreshRequest().finally(() => {
delete this.refreshRequestPromises[this.tokenCookieName];
});
}
return this.refreshRequestPromises[this.tokenCookieName];
}
async getJwtToken() {
// eslint-disable-next-line no-useless-catch
try {
const decodedJwtToken = this.decodeJwtCookie(this.tokenCookieName);
if (!AxiosJwtTokenService.isTokenExpired(decodedJwtToken)) {
return decodedJwtToken;
}
} catch (e) {
// Log unexpected error and continue with attempt to refresh it.
throw e;
}
// eslint-disable-next-line no-useless-catch
try {
return await this.refresh();
} catch (e) {
throw e;
}
}
}

View File

@@ -1,14 +0,0 @@
Responsibilities
================
The code in the jwt_auth folder was pulled from https://github.com/openedx/frontend-platform/tree/master/src/auth
Primarily the code required to use https://github.com/openedx/frontend-platform/blob/master/src/auth/AxiosJwtTokenService.js
This code will require updates if changes are made in the AxiosJwtTokenService.
The responsibility of this code is to refresh and manage the JWT authentication token.
It is included in all of our Micro Front-ends (MFE), but in edx-platform course
dashboard and other frontend locations that are not yet in MFE form we still
need to update the token to be able to call APIs in other IDAs.
TODO: Investigate a long term approach to the JWT refresh issue in LMS https://openedx.atlassian.net/browse/MICROBA-548

View File

@@ -1,78 +0,0 @@
/**
* Interceptor class to support JWT Token Authentication.
*
* Temporarily copied from the edx/frontend-platform
*/
import axios from 'axios';
// This default algorithm is a recreation of what is documented here
// https://cloud.google.com/storage/docs/exponential-backoff
const defaultGetBackoffMilliseconds = (nthRetry, maximumBackoffMilliseconds = 16000) => {
// Retry at exponential intervals (2, 4, 8, 16...)
const exponentialBackoffSeconds = 2 ** nthRetry;
// Add some randomness to avoid sending retries from separate requests all at once
const randomFractionOfASecond = Math.random();
const backoffSeconds = exponentialBackoffSeconds + randomFractionOfASecond;
const backoffMilliseconds = Math.round(backoffSeconds * 1000);
return Math.min(backoffMilliseconds, maximumBackoffMilliseconds);
};
const createRetryInterceptor = (options = {}) => {
const {
httpClient = axios.create(),
getBackoffMilliseconds = defaultGetBackoffMilliseconds,
// By default only retry outbound request failures (not responses)
shouldRetry = (error) => {
const isRequestError = !error.response && error.config;
return isRequestError;
},
// A per-request maxRetries can be specified in request config.
defaultMaxRetries = 2,
} = options;
const interceptor = async (error) => {
const { config } = error;
// If no config exists there was some other error setting up the request
if (!config) {
return Promise.reject(error);
}
if (!shouldRetry(error)) {
return Promise.reject(error);
}
const {
maxRetries = defaultMaxRetries,
} = config;
const retryRequest = async (nthRetry) => {
if (nthRetry > maxRetries) {
// Reject with the original error
return Promise.reject(error);
}
let retryResponse;
try {
const backoffDelay = getBackoffMilliseconds(nthRetry);
// Delay (wrapped in a promise so we can await the setTimeout)
// eslint-disable-next-line no-promise-executor-return
await new Promise(resolve => setTimeout(resolve, backoffDelay));
// Make retry request
retryResponse = await httpClient.request(config);
} catch (e) {
return retryRequest(nthRetry + 1);
}
return retryResponse;
};
return retryRequest(1);
};
return interceptor;
};
export default createRetryInterceptor;
export { defaultGetBackoffMilliseconds };

View File

@@ -1,111 +0,0 @@
/**
* Utils file to support JWT Token Authentication.
*
* Temporarily copied from the edx/frontend-platform
*/
// Lifted from here: https://regexr.com/3ok5o
const urlRegex = /([a-z]{1,2}tps?):\/\/((?:(?!(?:\/|#|\?|&)).)+)(?:(\/(?:(?:(?:(?!(?:#|\?|&)).)+\/))?))?(?:((?:(?!(?:\.|$|\?|#)).)+))?(?:(\.(?:(?!(?:\?|$|#)).)+))?(?:(\?(?:(?!(?:$|#)).)+))?(?:(#.+))?/;
const getUrlParts = (url) => {
const found = url.match(urlRegex);
try {
const [
fullUrl,
protocol,
domain,
path,
endFilename,
endFileExtension,
query,
hash,
] = found;
return {
fullUrl,
protocol,
domain,
path,
endFilename,
endFileExtension,
query,
hash,
};
} catch (e) {
throw new Error(`Could not find url parts from ${url}.`);
}
};
const logFrontendAuthError = (loggingService, error) => {
const prefixedMessageError = Object.create(error);
prefixedMessageError.message = `[frontend-auth] ${error.message}`;
loggingService.logError(prefixedMessageError, prefixedMessageError.customAttributes);
};
const processAxiosError = (axiosErrorObject) => {
const error = Object.create(axiosErrorObject);
const { request, response, config } = error;
if (!config) {
error.customAttributes = {
...error.customAttributes,
httpErrorType: 'unknown-api-request-error',
};
return error;
}
const {
url: httpErrorRequestUrl,
method: httpErrorRequestMethod,
} = config;
/* istanbul ignore else: difficult to enter the request-only error case in a unit test */
if (response) {
const { status, data } = response;
const stringifiedData = JSON.stringify(data) || '(empty response)';
const responseIsHTML = stringifiedData.includes('<!DOCTYPE html>');
// Don't include data if it is just an HTML document, like a 500 error page.
/* istanbul ignore next */
const httpErrorResponseData = responseIsHTML ? '<Response is HTML>' : stringifiedData;
error.customAttributes = {
...error.customAttributes,
httpErrorType: 'api-response-error',
httpErrorStatus: status,
httpErrorResponseData,
httpErrorRequestUrl,
httpErrorRequestMethod,
};
error.message = `Axios Error (Response): ${status} ${httpErrorRequestUrl} ${httpErrorResponseData}`;
} else if (request) {
error.customAttributes = {
...error.customAttributes,
httpErrorType: 'api-request-error',
httpErrorMessage: error.message,
httpErrorRequestUrl,
httpErrorRequestMethod,
};
// This case occurs most likely because of intermittent internet connection issues
// but it also, though less often, catches CORS or server configuration problems.
error.message = `Axios Error (Request): ${error.message} (possible local connectivity issue) ${httpErrorRequestMethod} ${httpErrorRequestUrl}`;
} else {
error.customAttributes = {
...error.customAttributes,
httpErrorType: 'api-request-config-error',
httpErrorMessage: error.message,
httpErrorRequestUrl,
httpErrorRequestMethod,
};
error.message = `Axios Error (Config): ${error.message} ${httpErrorRequestMethod} ${httpErrorRequestUrl}`;
}
return error;
};
const processAxiosErrorAndThrow = (axiosErrorObject) => {
throw processAxiosError(axiosErrorObject);
};
export {
getUrlParts,
logFrontendAuthError,
processAxiosError,
processAxiosErrorAndThrow,
};

39
package-lock.json generated
View File

@@ -20,7 +20,6 @@
"@edx/frontend-component-cookie-policy-banner": "2.2.0",
"@edx/paragon": "2.6.4",
"@edx/studio-frontend": "^2.1.0",
"axios": "^0.28.0",
"babel-loader": "^9.1.3",
"babel-plugin-transform-class-properties": "6.24.1",
"babel-polyfill": "6.26.0",
@@ -45,7 +44,6 @@
"jquery-migrate": "1.4.1",
"jquery.scrollto": "2.1.3",
"js-cookie": "3.0.5",
"jwt-decode": "^3.1.2",
"moment": "2.30.1",
"moment-timezone": "0.5.45",
"node-gyp": "10.0.1",
@@ -71,7 +69,6 @@
"uglify-js": "2.7.0",
"underscore": "1.12.1",
"underscore.string": "3.3.6",
"universal-cookie": "^4.0.4",
"webpack": "^5.90.3",
"webpack-bundle-tracker": "0.4.3",
"webpack-merge": "4.1.1",
@@ -5298,17 +5295,6 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.28.1.tgz",
"integrity": "sha512-iUcGA5a7p0mVb4Gm/sy+FSECNkPFT4y7wt6OM/CDpO/OnNCvSs3PoMG8ibrC9jRoGYU0gUK5pXVC4NPXq6lHRQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz",
@@ -10342,6 +10328,7 @@
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"dev": true,
"funding": [
{
"type": "individual",
@@ -10429,19 +10416,6 @@
"node": "*"
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/formatio": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/formatio/-/formatio-1.2.0.tgz",
@@ -15296,11 +15270,6 @@
"node": ">=4.0"
}
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/karma": {
"version": "0.13.22",
"resolved": "https://registry.npmjs.org/karma/-/karma-0.13.22.tgz",
@@ -19344,12 +19313,6 @@
"react": ">=0.14.0"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",

View File

@@ -26,7 +26,6 @@
"@edx/frontend-component-cookie-policy-banner": "2.2.0",
"@edx/paragon": "2.6.4",
"@edx/studio-frontend": "^2.1.0",
"axios": "^0.28.0",
"babel-loader": "^9.1.3",
"babel-plugin-transform-class-properties": "6.24.1",
"babel-polyfill": "6.26.0",
@@ -51,7 +50,6 @@
"jquery-migrate": "1.4.1",
"jquery.scrollto": "2.1.3",
"js-cookie": "3.0.5",
"jwt-decode": "^3.1.2",
"moment": "2.30.1",
"moment-timezone": "0.5.45",
"node-gyp": "10.0.1",
@@ -77,7 +75,6 @@
"uglify-js": "2.7.0",
"underscore": "1.12.1",
"underscore.string": "3.3.6",
"universal-cookie": "^4.0.4",
"webpack": "^5.90.3",
"webpack-bundle-tracker": "0.4.3",
"webpack-merge": "4.1.1",

View File

@@ -98,9 +98,6 @@ module.exports = Merge.smart({
StudentAccountDeletion: './lms/static/js/student_account/components/StudentAccountDeletion.jsx',
StudentAccountDeletionInitializer: './lms/static/js/student_account/StudentAccountDeletionInitializer.js',
ProblemBrowser: './lms/djangoapps/instructor/static/instructor/ProblemBrowser/index.jsx',
DemographicsCollectionBanner: './lms/static/js/demographics_collection/DemographicsCollectionBanner.jsx',
DemographicsCollectionModal: './lms/static/js/demographics_collection/DemographicsCollectionModal.jsx',
AxiosJwtTokenService: './lms/static/js/jwt_auth/AxiosJwtTokenService.js',
EnterpriseLearnerPortalModal: './lms/static/js/learner_dashboard/EnterpriseLearnerPortalModal.jsx',
// Learner Dashboard