feat: account Information Country, Education, Gender, Spoken Lang (#8)
* feat: add country select. improve handling of select inputs * feat: add education field * feat: add gender field * fix: injectIntl shim should pass extra arguments * feat: add language proficiencies select Includes extra functionality for EditableField
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import {
|
||||
injectIntl,
|
||||
intlShape,
|
||||
getLocale,
|
||||
getCountryList,
|
||||
getLanguageList,
|
||||
} from '@edx/frontend-i18n'; // eslint-disable-line
|
||||
|
||||
import messages from './AccountSettingsPage.messages';
|
||||
|
||||
@@ -11,10 +17,30 @@ import { pageSelector } from './selectors';
|
||||
import { PageLoading } from '../common';
|
||||
import EditableField from './components/EditableField';
|
||||
import PasswordReset from './components/PasswordReset';
|
||||
import { yearOfBirthOptions, yearOfBirthDefault } from './constants';
|
||||
import {
|
||||
YEAR_OF_BIRTH_OPTIONS,
|
||||
EDUCATION_LEVELS,
|
||||
GENDER_OPTIONS,
|
||||
} from './constants';
|
||||
|
||||
|
||||
class AccountSettingsPage extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.countryOptions = getCountryList(getLocale())
|
||||
.map(({ code, name }) => ({ value: code, label: name }));
|
||||
this.languageProficiencyOptions = getLanguageList(getLocale())
|
||||
.map(({ code, name }) => ({ value: code, label: name }));
|
||||
this.educationLevels = EDUCATION_LEVELS.map(key => ({
|
||||
value: key,
|
||||
label: props.intl.formatMessage(messages[`account.settings.field.education.levels.${key}`]),
|
||||
}));
|
||||
this.genderOptions = GENDER_OPTIONS.map(key => ({
|
||||
value: key,
|
||||
label: props.intl.formatMessage(messages[`account.settings.field.gender.options.${key}`]),
|
||||
}));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchAccount();
|
||||
}
|
||||
@@ -48,10 +74,35 @@ class AccountSettingsPage extends React.Component {
|
||||
name="year_of_birth"
|
||||
type="select"
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
|
||||
options={yearOfBirthOptions}
|
||||
defaultValue={yearOfBirthDefault}
|
||||
options={YEAR_OF_BIRTH_OPTIONS}
|
||||
/>
|
||||
<PasswordReset />
|
||||
<EditableField
|
||||
name="country"
|
||||
type="select"
|
||||
options={this.countryOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="level_of_education"
|
||||
type="select"
|
||||
options={this.educationLevels}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.education'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="gender"
|
||||
type="select"
|
||||
options={this.genderOptions}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
|
||||
/>
|
||||
<EditableField
|
||||
name="language_proficiencies"
|
||||
type="select"
|
||||
options={this.languageProficiencyOptions}
|
||||
transformValue={v => (v.length ? v[0].code : null)}
|
||||
reverseTransform={v => ([{ code: v }])}
|
||||
label={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies'])}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,16 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Error: {error}',
|
||||
description: 'Message when data failed to load',
|
||||
},
|
||||
'account.settings.section.account.information': {
|
||||
id: 'account.settings.section.account.information',
|
||||
defaultMessage: 'Account Information',
|
||||
description: 'The basic account information section heading.',
|
||||
},
|
||||
'account.settings.section.account.information.description': {
|
||||
id: 'account.settings.section.account.information.description',
|
||||
defaultMessage: 'These settings include basic information about your account.',
|
||||
description: 'The basic account information section heading description.',
|
||||
},
|
||||
'account.settings.field.username': {
|
||||
id: 'account.settings.field.username',
|
||||
defaultMessage: 'Username',
|
||||
@@ -41,15 +51,98 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Year of birth',
|
||||
description: 'Label for account settings year of birth field.',
|
||||
},
|
||||
'account.settings.section.account.information': {
|
||||
id: 'account.settings.section.account.information',
|
||||
defaultMessage: 'Account Information',
|
||||
description: 'The basic account information section heading.',
|
||||
'account.settings.field.country': {
|
||||
id: 'account.settings.field.country',
|
||||
defaultMessage: 'Country',
|
||||
description: 'Label for account settings country field.',
|
||||
},
|
||||
'account.settings.section.account.information.description': {
|
||||
id: 'account.settings.section.account.information.description',
|
||||
defaultMessage: 'These settings include basic information about your account.',
|
||||
description: 'The basic account information section heading description.',
|
||||
|
||||
'account.settings.field.education': {
|
||||
id: 'account.settings.field.education',
|
||||
defaultMessage: 'Education',
|
||||
description: 'Label for account settings education field.',
|
||||
},
|
||||
'account.settings.field.education.levels.null': {
|
||||
id: 'account.settings.field.education.levels.null',
|
||||
defaultMessage: 'Select a level of education',
|
||||
description: 'Placeholder for the education levels dropdown.',
|
||||
},
|
||||
'account.settings.field.education.levels.p': {
|
||||
id: 'account.settings.field.education.levels.p',
|
||||
defaultMessage: 'Doctorate',
|
||||
description: 'Selected by the user if their highest level of education is a doctorate degree.',
|
||||
},
|
||||
'account.settings.field.education.levels.m': {
|
||||
id: 'account.settings.field.education.levels.m',
|
||||
defaultMessage: "Master's or professional degree",
|
||||
description: "Selected by the user if their highest level of education is a master's or professional degree from a college or university.",
|
||||
},
|
||||
'account.settings.field.education.levels.b': {
|
||||
id: 'account.settings.field.education.levels.b',
|
||||
defaultMessage: "Bachelor's Degree",
|
||||
description: "Selected by the user if their highest level of education is a four year college or university bachelor's degree.",
|
||||
},
|
||||
'account.settings.field.education.levels.a': {
|
||||
id: 'account.settings.field.education.levels.a',
|
||||
defaultMessage: "Associate's degree",
|
||||
description: "Selected by the user if their highest level of education is an associate's degree. 1-2 years of college or university.",
|
||||
},
|
||||
'account.settings.field.education.levels.hs': {
|
||||
id: 'account.settings.field.education.levels.hs',
|
||||
defaultMessage: 'Secondary/high school',
|
||||
description: 'Selected by the user if their highest level of education is secondary or high school. 9-12 years of education.',
|
||||
},
|
||||
'account.settings.field.education.levels.jhs': {
|
||||
id: 'account.settings.field.education.levels.jhs',
|
||||
defaultMessage: 'Junior secondary/junior high/middle school',
|
||||
description: 'Selected by the user if their highest level of education is junior or middle school. 6-8 years of education.',
|
||||
},
|
||||
'account.settings.field.education.levels.el': {
|
||||
id: 'account.settings.field.education.levels.el',
|
||||
defaultMessage: 'Elementary/primary school',
|
||||
description: 'Selected by the user if their highest level of education is elementary or primary school. 1-5 years of education.',
|
||||
},
|
||||
'account.settings.field.education.levels.none': {
|
||||
id: 'account.settings.field.education.levels.none',
|
||||
defaultMessage: 'No formal education',
|
||||
description: 'Selected by the user to describe their education.',
|
||||
},
|
||||
'account.settings.field.education.levels.o': {
|
||||
id: 'account.settings.field.education.levels.o',
|
||||
defaultMessage: 'Other education',
|
||||
description: 'Selected by the user if they have a type of education not described by the other choices.',
|
||||
},
|
||||
|
||||
'account.settings.field.gender': {
|
||||
id: 'account.settings.field.gender',
|
||||
defaultMessage: 'Gender',
|
||||
description: 'Label for account settings gender field.',
|
||||
},
|
||||
'account.settings.field.gender.options.null': {
|
||||
id: 'account.settings.field.gender.options.null',
|
||||
defaultMessage: 'Select a gender',
|
||||
description: 'Placeholder for the gender options dropdown.',
|
||||
},
|
||||
'account.settings.field.gender.options.f': {
|
||||
id: 'account.settings.field.gender.options.f',
|
||||
defaultMessage: 'Female',
|
||||
description: 'The label for the female gender option.',
|
||||
},
|
||||
'account.settings.field.gender.options.m': {
|
||||
id: 'account.settings.field.gender.options.m',
|
||||
defaultMessage: 'Male',
|
||||
description: 'The label for the male gender option.',
|
||||
},
|
||||
'account.settings.field.gender.options.o': {
|
||||
id: 'account.settings.field.gender.options.o',
|
||||
defaultMessage: 'Other',
|
||||
description: 'The label for catch-all gender option.',
|
||||
},
|
||||
|
||||
'account.settings.field.language.proficiencies': {
|
||||
id: 'account.settings.field.language.proficiencies',
|
||||
defaultMessage: 'Spoken Languages',
|
||||
description: 'Label for account settings spoken languages field.',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import { Button } from '@edx/paragon';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-i18n'; // eslint-disable-line
|
||||
import { Button, StatefulButton } from '@edx/paragon';
|
||||
|
||||
import Input from './temp/Input';
|
||||
import ValidationFormGroup from './temp/ValidationFormGroup';
|
||||
@@ -23,7 +24,9 @@ function EditableField(props) {
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
value,
|
||||
value: propValue,
|
||||
options,
|
||||
saveState,
|
||||
error,
|
||||
confirmationMessageDefinition,
|
||||
confirmationValue,
|
||||
@@ -35,19 +38,36 @@ function EditableField(props) {
|
||||
isEditing,
|
||||
isEditable,
|
||||
intl,
|
||||
transformValue,
|
||||
reverseTransform,
|
||||
...others
|
||||
} = props;
|
||||
const id = `field-${name}`;
|
||||
const value = transformValue(propValue);
|
||||
|
||||
const getValue = (rawValue) => {
|
||||
if (options) {
|
||||
if (Array.isArray(rawValue)) {
|
||||
return rawValue.map(getValue).join(', ');
|
||||
}
|
||||
// Use == instead of === to prevent issues when HTML casts numbers as strings
|
||||
// eslint-disable-next-line eqeqeq
|
||||
const selectedOption = options.find(option => option.value == rawValue);
|
||||
if (selectedOption) return selectedOption.label;
|
||||
}
|
||||
return rawValue;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const data = {};
|
||||
new FormData(e.target).forEach((v, k) => { data[k] = v; });
|
||||
const data = {
|
||||
[name]: reverseTransform(new FormData(e.target).get(name)),
|
||||
};
|
||||
onSubmit(name, data);
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
onChange(name, e.target.value);
|
||||
onChange(name, reverseTransform(e.target.value));
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
@@ -60,7 +80,9 @@ function EditableField(props) {
|
||||
|
||||
const renderConfirmationMessage = () => {
|
||||
if (!confirmationMessageDefinition || !confirmationValue) return null;
|
||||
return intl.formatMessage(confirmationMessageDefinition, { value: confirmationValue });
|
||||
return intl.formatMessage(confirmationMessageDefinition, {
|
||||
value: transformValue(confirmationValue),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -82,17 +104,36 @@ function EditableField(props) {
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
{...others}
|
||||
/>
|
||||
</ValidationFormGroup>
|
||||
<p>
|
||||
<Button type="submit" className="btn-primary mr-2">
|
||||
<FormattedMessage
|
||||
id="account.settings.editable.field.action.save"
|
||||
defaultMessage="Save"
|
||||
description="The save button an editable field"
|
||||
/>
|
||||
</Button>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
className="btn-primary mr-2"
|
||||
state={saveState}
|
||||
labels={{
|
||||
default: (
|
||||
<FormattedMessage
|
||||
id="account.settings.editable.field.action.save"
|
||||
defaultMessage="Save"
|
||||
description="The save button on an editable field"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
// Swallow clicks if the state is pending.
|
||||
// We do this instead of disabling the button to prevent
|
||||
// it from losing focus (disabled elements cannot have focus).
|
||||
// Disabling it would causes upstream issues in focus management.
|
||||
// Swallowing the onSubmit event on the form would be better, but
|
||||
// we would have to add that logic for every field given our
|
||||
// current structure of the application.
|
||||
if (saveState === 'pending') e.preventDefault();
|
||||
}}
|
||||
disabledStates={[]}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
className="btn-outline-primary"
|
||||
@@ -120,7 +161,7 @@ function EditableField(props) {
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="m-0">{value}</p>
|
||||
<p className="m-0">{getValue(value)}</p>
|
||||
<p className="small text-muted">{renderConfirmationMessage() || helpText}</p>
|
||||
</div>
|
||||
),
|
||||
@@ -132,9 +173,14 @@ function EditableField(props) {
|
||||
|
||||
EditableField.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
type: PropTypes.string.isRequired,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
})),
|
||||
saveState: PropTypes.oneOf(['default', 'pending', 'complete', 'error']),
|
||||
error: PropTypes.string,
|
||||
confirmationMessageDefinition: PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
@@ -150,16 +196,23 @@ EditableField.propTypes = {
|
||||
isEditing: PropTypes.bool,
|
||||
isEditable: PropTypes.bool,
|
||||
intl: intlShape.isRequired,
|
||||
transformValue: PropTypes.func,
|
||||
reverseTransform: PropTypes.func,
|
||||
};
|
||||
|
||||
EditableField.defaultProps = {
|
||||
value: undefined,
|
||||
options: undefined,
|
||||
saveState: undefined,
|
||||
label: undefined,
|
||||
error: undefined,
|
||||
confirmationMessageDefinition: undefined,
|
||||
confirmationValue: undefined,
|
||||
helpText: undefined,
|
||||
isEditing: false,
|
||||
isEditable: true,
|
||||
transformValue: v => v,
|
||||
reverseTransform: v => v,
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -17,11 +17,15 @@ class Input extends React.Component {
|
||||
}
|
||||
|
||||
getClassNameForType() {
|
||||
const { type } = this.props;
|
||||
if (type === 'file') return 'form-control-file';
|
||||
if (type === 'checkbox') return 'form-check-input';
|
||||
if (type === 'radio') return 'form-check-input';
|
||||
return 'form-control';
|
||||
switch (this.props.type) {
|
||||
case 'file':
|
||||
return 'form-control-file';
|
||||
case 'checkbox':
|
||||
case 'radio':
|
||||
return 'form-check-input';
|
||||
default:
|
||||
return 'form-control';
|
||||
}
|
||||
}
|
||||
|
||||
getRef(forwardedRef) {
|
||||
@@ -78,15 +82,15 @@ class Input extends React.Component {
|
||||
|
||||
|
||||
Input.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
type: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
className: PropTypes.string,
|
||||
options: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
disabled: PropTypes.bool,
|
||||
group: PropTypes.arrayOf(PropTypes.shape({
|
||||
label: PropTypes.string,
|
||||
value: PropTypes.string,
|
||||
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
disabled: PropTypes.bool,
|
||||
})),
|
||||
})),
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
|
||||
export const yearOfBirthOptions = (() => {
|
||||
export const YEAR_OF_BIRTH_OPTIONS = (() => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const years = [];
|
||||
let startYear = currentYear - 120;
|
||||
while (startYear < currentYear) {
|
||||
startYear += 1;
|
||||
|
||||
years.push({ value: startYear, label: startYear });
|
||||
}
|
||||
return years.reverse();
|
||||
})();
|
||||
|
||||
export const yearOfBirthDefault = new Date().getFullYear() - 35;
|
||||
export const EDUCATION_LEVELS = [
|
||||
null,
|
||||
'p',
|
||||
'm',
|
||||
'b',
|
||||
'a',
|
||||
'hs',
|
||||
'jhs',
|
||||
'el',
|
||||
'none',
|
||||
'o',
|
||||
];
|
||||
|
||||
export const GENDER_OPTIONS = [
|
||||
null,
|
||||
'f',
|
||||
'm',
|
||||
'o',
|
||||
];
|
||||
|
||||
@@ -66,6 +66,7 @@ const accountSettingsReducer = (state = defaultState, action) => {
|
||||
return {
|
||||
...state,
|
||||
openFormId: null,
|
||||
saveState: null,
|
||||
errors: {},
|
||||
drafts: {},
|
||||
};
|
||||
@@ -77,6 +78,7 @@ const accountSettingsReducer = (state = defaultState, action) => {
|
||||
drafts: Object.assign({}, state.drafts, {
|
||||
[action.payload.name]: action.payload.value,
|
||||
}),
|
||||
saveState: null,
|
||||
errors: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export const formSelector = (state, props) => {
|
||||
|
||||
return {
|
||||
value,
|
||||
saveState: state[storeName].saveState,
|
||||
error: state[storeName].errors[props.name],
|
||||
confirmationValue: state[storeName].confirmationValues[props.name],
|
||||
isEditing: state[storeName].openFormId === props.name,
|
||||
|
||||
@@ -13,7 +13,7 @@ const injectIntlWithShim = (WrappedComponent) => {
|
||||
super(props);
|
||||
this.shimmedIntl = Object.create(this.props.intl, {
|
||||
formatMessage: {
|
||||
value: (definition) => {
|
||||
value: (definition, ...args) => {
|
||||
if (definition === undefined || definition.id === undefined) {
|
||||
const error = new Error('i18n error: An undefined message was supplied to intl.formatMessage.');
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
@@ -23,7 +23,7 @@ const injectIntlWithShim = (WrappedComponent) => {
|
||||
LoggingService.logError(error);
|
||||
return ''; // Fail silent in production
|
||||
}
|
||||
return this.props.intl.formatMessage(definition);
|
||||
return this.props.intl.formatMessage(definition, ...args);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user