feat: add dynamic optional fields support (#534)
Added a new component that renders fields based on the field descriptions returned from backend VAN-835
This commit is contained in:
1
.env
1
.env
@@ -25,3 +25,4 @@ REGISTER_CONVERSION_COOKIE_NAME=null
|
||||
ENABLE_PROGRESSIVE_PROFILING=''
|
||||
MARKETING_EMAILS_OPT_IN=''
|
||||
ENABLE_COPPA_COMPLIANCE=''
|
||||
SHOW_DYNAMIC_PROFILING_PAGE=''
|
||||
|
||||
@@ -8,5 +8,6 @@ module.exports = createConfig('jest', {
|
||||
'src/setupTest.js',
|
||||
'src/i18n',
|
||||
'src/index.jsx',
|
||||
'MainApp.jsx',
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
|
||||
import {
|
||||
@@ -13,7 +14,7 @@ import configureStore from './data/configureStore';
|
||||
import { updatePathWithQueryParams } from './data/utils';
|
||||
import ForgotPasswordPage from './forgot-password';
|
||||
import ResetPasswordPage from './reset-password';
|
||||
import WelcomePage from './welcome';
|
||||
import WelcomePage, { ProgressiveProfiling } from './welcome';
|
||||
import './index.scss';
|
||||
|
||||
registerIcons();
|
||||
@@ -28,7 +29,11 @@ const MainApp = () => (
|
||||
<UnAuthOnlyRoute exact path={REGISTER_PAGE} component={Logistration} />
|
||||
<UnAuthOnlyRoute exact path={RESET_PAGE} component={ForgotPasswordPage} />
|
||||
<Route exact path={PASSWORD_RESET_CONFIRM} component={ResetPasswordPage} />
|
||||
<Route exact path={WELCOME_PAGE} component={WelcomePage} />
|
||||
<Route
|
||||
exact
|
||||
path={WELCOME_PAGE}
|
||||
component={(getConfig().SHOW_DYNAMIC_PROFILING_PAGE) ? ProgressiveProfiling : WelcomePage}
|
||||
/>
|
||||
<Route path={PAGE_NOT_FOUND} component={NotFoundPage} />
|
||||
<Route path="*">
|
||||
<Redirect to={PAGE_NOT_FOUND} />
|
||||
|
||||
@@ -15,6 +15,15 @@ $apple-black: #000000;
|
||||
$apple-focus-black: $apple-black;
|
||||
$accent-a-light: #c9f2f5;
|
||||
|
||||
.centered-align-spinner {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
position: absolute;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@extend .pt-4;
|
||||
min-width: 464px !important;
|
||||
|
||||
@@ -70,7 +70,7 @@ const LogistrationDefaultProps = {
|
||||
};
|
||||
const LogistrationProps = {
|
||||
secondaryProviders: PropTypes.arrayOf(PropTypes.shape({
|
||||
name: PropTypes.string.isRequried,
|
||||
name: PropTypes.string.isRequired,
|
||||
loginUrl: PropTypes.string.isRequired,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export const API_RATELIMIT_ERROR = 'api-ratelimit-error';
|
||||
export const DEFAULT_STATE = 'default';
|
||||
export const PENDING_STATE = 'pending';
|
||||
export const COMPLETE_STATE = 'complete';
|
||||
export const FAILURE_STATE = 'failure';
|
||||
|
||||
// Regex
|
||||
export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*'
|
||||
|
||||
98
src/field-renderer/FieldRenderer.jsx
Normal file
98
src/field-renderer/FieldRenderer.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { Form, Icon } from '@edx/paragon';
|
||||
import { ExpandMore } from '@edx/paragon/icons';
|
||||
|
||||
const FormFieldRenderer = (props) => {
|
||||
let formField = null;
|
||||
const { fieldData, onChangeHandler, value } = props;
|
||||
|
||||
switch (fieldData.type) {
|
||||
case 'select': {
|
||||
if (!fieldData.options) {
|
||||
return null;
|
||||
}
|
||||
formField = (
|
||||
<Form.Group controlId={fieldData.name} className="mb-3">
|
||||
<Form.Control
|
||||
as="select"
|
||||
name={fieldData.name}
|
||||
value={value}
|
||||
onChange={(e) => onChangeHandler(e)}
|
||||
trailingElement={<Icon src={ExpandMore} />}
|
||||
floatingLabel={fieldData.label}
|
||||
>
|
||||
<option key="default" value="">{fieldData.label}</option>
|
||||
{fieldData.options.map(option => (
|
||||
<option className="data-hj-suppress" key={option[0]} value={option[0]}>{option[1]}</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'textarea': {
|
||||
formField = (
|
||||
<Form.Group controlId={fieldData.name} className="mb-3">
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
name={fieldData.name}
|
||||
value={value}
|
||||
onChange={(e) => onChangeHandler(e)}
|
||||
floatingLabel={fieldData.label}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'text': {
|
||||
formField = (
|
||||
<Form.Group controlId={fieldData.name} className="mb-3">
|
||||
<Form.Control
|
||||
name={fieldData.name}
|
||||
value={value}
|
||||
onChange={(e) => onChangeHandler(e)}
|
||||
floatingLabel={fieldData.label}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'checkbox': {
|
||||
formField = (
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Checkbox
|
||||
id={fieldData.name}
|
||||
checked={!!value}
|
||||
name={fieldData.name}
|
||||
value={value}
|
||||
onChange={(e) => onChangeHandler(e)}
|
||||
>
|
||||
{fieldData.label}
|
||||
</Form.Checkbox>
|
||||
</Form.Group>
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return formField;
|
||||
};
|
||||
FormFieldRenderer.defaultProps = {
|
||||
value: '',
|
||||
};
|
||||
|
||||
FormFieldRenderer.propTypes = {
|
||||
fieldData: PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
label: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
}).isRequired,
|
||||
onChangeHandler: PropTypes.func.isRequired,
|
||||
value: PropTypes.string,
|
||||
};
|
||||
|
||||
export default FormFieldRenderer;
|
||||
1
src/field-renderer/index.jsx
Normal file
1
src/field-renderer/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './FieldRenderer';
|
||||
105
src/field-renderer/tests/FieldRenderer.test.jsx
Normal file
105
src/field-renderer/tests/FieldRenderer.test.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import FieldRenderer from '../FieldRenderer';
|
||||
|
||||
describe('FieldRendererTests', () => {
|
||||
let value = '';
|
||||
|
||||
const changeHandler = (e) => {
|
||||
if (e.target.type === 'checkbox') {
|
||||
value = e.target.checked;
|
||||
} else {
|
||||
value = e.target.value;
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
value = '';
|
||||
});
|
||||
|
||||
it('should render select field type', () => {
|
||||
const fieldData = {
|
||||
type: 'select',
|
||||
label: 'Year of Birth',
|
||||
name: 'yob-field',
|
||||
options: [['1997', 1997], ['1998', 1998]],
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('select#yob-field');
|
||||
field.simulate('change', { target: { value: 1997 } });
|
||||
|
||||
expect(field.type()).toEqual('select');
|
||||
expect(fieldRenderer.find('label').text()).toEqual('Year of Birth');
|
||||
expect(value).toEqual(1997);
|
||||
});
|
||||
|
||||
it('should return null if no options are provided for select field', () => {
|
||||
const fieldData = {
|
||||
type: 'select',
|
||||
label: 'Year of Birth',
|
||||
name: 'yob-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
|
||||
expect(fieldRenderer.html()).toBeNull();
|
||||
});
|
||||
|
||||
it('should render textarea field', () => {
|
||||
const fieldData = {
|
||||
type: 'textarea',
|
||||
label: 'Why do you want to join this platform?',
|
||||
name: 'goals-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('#goals-field').last();
|
||||
field.simulate('change', { target: { value: 'These are my goals.' } });
|
||||
|
||||
expect(field.type()).toEqual('textarea');
|
||||
expect(fieldRenderer.find('label').text()).toEqual('Why do you want to join this platform?');
|
||||
expect(value).toEqual('These are my goals.');
|
||||
});
|
||||
|
||||
it('should render an input field', () => {
|
||||
const fieldData = {
|
||||
type: 'text',
|
||||
label: 'Company',
|
||||
name: 'company-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('#company-field').last();
|
||||
field.simulate('change', { target: { value: 'ABC' } });
|
||||
|
||||
expect(field.type()).toEqual('input');
|
||||
expect(fieldRenderer.find('label').text()).toEqual('Company');
|
||||
expect(value).toEqual('ABC');
|
||||
});
|
||||
|
||||
it('should render checkbox field', () => {
|
||||
const fieldData = {
|
||||
type: 'checkbox',
|
||||
label: 'I agree that edX may send me marketing messages.',
|
||||
name: 'marketing-emails-opt-in-field',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer value={value} fieldData={fieldData} onChangeHandler={changeHandler} />);
|
||||
const field = fieldRenderer.find('input#marketing-emails-opt-in-field');
|
||||
field.simulate('change', { target: { checked: true, type: 'checkbox' } });
|
||||
|
||||
expect(field.prop('type')).toEqual('checkbox');
|
||||
expect(fieldRenderer.find('label').text()).toEqual('I agree that edX may send me marketing messages.');
|
||||
expect(value).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return null if field type is unknown', () => {
|
||||
const fieldData = {
|
||||
type: 'unknown',
|
||||
};
|
||||
|
||||
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
|
||||
expect(fieldRenderer.html()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ initialize({
|
||||
ENABLE_PROGRESSIVE_PROFILING: process.env.ENABLE_PROGRESSIVE_PROFILING || false,
|
||||
MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '',
|
||||
ENABLE_COPPA_COMPLIANCE: process.env.ENABLE_COPPA_COMPLIANCE || '',
|
||||
SHOW_DYNAMIC_PROFILING_PAGE: process.env.SHOW_DYNAMIC_PROFILING_PAGE || false,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
237
src/welcome/ProgressiveProfiling.jsx
Normal file
237
src/welcome/ProgressiveProfiling.jsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
configure as configureAuth,
|
||||
AxiosJwtAuthService,
|
||||
ensureAuthenticatedUser,
|
||||
hydrateAuthenticatedUser,
|
||||
getAuthenticatedUser,
|
||||
} from '@edx/frontend-platform/auth';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getLoggingService } from '@edx/frontend-platform/logging';
|
||||
import {
|
||||
Alert,
|
||||
Form,
|
||||
StatefulButton,
|
||||
Hyperlink,
|
||||
Spinner,
|
||||
} from '@edx/paragon';
|
||||
import { Error } from '@edx/paragon/icons';
|
||||
|
||||
import { getFieldData, saveUserProfile } from './data/actions';
|
||||
import { welcomePageSelector } from './data/selectors';
|
||||
import messages from './messages';
|
||||
|
||||
import { RedirectLogistration } from '../common-components';
|
||||
import {
|
||||
DEFAULT_REDIRECT_URL, DEFAULT_STATE, FAILURE_STATE, COMPLETE_STATE,
|
||||
} from '../data/constants';
|
||||
import FormFieldRenderer from '../field-renderer';
|
||||
import WelcomePageModal from './WelcomePageModal';
|
||||
import BaseComponent from '../base-component';
|
||||
|
||||
const ProgressiveProfiling = (props) => {
|
||||
const {
|
||||
extendedProfile, fieldDescriptions, formRenderState, intl, submitState, showError,
|
||||
} = props;
|
||||
|
||||
const [ready, setReady] = useState(false);
|
||||
const [registrationResult, setRegistrationResult] = useState({ redirectUrl: '' });
|
||||
const [values, setValues] = useState({});
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
|
||||
useEffect(() => {
|
||||
configureAuth(AxiosJwtAuthService, { loggingService: getLoggingService(), config: getConfig() });
|
||||
ensureAuthenticatedUser(DASHBOARD_URL).then(() => {
|
||||
hydrateAuthenticatedUser().then(() => {
|
||||
props.getFieldData();
|
||||
setReady(true);
|
||||
});
|
||||
});
|
||||
|
||||
if (props.location.state && props.location.state.registrationResult) {
|
||||
setRegistrationResult(props.location.state.registrationResult);
|
||||
sendPageEvent('login_and_registration', 'welcome');
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!props.location.state || !props.location.state.registrationResult || formRenderState === FAILURE_STATE) {
|
||||
global.location.assign(DASHBOARD_URL);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const authenticatedUser = getAuthenticatedUser();
|
||||
const payload = { ...values, extendedProfile: [] };
|
||||
extendedProfile.forEach(fieldName => {
|
||||
if (values[fieldName]) {
|
||||
payload.extendedProfile.push({ fieldName, fieldValue: values[fieldName] });
|
||||
}
|
||||
delete payload[fieldName];
|
||||
});
|
||||
props.saveUserProfile(authenticatedUser.username, snakeCaseObject(payload));
|
||||
|
||||
sendTrackEvent(
|
||||
'edx.bi.welcome.page.submit.clicked',
|
||||
{
|
||||
isGenderSelected: !!values.gender,
|
||||
isYearOfBirthSelected: !!values.year_of_birth,
|
||||
isLevelOfEducationSelected: !!values.level_of_education,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleSkip = (e) => {
|
||||
e.preventDefault();
|
||||
setOpenDialog(true);
|
||||
sendTrackEvent('edx.bi.welcome.page.skip.link.clicked');
|
||||
};
|
||||
|
||||
const onChangeHandler = (e) => {
|
||||
if (e.target.type === 'checkbox') {
|
||||
setValues({ ...values, [e.target.name]: e.target.checked });
|
||||
} else {
|
||||
setValues({ ...values, [e.target.name]: e.target.value });
|
||||
}
|
||||
};
|
||||
|
||||
const formFields = Object.keys(fieldDescriptions).map((fieldName) => {
|
||||
const fieldData = fieldDescriptions[fieldName];
|
||||
return (
|
||||
<span key={fieldData.name}>
|
||||
<FormFieldRenderer
|
||||
fieldData={fieldData}
|
||||
value={values[fieldData.name]}
|
||||
onChangeHandler={onChangeHandler}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
if (formRenderState === COMPLETE_STATE) {
|
||||
return (
|
||||
<>
|
||||
<BaseComponent showWelcomeBanner>
|
||||
<Helmet>
|
||||
<title>{intl.formatMessage(messages['progressive.profiling.page.title'],
|
||||
{ siteName: getConfig().SITE_NAME })}
|
||||
</title>
|
||||
</Helmet>
|
||||
<WelcomePageModal isOpen={openDialog} redirectUrl={registrationResult.redirectUrl} />
|
||||
{props.shouldRedirect ? (
|
||||
<RedirectLogistration
|
||||
success
|
||||
redirectUrl={registrationResult.redirectUrl}
|
||||
/>
|
||||
) : null}
|
||||
<div className="mw-xs pp-page-content">
|
||||
<div className="pp-page-heading">
|
||||
<h2 className="h3 text-primary">{intl.formatMessage(messages['progressive.profiling.page.heading'])}</h2>
|
||||
</div>
|
||||
<hr className="border-light-700 mb-4" />
|
||||
{showError ? (
|
||||
<Alert id="pp-page-errors" className="mb-3" variant="danger" icon={Error}>
|
||||
<Alert.Heading>{intl.formatMessage(messages['welcome.page.error.heading'])}</Alert.Heading>
|
||||
<p>{intl.formatMessage(messages['welcome.page.error.message'])}</p>
|
||||
</Alert>
|
||||
) : null}
|
||||
<Form>
|
||||
{formFields}
|
||||
<span className="progressive-profiling-support">
|
||||
<Hyperlink
|
||||
isInline
|
||||
variant="muted"
|
||||
destination={getConfig().WELCOME_PAGE_SUPPORT_LINK}
|
||||
target="_blank"
|
||||
showLaunchIcon={false}
|
||||
onClick={() => (sendTrackEvent('edx.bi.welcome.page.support.link.clicked'))}
|
||||
>
|
||||
{intl.formatMessage(messages['optional.fields.information.link'])}
|
||||
</Hyperlink>
|
||||
</span>
|
||||
<div className="d-flex mt-4 mb-3">
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
variant="brand"
|
||||
className="login-button-width"
|
||||
state={submitState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['optional.fields.submit.button']),
|
||||
pending: '',
|
||||
}}
|
||||
onClick={handleSubmit}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
<StatefulButton
|
||||
className="text-gray-700 font-weight-500"
|
||||
type="submit"
|
||||
variant="link"
|
||||
labels={{
|
||||
default: intl.formatMessage(messages['optional.fields.skip.button']),
|
||||
}}
|
||||
onClick={handleSkip}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</BaseComponent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <Spinner id="loader" animation="border" variant="primary" className="centered-align-spinner" />;
|
||||
};
|
||||
|
||||
ProgressiveProfiling.propTypes = {
|
||||
extendedProfile: PropTypes.arrayOf(PropTypes.string),
|
||||
fieldDescriptions: PropTypes.shape({}),
|
||||
formRenderState: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
location: PropTypes.shape({
|
||||
state: PropTypes.object,
|
||||
}),
|
||||
getFieldData: PropTypes.func.isRequired,
|
||||
saveUserProfile: PropTypes.func.isRequired,
|
||||
showError: PropTypes.bool,
|
||||
shouldRedirect: PropTypes.bool,
|
||||
submitState: PropTypes.string,
|
||||
};
|
||||
|
||||
ProgressiveProfiling.defaultProps = {
|
||||
extendedProfile: [],
|
||||
fieldDescriptions: {},
|
||||
location: { state: {} },
|
||||
shouldRedirect: false,
|
||||
showError: false,
|
||||
submitState: DEFAULT_STATE,
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
extendedProfile: welcomePageSelector(state).extendedProfile,
|
||||
fieldDescriptions: welcomePageSelector(state).fieldDescriptions,
|
||||
formRenderState: welcomePageSelector(state).formRenderState,
|
||||
shouldRedirect: welcomePageSelector(state).success,
|
||||
submitState: welcomePageSelector(state).submitState,
|
||||
showError: welcomePageSelector(state).showError,
|
||||
});
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
{
|
||||
saveUserProfile,
|
||||
getFieldData,
|
||||
},
|
||||
)(injectIntl(ProgressiveProfiling));
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AsyncActionType } from '../../data/utils';
|
||||
|
||||
export const GET_FIELDS_DATA = new AsyncActionType('FIELD_DESCRIPTION', 'GET_FIELDS_DATA');
|
||||
export const SAVE_USER_PROFILE = new AsyncActionType('USER_PROFILE', 'SAVE_USER_PROFILE');
|
||||
|
||||
// save additional user information
|
||||
@@ -19,3 +20,21 @@ export const saveUserProfileSuccess = () => ({
|
||||
export const saveUserProfileFailure = () => ({
|
||||
type: SAVE_USER_PROFILE.FAILURE,
|
||||
});
|
||||
|
||||
// get field data from platform
|
||||
export const getFieldData = () => ({
|
||||
type: GET_FIELDS_DATA.BASE,
|
||||
});
|
||||
|
||||
export const getFieldDataBegin = () => ({
|
||||
type: GET_FIELDS_DATA.BEGIN,
|
||||
});
|
||||
|
||||
export const getFieldDataSuccess = (data, extendedProfile) => ({
|
||||
type: GET_FIELDS_DATA.SUCCESS,
|
||||
payload: { data, extendedProfile },
|
||||
});
|
||||
|
||||
export const getFieldDataFailure = () => ({
|
||||
type: GET_FIELDS_DATA.FAILURE,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { SAVE_USER_PROFILE } from './actions';
|
||||
import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants';
|
||||
import { GET_FIELDS_DATA, SAVE_USER_PROFILE } from './actions';
|
||||
import {
|
||||
DEFAULT_STATE, PENDING_STATE, COMPLETE_STATE, FAILURE_STATE,
|
||||
} from '../../data/constants';
|
||||
|
||||
export const defaultState = {
|
||||
extendedProfile: [],
|
||||
fieldDescriptions: {},
|
||||
formRenderState: DEFAULT_STATE,
|
||||
success: false,
|
||||
submitState: DEFAULT_STATE,
|
||||
showError: false,
|
||||
@@ -9,6 +14,23 @@ export const defaultState = {
|
||||
|
||||
const reducer = (state = defaultState, action) => {
|
||||
switch (action.type) {
|
||||
case GET_FIELDS_DATA.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
formRenderState: PENDING_STATE,
|
||||
};
|
||||
case GET_FIELDS_DATA.SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
extendedProfile: action.payload.extendedProfile,
|
||||
fieldDescriptions: action.payload.data,
|
||||
formRenderState: COMPLETE_STATE,
|
||||
};
|
||||
case GET_FIELDS_DATA.FAILURE:
|
||||
return {
|
||||
...state,
|
||||
formRenderState: FAILURE_STATE,
|
||||
};
|
||||
case SAVE_USER_PROFILE.BEGIN:
|
||||
return {
|
||||
...state,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { call, put, takeEvery } from 'redux-saga/effects';
|
||||
|
||||
import {
|
||||
GET_FIELDS_DATA,
|
||||
getFieldDataBegin,
|
||||
getFieldDataFailure,
|
||||
getFieldDataSuccess,
|
||||
SAVE_USER_PROFILE,
|
||||
saveUserProfileBegin,
|
||||
saveUserProfileFailure,
|
||||
saveUserProfileSuccess,
|
||||
} from './actions';
|
||||
|
||||
import patchAccount from './service';
|
||||
import { patchAccount, getOptionalFieldData } from './service';
|
||||
|
||||
export function* saveUserProfileInformation(action) {
|
||||
try {
|
||||
@@ -20,6 +24,17 @@ export function* saveUserProfileInformation(action) {
|
||||
}
|
||||
}
|
||||
|
||||
export function* getFieldData() {
|
||||
try {
|
||||
yield put(getFieldDataBegin());
|
||||
const data = yield call(getOptionalFieldData);
|
||||
yield put(getFieldDataSuccess(data.fields, data.extended_profile));
|
||||
} catch (e) {
|
||||
yield put(getFieldDataFailure());
|
||||
}
|
||||
}
|
||||
|
||||
export default function* saga() {
|
||||
yield takeEvery(SAVE_USER_PROFILE.BASE, saveUserProfileInformation);
|
||||
yield takeEvery(GET_FIELDS_DATA.BASE, getFieldData);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
export default async function patchAccount(username, commitValues) {
|
||||
export async function patchAccount(username, commitValues) {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/merge-patch+json' },
|
||||
};
|
||||
@@ -16,3 +16,20 @@ export default async function patchAccount(username, commitValues) {
|
||||
throw (error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOptionalFieldData() {
|
||||
const requestConfig = {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(
|
||||
`${getConfig().LMS_BASE_URL}/api/optional_fields`,
|
||||
requestConfig,
|
||||
)
|
||||
.catch((e) => {
|
||||
throw (e);
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default } from './WelcomePage';
|
||||
export { default as ProgressiveProfiling } from './ProgressiveProfiling';
|
||||
export { default as reducer } from './data/reducers';
|
||||
export { default as saga } from './data/sagas';
|
||||
export { storeName } from './data/selectors';
|
||||
|
||||
200
src/welcome/tests/ProgressiveProfiling.test.jsx
Normal file
200
src/welcome/tests/ProgressiveProfiling.test.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React from 'react';
|
||||
|
||||
import { mount } from 'enzyme';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Provider } from 'react-redux';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
import { getConfig, mergeConfig } from '@edx/frontend-platform';
|
||||
import * as analytics from '@edx/frontend-platform/analytics';
|
||||
import * as auth from '@edx/frontend-platform/auth';
|
||||
import * as logging from '@edx/frontend-platform/logging';
|
||||
import { injectIntl, IntlProvider, configure } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { getFieldData, saveUserProfile } from '../data/actions';
|
||||
import ProgressiveProfiling from '../ProgressiveProfiling';
|
||||
import {
|
||||
COMPLETE_STATE, DEFAULT_REDIRECT_URL, FAILURE_STATE, PENDING_STATE,
|
||||
} from '../../data/constants';
|
||||
|
||||
const IntlProgressiveProfilingPage = injectIntl(ProgressiveProfiling);
|
||||
const mockStore = configureStore();
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics');
|
||||
jest.mock('@edx/frontend-platform/auth');
|
||||
jest.mock('@edx/frontend-platform/logging');
|
||||
|
||||
analytics.sendTrackEvent = jest.fn();
|
||||
analytics.sendPageEvent = jest.fn();
|
||||
logging.getLoggingService = jest.fn();
|
||||
|
||||
auth.configure = jest.fn();
|
||||
auth.ensureAuthenticatedUser = jest.fn().mockImplementation(() => Promise.resolve(true));
|
||||
auth.hydrateAuthenticatedUser = jest.fn().mockImplementation(() => Promise.resolve(true));
|
||||
|
||||
describe('ProgressiveProfilingTests', () => {
|
||||
mergeConfig({
|
||||
WELCOME_PAGE_SUPPORT_LINK: 'http://localhost:1999/welcome',
|
||||
});
|
||||
|
||||
const registrationResult = { redirectUrl: 'http://localhost:18000/dashboard', success: true };
|
||||
let props = {};
|
||||
let store = {};
|
||||
const DASHBOARD_URL = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL);
|
||||
const initialState = {
|
||||
welcomePage: {
|
||||
formRenderState: COMPLETE_STATE,
|
||||
},
|
||||
};
|
||||
|
||||
const reduxWrapper = children => (
|
||||
<IntlProvider locale="en">
|
||||
<Provider store={store}>{children}</Provider>
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
const getProgressiveProfilingPage = async () => {
|
||||
const progressiveProfilingPage = mount(reduxWrapper(<IntlProgressiveProfilingPage {...props} />));
|
||||
await act(async () => {
|
||||
await Promise.resolve(progressiveProfilingPage);
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
progressiveProfilingPage.update();
|
||||
});
|
||||
|
||||
return progressiveProfilingPage;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
store = mockStore(initialState);
|
||||
configure({
|
||||
loggingService: { logError: jest.fn() },
|
||||
config: {
|
||||
ENVIRONMENT: 'production',
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
|
||||
},
|
||||
messages: { 'es-419': {}, de: {}, 'en-us': {} },
|
||||
});
|
||||
props = {
|
||||
getFieldData: jest.fn(),
|
||||
location: {
|
||||
state: {
|
||||
registrationResult,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should fire action to get form fields', async () => {
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
|
||||
progressiveProfilingPage.find('button.btn-link').simulate('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(getFieldData());
|
||||
});
|
||||
|
||||
it('should show spinner until fields are fetched', async () => {
|
||||
store = mockStore({
|
||||
welcomePage: {
|
||||
formRenderState: PENDING_STATE,
|
||||
},
|
||||
});
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
expect(progressiveProfilingPage.find('#loader').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render fields returned by backend api', async () => {
|
||||
store = mockStore({
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
fieldDescriptions: {
|
||||
gender: {
|
||||
name: 'gender',
|
||||
type: 'select',
|
||||
label: 'Gender',
|
||||
options: [['m', 'Male'], ['f', 'Female'], ['o', 'Other/Prefer Not to Say']],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
expect(progressiveProfilingPage.find('#gender').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should submit user profile details on form submission', async () => {
|
||||
auth.getAuthenticatedUser = jest.fn(() => ({ userId: 3, username: 'abc123' }));
|
||||
const formPayload = {
|
||||
gender: 'm',
|
||||
extended_profile: [{ field_name: 'company', field_value: 'edx' }],
|
||||
};
|
||||
store = mockStore({
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
extendedProfile: ['company'],
|
||||
fieldDescriptions: {
|
||||
gender: {
|
||||
name: 'gender',
|
||||
type: 'select',
|
||||
label: 'Gender',
|
||||
options: [['m', 'Male'], ['f', 'Female'], ['o', 'Other/Prefer Not to Say']],
|
||||
},
|
||||
company: {
|
||||
name: 'company',
|
||||
type: 'text',
|
||||
label: 'Company',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
store.dispatch = jest.fn(store.dispatch);
|
||||
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
progressiveProfilingPage.find('select#gender').simulate('change', { target: { value: 'm', name: 'gender' } });
|
||||
progressiveProfilingPage.find('input#company').simulate('change', { target: { value: 'edx', name: 'company' } });
|
||||
|
||||
progressiveProfilingPage.find('button.btn-brand').simulate('click');
|
||||
expect(store.dispatch).toHaveBeenCalledWith(saveUserProfile('abc123', formPayload));
|
||||
});
|
||||
|
||||
it('should open modal on pressing skip for now button', async () => {
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
|
||||
progressiveProfilingPage.find('button.btn-link').simulate('click');
|
||||
expect(progressiveProfilingPage.find('.pgn__modal-content-container').exists()).toBeTruthy();
|
||||
expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked');
|
||||
});
|
||||
|
||||
it('should send analytic event for support link click', async () => {
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
|
||||
progressiveProfilingPage.find('.progressive-profiling-support a[target="_blank"]').simulate('click');
|
||||
expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked');
|
||||
});
|
||||
|
||||
it('should show error message when patch request fails', async () => {
|
||||
store = mockStore({
|
||||
welcomePage: {
|
||||
...initialState.welcomePage,
|
||||
showError: true,
|
||||
},
|
||||
});
|
||||
|
||||
const progressiveProfilingPage = await getProgressiveProfilingPage();
|
||||
expect(progressiveProfilingPage.find('#pp-page-errors').exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should redirect to dashboard if no form fields are configured', async () => {
|
||||
store = mockStore({
|
||||
welcomePage: {
|
||||
formRenderState: FAILURE_STATE,
|
||||
},
|
||||
});
|
||||
|
||||
delete window.location;
|
||||
window.location = {
|
||||
href: getConfig().BASE_URL,
|
||||
assign: jest.fn().mockImplementation((value) => { window.location.href = value; }),
|
||||
};
|
||||
await getProgressiveProfilingPage();
|
||||
expect(window.location.href).toBe(DASHBOARD_URL);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user