feat: add required field support (#557)

This commit is contained in:
Attiya Ishaque
2022-05-19 18:22:51 +05:00
committed by GitHub
parent 25bc11ea8d
commit bce8aa712d
22 changed files with 719 additions and 65 deletions

View File

@@ -624,6 +624,7 @@ select.form-control {
position: absolute;
background-color: #fff;
width: 464px;
z-index: 100 !important;
}
.email-error-alert {
@@ -857,7 +858,6 @@ select.form-control {
font-size: 0.75rem;
line-height: 1.25rem;
}
margin-top: 1rem;
margin-left: 3px;
}
.suggested-username {

View File

@@ -12,9 +12,9 @@ export const getThirdPartyAuthContextBegin = () => ({
type: THIRD_PARTY_AUTH_CONTEXT.BEGIN,
});
export const getThirdPartyAuthContextSuccess = (thirdPartyAuthContext) => ({
export const getThirdPartyAuthContextSuccess = (fieldDescriptions, thirdPartyAuthContext) => ({
type: THIRD_PARTY_AUTH_CONTEXT.SUCCESS,
payload: { thirdPartyAuthContext },
payload: { fieldDescriptions, thirdPartyAuthContext },
});
export const getThirdPartyAuthContextFailure = () => ({

View File

@@ -3,6 +3,8 @@ import { THIRD_PARTY_AUTH_CONTEXT } from './actions';
import { PENDING_STATE, COMPLETE_STATE } from '../../data/constants';
export const defaultState = {
extendedProfile: [],
fieldDescriptions: {},
thirdPartyAuthApiStatus: null,
};
@@ -16,6 +18,8 @@ const reducer = (state = defaultState, action) => {
case THIRD_PARTY_AUTH_CONTEXT.SUCCESS:
return {
...state,
extendedProfile: action.payload.fieldDescriptions.extendedProfile,
fieldDescriptions: action.payload.fieldDescriptions.fields,
thirdPartyAuthContext: action.payload.thirdPartyAuthContext,
thirdPartyAuthApiStatus: COMPLETE_STATE,
};

View File

@@ -18,10 +18,10 @@ import {
export function* fetchThirdPartyAuthContext(action) {
try {
yield put(getThirdPartyAuthContextBegin());
const { thirdPartyAuthContext } = yield call(getThirdPartyAuthContext, action.payload.urlParams);
const { fieldDescriptions, thirdPartyAuthContext } = yield call(getThirdPartyAuthContext, action.payload.urlParams);
yield put(getThirdPartyAuthContextSuccess(
thirdPartyAuthContext,
fieldDescriptions, thirdPartyAuthContext,
));
} catch (e) {
yield put(getThirdPartyAuthContextFailure());

View File

@@ -8,3 +8,13 @@ export const thirdPartyAuthContextSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.thirdPartyAuthContext,
);
export const fieldDescriptionSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.fieldDescriptions,
);
export const extendedProfileSelector = createSelector(
commonComponentsSelector,
commonComponents => commonComponents.extendedProfile,
);

View File

@@ -18,6 +18,11 @@ export async function getThirdPartyAuthContext(urlParams) {
throw (e);
});
return {
thirdPartyAuthContext: camelCaseObject(convertKeyNames(data, { fullname: 'name' })),
fieldDescriptions: data.registration_fields || {},
// For backward compatibility with the API, once https://github.com/openedx/edx-platform/pull/30198 is merged
// and deployed update it to use data.context_data
thirdPartyAuthContext: camelCaseObject(
convertKeyNames(data.context_data || data, { fullname: 'name' }),
),
};
}

View File

@@ -26,7 +26,7 @@ describe('fetchThirdPartyAuthContext', () => {
it('should call service and dispatch success action', async () => {
const getThirdPartyAuthContext = jest.spyOn(api, 'getThirdPartyAuthContext')
.mockImplementation(() => Promise.resolve({ thirdPartyAuthContext: data }));
.mockImplementation(() => Promise.resolve({ thirdPartyAuthContext: data, fieldDescriptions: {} }));
const dispatched = [];
await runSaga(
@@ -38,7 +38,7 @@ describe('fetchThirdPartyAuthContext', () => {
expect(getThirdPartyAuthContext).toHaveBeenCalledTimes(1);
expect(dispatched).toEqual([
actions.getThirdPartyAuthContextBegin(),
actions.getThirdPartyAuthContextSuccess(data),
actions.getThirdPartyAuthContextSuccess({}, data),
]);
getThirdPartyAuthContext.mockClear();
});

View File

@@ -6,7 +6,17 @@ import { ExpandMore } from '@edx/paragon/icons';
const FormFieldRenderer = (props) => {
let formField = null;
const { fieldData, onChangeHandler, value } = props;
const {
errorMessage, fieldData, onChangeHandler, isRequired, value,
} = props;
const handleFocus = (e) => {
if (props.handleFocus) { props.handleFocus(e); }
};
const handleOnBlur = (e) => {
if (props.handleBlur) { props.handleBlur(e); }
};
switch (fieldData.type) {
case 'select': {
@@ -14,63 +24,95 @@ const FormFieldRenderer = (props) => {
return null;
}
formField = (
<Form.Group controlId={fieldData.name} className="mb-3">
<Form.Group controlId={fieldData.name} isInvalid={isRequired && errorMessage}>
<Form.Control
as="select"
name={fieldData.name}
value={value}
aria-invalid={isRequired && Boolean(errorMessage)}
onChange={(e) => onChangeHandler(e)}
trailingElement={<Icon src={ExpandMore} />}
floatingLabel={fieldData.label}
onBlur={handleOnBlur}
onFocus={handleFocus}
>
<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>
{isRequired && errorMessage && (
<Form.Control.Feedback id={`${fieldData.name}-error`} type="invalid" className="form-text-size" hasIcon={false}>
{errorMessage}
</Form.Control.Feedback>
)}
</Form.Group>
);
break;
}
case 'textarea': {
formField = (
<Form.Group controlId={fieldData.name} className="mb-3">
<Form.Group controlId={fieldData.name} isInvalid={isRequired && errorMessage}>
<Form.Control
as="textarea"
name={fieldData.name}
value={value}
aria-invalid={isRequired && Boolean(errorMessage)}
onChange={(e) => onChangeHandler(e)}
floatingLabel={fieldData.label}
onBlur={handleOnBlur}
onFocus={handleFocus}
/>
{isRequired && errorMessage && (
<Form.Control.Feedback id={`${fieldData.name}-error`} type="invalid" className="form-text-size" hasIcon={false}>
{errorMessage}
</Form.Control.Feedback>
)}
</Form.Group>
);
break;
}
case 'text': {
formField = (
<Form.Group controlId={fieldData.name} className="mb-3">
<Form.Group controlId={fieldData.name} isInvalid={isRequired && errorMessage}>
<Form.Control
name={fieldData.name}
value={value}
aria-invalid={isRequired && Boolean(errorMessage)}
onChange={(e) => onChangeHandler(e)}
floatingLabel={fieldData.label}
onBlur={handleOnBlur}
onFocus={handleFocus}
/>
{isRequired && errorMessage && (
<Form.Control.Feedback id={`${fieldData.name}-error`} type="invalid" className="form-text-size" hasIcon={false}>
{errorMessage}
</Form.Control.Feedback>
)}
</Form.Group>
);
break;
}
case 'checkbox': {
formField = (
<Form.Group className="mb-3">
<Form.Group isInvalid={isRequired && errorMessage}>
<Form.Checkbox
id={fieldData.name}
checked={!!value}
name={fieldData.name}
value={value}
aria-invalid={isRequired && Boolean(errorMessage)}
onChange={(e) => onChangeHandler(e)}
onBlur={handleOnBlur}
onFocus={handleFocus}
>
{fieldData.label}
</Form.Checkbox>
{isRequired && errorMessage && (
<Form.Control.Feedback id={`${fieldData.name}-error`} type="invalid" className="form-text-size" hasIcon={false}>
{errorMessage}
</Form.Control.Feedback>
)}
</Form.Group>
);
break;
@@ -83,6 +125,10 @@ const FormFieldRenderer = (props) => {
};
FormFieldRenderer.defaultProps = {
value: '',
handleBlur: null,
handleFocus: null,
errorMessage: '',
isRequired: false,
};
FormFieldRenderer.propTypes = {
@@ -92,6 +138,10 @@ FormFieldRenderer.propTypes = {
name: PropTypes.string,
}).isRequired,
onChangeHandler: PropTypes.func.isRequired,
handleBlur: PropTypes.func,
handleFocus: PropTypes.func,
errorMessage: PropTypes.string,
isRequired: PropTypes.bool,
value: PropTypes.string,
};

View File

@@ -102,4 +102,96 @@ describe('FieldRendererTests', () => {
const fieldRenderer = mount(<FieldRenderer fieldData={fieldData} onChangeHandler={() => {}} />);
expect(fieldRenderer.html()).toBeNull();
});
it('should run onBlur and onFocus functions for a field if given', () => {
const fieldData = { type: 'text', label: 'Test', name: 'test-field' };
let functionValue = '';
const onBlur = (e) => {
functionValue = `${e.target.name} blurred`;
};
const onFocus = (e) => {
functionValue = `${e.target.name} focussed`;
};
const fieldRenderer = mount(
<FieldRenderer
handleFocus={onFocus}
handleBlur={onBlur}
value={value}
fieldData={fieldData}
onChangeHandler={changeHandler}
/>,
);
const field = fieldRenderer.find('#test-field').last();
field.simulate('focus');
expect(functionValue).toEqual('test-field focussed');
field.simulate('blur');
expect(functionValue).toEqual('test-field blurred');
});
it('should render error message for required text fields', () => {
const fieldData = { type: 'text', label: 'First Name', name: 'first-name-field' };
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
onChangeHandler={changeHandler}
errorMessage="Enter your first name"
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Enter your first name');
});
it('should render error message for required select fields', () => {
const fieldData = {
type: 'select', label: 'Preference', name: 'preference-field', options: [['a', 'Opt 1'], ['b', 'Opt 2']],
};
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
onChangeHandler={changeHandler}
errorMessage="Select your preference"
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Select your preference');
});
it('should render error message for required textarea fields', () => {
const fieldData = { type: 'textarea', label: 'Goals', name: 'goals-field' };
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
onChangeHandler={changeHandler}
errorMessage="Tell us your goals"
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('Tell us your goals');
});
it('should render error message for required checkbox fields', () => {
const fieldData = { type: 'checkbox', label: 'Honor Code', name: 'honor-code-field' };
const fieldRenderer = mount(
<FieldRenderer
isRequired
fieldData={fieldData}
onChangeHandler={changeHandler}
errorMessage="You must agree to our Honor Code"
/>,
);
expect(fieldRenderer.find('.form-text-size').last().text()).toEqual('You must agree to our Honor Code');
});
});

View File

@@ -41,6 +41,7 @@ initialize({
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,
ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false,
});
},
},

View File

@@ -176,7 +176,7 @@ class CountryDropdown extends React.Component {
render() {
return (
<div>
<div className="mb-4">
<FormGroup
as="input"
name={this.props.name}

View File

@@ -0,0 +1,88 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, Form } from '@edx/paragon';
import messages from './messages';
const HonorCode = (props) => {
const {
intl, errorMessage, onChangeHandler, fieldType, value,
} = props;
if (fieldType === 'tos_and_honor_code') {
return (
<div id="honor-code" className="micro text-muted mt-4">
<FormattedMessage
id="register.page.terms.of.service.and.honor.code"
defaultMessage="By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each
Member process your personal data in accordance with the {privacyPolicy}."
description="Text that appears on registration form stating honor code and privacy policy"
values={{
platformName: getConfig().SITE_NAME,
tosAndHonorCode: (
<Hyperlink variant="muted" destination={getConfig().TOS_AND_HONOR_CODE || '#'} target="_blank">
{intl.formatMessage(messages['terms.of.service.and.honor.code'])}
</Hyperlink>
),
privacyPolicy: (
<Hyperlink variant="muted" destination={getConfig().PRIVACY_POLICY || '#'} target="_blank">
{intl.formatMessage(messages['privacy.policy'])}
</Hyperlink>
),
}}
/>
</div>
);
}
return (
<div id="honor-code" className="micro text-muted">
<Form.Checkbox
className="opt-checkbox mt-1"
id="honor-code"
checked={value}
name="honor_code"
value={value}
onChange={onChangeHandler}
>
<FormattedMessage
id="register.page.honor.code"
defaultMessage="I agree to the {platformName} {tosAndHonorCode}"
description="Text that appears on registration form stating honor code"
values={{
platformName: getConfig().SITE_NAME,
tosAndHonorCode: (
<Hyperlink variant="muted" destination={getConfig().TOS_AND_HONOR_CODE || '#'} target="_blank">
{intl.formatMessage(messages['honor.code'])}
</Hyperlink>
),
}}
/>
</Form.Checkbox>
{errorMessage && (
<Form.Control.Feedback type="invalid" className="form-text-size" hasIcon={false}>
{errorMessage}
</Form.Control.Feedback>
)}
</div>
);
};
HonorCode.defaultProps = {
errorMessage: '',
onChangeHandler: null,
fieldType: 'honor_code',
value: false,
};
HonorCode.propTypes = {
intl: intlShape.isRequired,
errorMessage: PropTypes.string,
onChangeHandler: PropTypes.func,
fieldType: PropTypes.string,
value: PropTypes.bool,
};
export default injectIntl(HonorCode);

View File

@@ -5,35 +5,43 @@ import Skeleton from 'react-loading-skeleton';
import { Helmet } from 'react-helmet';
import PropTypes, { string } from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { getConfig, snakeCaseObject } from '@edx/frontend-platform';
import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics';
import {
injectIntl, intlShape, getCountryList, getLocale, FormattedMessage,
injectIntl, intlShape, getCountryList, getLocale,
} from '@edx/frontend-platform/i18n';
import {
Alert, Form, Hyperlink, StatefulButton, Icon,
Alert, Form, StatefulButton, Icon,
} from '@edx/paragon';
import { Error, Close } from '@edx/paragon/icons';
import FormFieldRenderer from '../field-renderer';
import {
clearUsernameSuggestions, registerNewUser, resetRegistrationForm, fetchRealtimeValidations,
} from './data/actions';
import {
FORM_SUBMISSION_ERROR, DEFAULT_SERVICE_PROVIDER_DOMAINS, DEFAULT_TOP_LEVEL_DOMAINS, COMMON_EMAIL_PROVIDERS,
FIELDS, FORM_SUBMISSION_ERROR, DEFAULT_SERVICE_PROVIDER_DOMAINS, DEFAULT_TOP_LEVEL_DOMAINS, COMMON_EMAIL_PROVIDERS,
} from './data/constants';
import {
registrationErrorSelector, registrationRequestSelector, validationsSelector, usernameSuggestionsSelector,
registrationErrorSelector,
registrationRequestSelector,
validationsSelector,
usernameSuggestionsSelector,
} from './data/selectors';
import messages from './messages';
import RegistrationFailure from './RegistrationFailure';
import UsernameField from './UsernameField';
import HonorCode from './HonorCode';
import {
RedirectLogistration, SocialAuthProviders, ThirdPartyAuthAlert, RenderInstitutionButton,
InstitutionLogistration, FormGroup, PasswordField,
} from '../common-components';
import { getThirdPartyAuthContext } from '../common-components/data/actions';
import { thirdPartyAuthContextSelector } from '../common-components/data/selectors';
import {
extendedProfileSelector,
fieldDescriptionSelector,
thirdPartyAuthContextSelector,
} from '../common-components/data/selectors';
import EnterpriseSSO from '../common-components/EnterpriseSSO';
import {
DEFAULT_STATE, PENDING_STATE, REGISTER_PAGE, VALID_EMAIL_REGEX, LETTER_REGEX, NUMBER_REGEX, VALID_NAME_REGEX,
@@ -43,6 +51,7 @@ import {
} from '../data/utils';
import CountryDropdown from './CountryDropdown';
import { getLevenshteinSuggestion, getSuggestionForInvalidEmail } from './utils';
import TermsOfService from './TermsOfService';
class RegistrationPage extends React.Component {
constructor(props, context) {
@@ -51,6 +60,9 @@ class RegistrationPage extends React.Component {
this.handleOnClose = this.handleOnClose.bind(this);
this.queryParams = getAllPossibleQueryParam();
// TODO: Once we have tested it and ready for openedX we can remove this flag and make the code
// permanent part of Authn and remove extra code
this.showDynamicRegistrationFields = getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS;
this.tpaHint = getTpaHint();
this.state = {
country: '',
@@ -75,6 +87,7 @@ class RegistrationPage extends React.Component {
optimizelyExperimentName: '',
readOnly: true,
validatePassword: false,
values: {},
};
}
@@ -171,16 +184,28 @@ class RegistrationPage extends React.Component {
}
};
onChangeHandler = (e) => {
const { name, value, checked } = e.target;
const { errors, values } = this.state;
if (e.target.type === 'checkbox') {
errors[name] = '';
values[name] = checked;
} else {
values[name] = value;
}
const state = { errors, values };
this.setState({ ...state });
};
handleSubmit = (e) => {
e.preventDefault();
const { startTime } = this.state;
const totalRegistrationTime = (Date.now() - startTime) / 1000;
const payload = {
const dynamicFieldErrorMessages = {};
let payload = {
name: this.state.name,
username: this.state.username,
email: this.state.email,
country: this.state.country,
honor_code: true,
is_authn_mfe: true,
};
@@ -190,7 +215,28 @@ class RegistrationPage extends React.Component {
payload.password = this.state.password;
}
if (!this.isFormValid(payload)) {
if (this.showDynamicRegistrationFields) {
payload.extendedProfile = [];
Object.keys(this.props.fieldDescriptions).forEach((fieldName) => {
if (this.props.extendedProfile.includes(fieldName)) {
payload.extendedProfile.push({ fieldName, fieldValue: this.state.values[fieldName] });
} else {
payload[fieldName] = this.state.values[fieldName];
}
dynamicFieldErrorMessages[fieldName] = this.props.fieldDescriptions[fieldName].error_message;
});
if (
this.props.fieldDescriptions[FIELDS.HONOR_CODE]
&& this.props.fieldDescriptions[FIELDS.HONOR_CODE].type === 'tos_and_honor_code'
) {
payload[FIELDS.HONOR_CODE] = true;
}
} else {
payload.country = this.state.country;
payload.honor_code = true;
}
if (!this.isFormValid(payload, dynamicFieldErrorMessages)) {
this.setState(prevState => ({
errorCode: FORM_SUBMISSION_ERROR,
failureCount: prevState.failureCount + 1,
@@ -202,6 +248,7 @@ class RegistrationPage extends React.Component {
payload.marketing_emails_opt_in = this.state.marketingOptIn;
}
payload = snakeCaseObject(payload);
payload.totalRegistrationTime = totalRegistrationTime;
this.setState({
totalRegistrationTime,
@@ -282,13 +329,22 @@ class RegistrationPage extends React.Component {
this.props.clearUsernameSuggestions();
}
isFormValid(payload) {
validateDynamicFields = (e) => {
const { errors } = this.state;
const { name, value } = e.target;
if (!value) {
errors[name] = this.props.fieldDescriptions[name].error_message;
}
this.setState({ errors });
}
isFormValid(payload, dynamicFieldError) {
const { errors } = this.state;
let isValid = true;
Object.keys(payload).forEach(key => {
if (!payload[key]) {
errors[key] = this.props.intl.formatMessage(messages[`empty.${key}.field.error`]);
errors[key] = (key in dynamicFieldError) ? dynamicFieldError[key] : this.props.intl.formatMessage(messages[`empty.${key}.field.error`]);
}
// Mark form invalid, if there was already a validation error for this key or we added empty field error
if (errors[key]) {
@@ -521,6 +577,73 @@ class RegistrationPage extends React.Component {
});
}
const honorCode = [];
const formFields = this.showDynamicRegistrationFields ? (
Object.keys(this.props.fieldDescriptions).map((fieldName) => {
const fieldData = this.props.fieldDescriptions[fieldName];
switch (fieldData.name) {
case FIELDS.COUNTRY:
return (
<span key={fieldData.name}>
<CountryDropdown
name="country"
floatingLabel={intl.formatMessage(messages['registration.country.label'])}
options={getCountryList(getLocale())}
valueKey="code"
displayValueKey="name"
value={this.state.values[fieldData.name]}
handleBlur={this.handleOnBlur}
handleFocus={this.handleOnFocus}
errorMessage={intl.formatMessage(messages['empty.country.field.error'])}
handleChange={
(value) => this.setState(prevState => ({ values: { ...prevState.values, country: value } }))
}
errorCode={this.state.errorCode}
readOnly={this.state.readOnly}
/>
</span>
);
case FIELDS.HONOR_CODE:
honorCode.push(
<span key={fieldData.name}>
<HonorCode
fieldType={fieldData.type}
value={this.state.values[fieldData.name]}
onChangeHandler={this.onChangeHandler}
errorMessage={this.state.errors[fieldData.name]}
/>
</span>,
);
return null;
case FIELDS.TERMS_OF_SERVICE:
honorCode.push(
<span key={fieldData.name}>
<TermsOfService
value={this.state.values[fieldData.name]}
onChangeHandler={this.onChangeHandler}
errorMessage={this.state.errors[fieldData.name]}
/>
</span>,
);
return null;
default:
return (
<span key={fieldData.name}>
<FormFieldRenderer
fieldData={fieldData}
value={this.state.values[fieldData.name]}
onChangeHandler={this.onChangeHandler}
handleBlur={this.validateDynamicFields}
handleFocus={this.handleOnFocus}
errorMessage={this.state.errors[fieldData.name]}
isRequired
/>
</span>
);
}
})
) : null;
return (
<>
<Helmet>
@@ -602,20 +725,24 @@ class RegistrationPage extends React.Component {
floatingLabel={intl.formatMessage(messages['registration.password.label'])}
/>
)}
<CountryDropdown
name="country"
floatingLabel={intl.formatMessage(messages['registration.country.label'])}
options={getCountryList(getLocale())}
valueKey="code"
displayValueKey="name"
value={this.state.country}
handleBlur={this.handleOnBlur}
handleFocus={this.handleOnFocus}
errorMessage={intl.formatMessage(messages['empty.country.field.error'])}
handleChange={(value) => this.setState({ country: value })}
errorCode={this.state.errorCode}
readOnly={this.state.readOnly}
/>
{!(this.showDynamicRegistrationFields)
&& (
<CountryDropdown
name="country"
floatingLabel={intl.formatMessage(messages['registration.country.label'])}
options={getCountryList(getLocale())}
valueKey="code"
displayValueKey="name"
value={this.state.country}
handleBlur={this.handleOnBlur}
handleFocus={this.handleOnFocus}
errorMessage={intl.formatMessage(messages['empty.country.field.error'])}
handleChange={(value) => this.setState({ country: value })}
errorCode={this.state.errorCode}
readOnly={this.state.readOnly}
/>
)}
{formFields}
{(getConfig().MARKETING_EMAILS_OPT_IN)
&& (
<Form.Checkbox
@@ -627,27 +754,11 @@ class RegistrationPage extends React.Component {
{intl.formatMessage(messages['registration.opt.in.label'], { siteName: getConfig().SITE_NAME })}
</Form.Checkbox>
)}
<div id="honor-code" className="micro text-muted mt-4">
<FormattedMessage
id="register.page.terms.of.service.and.honor.code"
defaultMessage="By creating an account, you agree to the {tosAndHonorCode} and you acknowledge that {platformName} and each
Member process your personal data in accordance with the {privacyPolicy}."
description="Text that appears on registration form stating honor code and privacy policy"
values={{
platformName: getConfig().SITE_NAME,
tosAndHonorCode: (
<Hyperlink variant="muted" destination={getConfig().TOS_AND_HONOR_CODE || '#'} target="_blank">
{intl.formatMessage(messages['terms.of.service.and.honor.code'])}
</Hyperlink>
),
privacyPolicy: (
<Hyperlink variant="muted" destination={getConfig().PRIVACY_POLICY || '#'} target="_blank">
{intl.formatMessage(messages['privacy.policy'])}
</Hyperlink>
),
}}
{!(this.showDynamicRegistrationFields) ? (
<HonorCode
fieldType="tos_and_honor_code"
/>
</div>
) : <div className="mt-4">{honorCode}</div>}
<StatefulButton
name="register-user"
id="register-user"
@@ -719,6 +830,8 @@ class RegistrationPage extends React.Component {
}
RegistrationPage.defaultProps = {
extendedProfile: [],
fieldDescriptions: {},
registrationResult: null,
registerNewUser: null,
registrationErrorCode: null,
@@ -738,6 +851,8 @@ RegistrationPage.defaultProps = {
};
RegistrationPage.propTypes = {
extendedProfile: PropTypes.arrayOf(PropTypes.string),
fieldDescriptions: PropTypes.shape({}),
intl: intlShape.isRequired,
getThirdPartyAuthContext: PropTypes.func.isRequired,
registerNewUser: PropTypes.func,
@@ -791,6 +906,8 @@ const mapStateToProps = state => {
validationDecisions: validationsSelector(state),
statusCode: state.register.statusCode,
usernameSuggestions: usernameSuggestionsSelector(state),
fieldDescriptions: fieldDescriptionSelector(state),
extendedProfile: extendedProfileSelector(state),
};
};

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
import { Hyperlink, Form } from '@edx/paragon';
import messages from './messages';
const TermsOfService = (props) => {
const {
intl, errorMessage, onChangeHandler, value,
} = props;
return (
<div id="terms-of-service" className="micro text-muted">
<Form.Checkbox
className="opt-checkbox mt-1"
id="tos"
checked={value}
name="terms_of_service"
value={value}
onChange={onChangeHandler}
>
<FormattedMessage
id="register.page.terms.of.service"
defaultMessage="I agree to the {platformName} {termsOfService}"
description="Text that appears on registration form stating terms of service.
It is a legal document that users must agree to."
values={{
platformName: getConfig().SITE_NAME,
termsOfService: (
<Hyperlink variant="muted" destination={getConfig().TOS_LINK || '#'} target="_blank">
{intl.formatMessage(messages['terms.of.service'])}
</Hyperlink>
),
}}
/>
</Form.Checkbox>
{errorMessage && (
<Form.Control.Feedback type="invalid" className="form-text-size" hasIcon={false}>
{errorMessage}
</Form.Control.Feedback>
)}
</div>
);
};
TermsOfService.defaultProps = {
errorMessage: '',
value: false,
};
TermsOfService.propTypes = {
intl: intlShape.isRequired,
errorMessage: PropTypes.string,
onChangeHandler: PropTypes.func.isRequired,
value: PropTypes.bool,
};
export default injectIntl(TermsOfService);

View File

@@ -1,3 +1,10 @@
// Registration Fields
export const FIELDS = {
COUNTRY: 'country',
HONOR_CODE: 'honor_code',
TERMS_OF_SERVICE: 'terms_of_service',
};
// Registration Error Codes
export const FORBIDDEN_REQUEST = 'forbidden-request';
export const FORM_SUBMISSION_ERROR = 'form-submission-error';

View File

@@ -1,8 +1,14 @@
import {
REGISTRATION_FORM, REGISTER_NEW_USER, REGISTER_FORM_VALIDATIONS, REGISTER_CLEAR_USERNAME_SUGGESTIONS,
REGISTRATION_FORM,
REGISTER_NEW_USER,
REGISTER_FORM_VALIDATIONS,
REGISTER_CLEAR_USERNAME_SUGGESTIONS,
} from './actions';
import { DEFAULT_STATE, PENDING_STATE } from '../../data/constants';
import {
DEFAULT_STATE,
PENDING_STATE,
} from '../../data/constants';
export const defaultState = {
registrationError: {},
@@ -11,6 +17,9 @@ export const defaultState = {
validations: null,
statusCode: null,
usernameSuggestions: [],
extendedProfile: [],
fieldDescriptions: {},
formRenderState: DEFAULT_STATE,
};
const reducer = (state = defaultState, action) => {

View File

@@ -55,7 +55,6 @@ export function* fetchRealtimeValidations(action) {
}
}
}
export default function* saga() {
yield takeEvery(REGISTER_NEW_USER.BASE, handleNewUserRegistration);
yield takeEvery(REGISTER_FORM_VALIDATIONS.BASE, fetchRealtimeValidations);

View File

@@ -14,6 +14,9 @@ describe('register reducer', () => {
validations: null,
statusCode: null,
usernameSuggestions: [],
extendedProfile: [],
fieldDescriptions: {},
formRenderState: DEFAULT_STATE,
},
);
});

View File

@@ -188,6 +188,16 @@ const messages = defineMessages({
defaultMessage: 'Privacy Policy',
description: 'Text for the hyperlink that redirects user to privacy policy',
},
'honor.code': {
id: 'honor.code',
defaultMessage: 'Honor Code',
description: 'Text for the hyperlink that redirects user to the honor code',
},
'terms.of.service': {
id: 'terms.of.service',
defaultMessage: 'Terms of Service',
description: 'Text for the hyperlink that redirects user to the terms of service',
},
// Optional fields
'registration.year.of.birth.label': {
id: 'registration.year.of.birth.label',

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { mount } from 'enzyme';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mergeConfig } from '@edx/frontend-platform';
import HonorCode from '../HonorCode';
const IntlHonorCode = injectIntl(HonorCode);
describe('HonorCodeTest', () => {
mergeConfig({
PRIVACY_POLICY: 'http://privacy-policy.com',
TOS_AND_HONOR_CODE: 'http://tos-and-honot-code.com',
});
let value = false;
const changeHandler = (e) => {
value = e.target.checked;
};
beforeEach(() => {
value = false;
});
it('should render error msg if honor code is not checked', () => {
const honorCode = mount(
<IntlProvider locale="en">
<IntlHonorCode errorMessage="You must agree to the edx Honor Code" onChangeHandler={changeHandler} />
</IntlProvider>,
);
expect(honorCode.find('.form-text-size').last().text()).toEqual('You must agree to the edx Honor Code');
});
it('should render Honor code field', () => {
const expectedMsg = 'I agree to the Your Platform Name Here Honor Codein a new tab';
const honorCode = mount(
<IntlProvider locale="en">
<IntlHonorCode onChangeHandler={changeHandler} />
</IntlProvider>,
);
honorCode.find('#honor-code').last().simulate('change', { target: { checked: true, type: 'checkbox' } });
expect(honorCode.find('#honor-code').find('label').text()).toEqual(expectedMsg);
expect(value).toEqual(true);
});
it('should render Terms of Service and Honor code field', () => {
const HonorCodeProps = mount(
<IntlProvider locale="en">
<IntlHonorCode fieldType="tos_and_honor_code" onChangeHandler={changeHandler} />
</IntlProvider>,
);
const expectedMsg = 'By creating an account, you agree to the Terms of Service and Honor Codein a new tab and you '
+ 'acknowledge that Your Platform Name Here and each Member process your personal data in '
+ 'accordance with the Privacy Policyin a new tab.';
const field = HonorCodeProps.find('#honor-code');
expect(field.text()).toEqual(expectedMsg);
});
});

View File

@@ -16,7 +16,9 @@ import {
registerNewUser,
resetRegistrationForm,
} from '../data/actions';
import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_SESSION_EXPIRED } from '../data/constants';
import {
FIELDS, FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_SESSION_EXPIRED,
} from '../data/constants';
import RegistrationFailureMessage from '../RegistrationFailure';
import RegistrationPage from '../RegistrationPage';
@@ -773,4 +775,87 @@ describe('RegistrationPage', () => {
});
});
});
describe('TestDynamicFields', () => {
it('should render fields returned by backend', () => {
mergeConfig({
ENABLE_DYNAMIC_REGISTRATION_FIELDS: true,
});
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
country: { name: 'country', error_message: true },
profession: { name: 'profession', type: 'text', label: 'Profession' },
honor_code: { name: FIELDS.HONOR_CODE, error_message: 'You must agree to Honor Code of our site' },
terms_of_service: {
name: FIELDS.TERMS_OF_SERVICE,
error_message: 'You must agree to the Terms and Service agreement of our site',
},
},
},
});
const registerPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
expect(registerPage.find('#country').exists()).toBeTruthy();
expect(registerPage.find('#profession').exists()).toBeTruthy();
expect(registerPage.find('#honor-code').exists()).toBeTruthy();
expect(registerPage.find('#tos').exists()).toBeTruthy();
});
it('should submit form with fields returned by backend in payload', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => 0);
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
country: { name: 'country', error_message: true },
profession: { name: 'profession', type: 'text', label: 'Profession' },
honor_code: { name: 'honor_code', type: 'tos_and_honor_code' },
},
extendedProfile: ['profession'],
},
});
const payload = {
name: 'John Doe',
username: 'john_doe',
email: 'john.doe@example.com',
password: 'password1',
country: 'Pakistan',
totalRegistrationTime: 0,
is_authn_mfe: true,
honor_code: true,
extended_profile: [{ field_name: 'profession', field_value: 'Engineer' }],
};
store.dispatch = jest.fn(store.dispatch);
const registerPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
populateRequiredFields(registerPage, payload);
registerPage.find('input#profession').simulate('change', { target: { value: 'Engineer', name: 'profession' } });
registerPage.find('button.btn-brand').simulate('click');
expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' }));
});
it('should show error message for fields returned by backend', () => {
store = mockStore({
...initialState,
commonComponents: {
...initialState.commonComponents,
fieldDescriptions: {
profession: {
name: 'profession', type: 'text', label: 'Profession', error_message: 'Enter profession',
},
},
},
});
const registrationPage = mount(reduxWrapper(<IntlRegistrationPage {...props} />));
registrationPage.find('button.btn-brand').simulate('click');
expect(registrationPage.find('#profession-error').last().text()).toEqual('Enter profession');
});
});
});

View File

@@ -0,0 +1,55 @@
import React from 'react';
import { mount } from 'enzyme';
import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n';
import { mergeConfig } from '@edx/frontend-platform';
import TermsOfService from '../TermsOfService';
const IntlTermsOfService = injectIntl(TermsOfService);
describe('TermsOfServiceTest', () => {
mergeConfig({
TOS_LINK: 'http://tos-and-honot-code.com',
});
let value = false;
const changeHandler = (e) => {
value = e.target.checked;
};
beforeEach(() => {
value = false;
});
it('should render error msg if Terms of Service checkbox is not checked', () => {
const errorMessage = 'You must agree to the edx Terms of Service';
const termsOfService = mount(
<IntlProvider locale="en">
<IntlTermsOfService errorMessage={errorMessage} onChangeHandler={changeHandler} />
</IntlProvider>,
);
expect(termsOfService.find('.form-text-size').last().text()).toEqual(errorMessage);
});
it('should render Terms of Service field', () => {
const termsOfService = mount(
<IntlProvider locale="en">
<IntlTermsOfService onChangeHandler={changeHandler} />
</IntlProvider>,
);
const expectedMsg = 'I agree to the Your Platform Name Here Terms of Servicein a new tab';
expect(termsOfService.find('#terms-of-service').find('label').text()).toEqual(expectedMsg);
expect(value).toEqual(false);
});
it('should change value when Terms of Service field is checked', () => {
const termsOfService = mount(
<IntlProvider locale="en">
<IntlTermsOfService onChangeHandler={changeHandler} />
</IntlProvider>,
);
const field = termsOfService.find('input#tos');
field.simulate('change', { target: { checked: true, type: 'checkbox' } });
expect(value).toEqual(true);
});
});