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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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! You’re 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};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
@@ -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
39
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user