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:
Adam Butterworth
2019-04-26 13:38:50 -04:00
committed by GitHub
parent 8bec2721b1
commit 49488f9386
8 changed files with 263 additions and 40 deletions

View File

@@ -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>

View File

@@ -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.',
},
});

View File

@@ -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,
};

View File

@@ -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,
})),
})),

View File

@@ -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',
];

View File

@@ -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: {},
};

View File

@@ -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,

View File

@@ -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);
},
},
});