Client/server data adapter and simpler data model. (#53)

* Client/server data adapter and simpler data passthroughs.

* Parse error response and pipe to UI

* Add top-of-form error display for social links

* Remove save failed state from save button

* Remove object deconstruction in catch

* Fixing a few small bugs.

* When opening and closing forms, remove drafts.

* Tweak where we send account_privacy back to

* Passing course cert visibility through.

* Fixin’ up the tests.

* Documenting weird social links behavior.

* More comments.
This commit is contained in:
David Joy
2019-03-04 16:45:22 -05:00
committed by GitHub
parent af8fb0e859
commit 129e32f7b5
25 changed files with 815 additions and 588 deletions

View File

@@ -25,10 +25,6 @@ module.exports = Merge.smart(commonConfig, {
test: /\.(js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
path.resolve(__dirname, 'node_modules/camelcase-keys'),
path.resolve(__dirname, 'node_modules/camelcase'),
path.resolve(__dirname, 'node_modules/map-obj'),
path.resolve(__dirname, 'node_modules/quick-lru'),
],
loader: 'babel-loader',
options: {

View File

@@ -24,10 +24,6 @@ module.exports = Merge.smart(commonConfig, {
test: /\.(js|jsx)$/,
include: [
path.resolve(__dirname, '../src'),
path.resolve(__dirname, 'node_modules/camelcase-keys'),
path.resolve(__dirname, 'node_modules/camelcase'),
path.resolve(__dirname, 'node_modules/map-obj'),
path.resolve(__dirname, 'node_modules/quick-lru'),
],
loader: 'babel-loader',
},

40
package-lock.json generated
View File

@@ -2442,7 +2442,7 @@
"integrity": "sha512-APBpZvdQrC1MJWMzk33V7FR2RhBRtnH2QPLqZzS+qia7PixwgWNlnX7UfHjhx+YWkM53GdsZKs40EBkSwADuMA=="
},
"@edx/edx-bootstrap": {
"version": "git://github.com/edx/edx-bootstrap.git#e14bb7b678037a675e26c1b196f800e0f573f22e",
"version": "git://github.com/edx/edx-bootstrap.git#71fd3272d235eb133cccefc1ce63f35a7696bf28",
"from": "git://github.com/edx/edx-bootstrap.git#update-with-documentation-site",
"requires": {
"@fortawesome/fontawesome-svg-core": "^1.2.13",
@@ -5347,28 +5347,6 @@
"upper-case": "^1.1.1"
}
},
"camelcase": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz",
"integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA=="
},
"camelcase-keys": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-5.0.0.tgz",
"integrity": "sha512-RFdQsUUi4TS/xg4RoLwmpw9hwOp/M0OY+9g3Oa90sH+tueZ1Cd7vXl4fEaCbydiGp1xo+eUr+wq9jLjRPLjhtg==",
"requires": {
"camelcase": "^5.0.0",
"map-obj": "^3.0.0",
"quick-lru": "^1.0.0"
},
"dependencies": {
"map-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-3.0.0.tgz",
"integrity": "sha512-Ot+2wruG8WqTbJngDxz0Ifm03y2pO4iL+brq/l+yEkGjUza03BnMQqX2XT//Jls8MOOl2VTHviAoLX+/nq/HXw=="
}
}
},
"caniuse-api": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-1.6.1.tgz",
@@ -12760,19 +12738,13 @@
"lodash.camelcase": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY=",
"dev": true
"integrity": "sha1-soqmKIorn8ZRA1x3EfZathkDMaY="
},
"lodash.clonedeep": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
},
"lodash.defaultsdeep": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.defaultsdeep/-/lodash.defaultsdeep-4.6.0.tgz",
"integrity": "sha1-vsECT4WxvZbL6kBbI8FK1kQ6b4E="
},
"lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
@@ -12863,10 +12835,10 @@
"integrity": "sha1-0uPuv/DZ05rVD1y9G1KnvOa7YRs=",
"dev": true
},
"lodash.set": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/lodash.set/-/lodash.set-4.3.2.tgz",
"integrity": "sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM="
"lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha1-OdcUo1NXFHg3rv1ktdy7Fr7Nj40="
},
"lodash.sortby": {
"version": "4.7.0",

View File

@@ -38,7 +38,6 @@
"@fortawesome/react-fontawesome": "^0.1.4",
"babel-polyfill": "^6.26.0",
"bootstrap": "^4.2.1",
"camelcase-keys": "^5.0.0",
"classnames": "^2.2.5",
"connected-react-router": "^5.0.1",
"copy-webpack-plugin": "^4.6.0",
@@ -47,8 +46,8 @@
"history": "^4.7.2",
"i18n-iso-countries": "^3.7.8",
"iso-countries-languages": "^0.2.1",
"lodash.defaultsdeep": "^4.6.0",
"lodash.set": "^4.3.2",
"lodash.camelcase": "^4.3.0",
"lodash.snakecase": "^4.1.1",
"prop-types": "^15.5.10",
"query-string": "^5.1.1",
"react": "^16.8.3",
@@ -65,7 +64,6 @@
"redux-saga": "^1.0.1",
"redux-thunk": "^2.2.0",
"reselect": "^4.0.0",
"snakecase-keys": "^2.1.0",
"whatwg-fetch": "^2.0.3"
},
"devDependencies": {

View File

@@ -6,8 +6,7 @@ export const SAVE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'SAVE_PROFILE_P
export const DELETE_PROFILE_PHOTO = new AsyncActionType('PROFILE', 'DELETE_PROFILE_PHOTO');
export const OPEN_FORM = 'OPEN_FORM';
export const CLOSE_FORM = 'CLOSE_FORM';
export const UPDATE_ACCOUNT_DRAFT = 'UPDATE_ACCOUNT_DRAFT';
export const UPDATE_VISIBILITY_DRAFT = 'UPDATE_VISIBILITY_DRAFT';
export const UPDATE_DRAFT = 'UPDATE_DRAFT';
export const RESET_DRAFTS = 'RESET_DRAFTS';
// FETCH PROFILE ACTIONS
@@ -21,11 +20,11 @@ export const fetchProfileBegin = () => ({
type: FETCH_PROFILE.BEGIN,
});
export const fetchProfileSuccess = (account, preferences, certificates) => ({
export const fetchProfileSuccess = (account, preferences, courseCertificates) => ({
type: FETCH_PROFILE.SUCCESS,
account,
preferences,
certificates,
courseCertificates,
});
export const fetchProfileFailure = error => ({
@@ -62,9 +61,9 @@ export const saveProfileReset = () => ({
type: SAVE_PROFILE.RESET,
});
export const saveProfileFailure = error => ({
export const saveProfileFailure = errors => ({
type: SAVE_PROFILE.FAILURE,
payload: { error },
payload: { errors },
});
// SAVE PROFILE PHOTO ACTIONS
@@ -138,16 +137,8 @@ export const closeForm = formId => ({
// FORM STATE ACTIONS
export const updateAccountDraft = (name, value) => ({
type: UPDATE_ACCOUNT_DRAFT,
payload: {
name,
value,
},
});
export const updateVisibilityDraft = (name, value) => ({
type: UPDATE_VISIBILITY_DRAFT,
export const updateDraft = (name, value) => ({
type: UPDATE_DRAFT,
payload: {
name,
value,

View File

@@ -84,12 +84,12 @@ describe('SAVE profile actions', () => {
});
it('should create an action to signal user account save failure', () => {
const error = 'Test failure';
const errors = ['Test failure'];
const expectedAction = {
type: SAVE_PROFILE.FAILURE,
payload: { error },
payload: { errors },
};
expect(saveProfileFailure(error)).toEqual(expectedAction);
expect(saveProfileFailure(errors)).toEqual(expectedAction);
});
});

View File

@@ -1,6 +1,6 @@
import snakecaseKeys from 'snakecase-keys';
import apiClient from '../config/apiClient';
import { configuration } from '../config/environment';
import { snakeCaseObject } from '../services/utils';
const eventLogApiBaseUrl = `${configuration.LMS_BASE_URL}/event`;
@@ -14,7 +14,7 @@ function handleTrackEvents(eventName, properties) {
// Sends events to tracking log and downstream
// TODO: Determine consistent naming for eventName vs eventType and properties v eventData.
function logEvent(eventType, eventData) {
snakecaseKeys(eventData, { deep: true });
snakeCaseObject(eventData, { deep: true });
const serverData = {
event_type: eventType,
event: eventData,

View File

@@ -12,8 +12,7 @@ import {
deleteProfilePhoto,
openForm,
closeForm,
updateVisibilityDraft,
updateAccountDraft,
updateDraft,
} from '../actions/ProfileActions';
// Components
@@ -27,6 +26,7 @@ import Bio from './ProfilePage/Bio';
import Certificates from './ProfilePage/Certificates';
import AgeMessage from './ProfilePage/AgeMessage';
import DateJoined from './ProfilePage/DateJoined';
import { profilePageSelector } from '../selectors/ProfilePageSelector';
export class ProfilePage extends React.Component {
constructor(props) {
@@ -67,17 +67,30 @@ export class ProfilePage extends React.Component {
this.props.saveProfile(formId);
}
handleChange(formId, name, value) {
if (name === 'visibility') {
this.props.updateVisibilityDraft(formId, value);
} else {
this.props.updateAccountDraft(formId, value);
}
handleChange(name, value) {
this.props.updateDraft(name, value);
}
render() {
const {
profileImage, username, dateJoined, errors,
profileImage,
username,
dateJoined,
errors,
name,
visibilityName,
country,
visibilityCountry,
education,
visibilityEducation,
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
languageProficiencies,
visibilityLanguageProficiencies,
visibilityCourseCertificates,
bio,
visibilityBio,
} = this.props;
const commonFormProps = {
@@ -112,11 +125,37 @@ export class ProfilePage extends React.Component {
</Row>
<Row>
<Col xs={{ order: 2 }} md={{ size: 4, order: 1 }} lg={3} className="mt-md-4">
<Name formId="name" {...commonFormProps} />
<Country formId="country" {...commonFormProps} />
<PreferredLanguage formId="languageProficiencies" {...commonFormProps} />
<Education formId="education" {...commonFormProps} />
<SocialLinks formId="socialLinks" {...commonFormProps} />
<Name
name={name}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
<PreferredLanguage
languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
<Education
education={education}
visibilityEducation={visibilityEducation}
formId="education"
{...commonFormProps}
/>
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
</Col>
<Col
xs={{ order: 1 }}
@@ -125,8 +164,17 @@ export class ProfilePage extends React.Component {
className="mt-4 mt-md-n5"
>
{this.props.requiresParentalConsent ? <AgeMessage accountURL="#account" /> : null}
<Bio formId="bio" {...commonFormProps} />
<Certificates formId="certificates" {...commonFormProps} />
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
</Col>
</Row>
</Container>
@@ -136,30 +184,58 @@ export class ProfilePage extends React.Component {
}
ProfilePage.propTypes = {
// Page state helpers
currentlyEditingField: PropTypes.string,
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
isCurrentUserProfile: PropTypes.bool.isRequired,
errors: PropTypes.objectOf(PropTypes.string),
// Profile data
// Account data
username: PropTypes.string,
requiresParentalConsent: PropTypes.bool,
dateJoined: PropTypes.string,
profileImage: PropTypes.string,
accountPrivacy: PropTypes.string,
certificates: PropTypes.arrayOf(PropTypes.shape({
isCurrentUserProfile: PropTypes.bool.isRequired,
// Bio form data
bio: PropTypes.string,
visibilityBio: PropTypes.string.isRequired,
// Certificates form data
courseCertificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
visibilityCourseCertificates: PropTypes.string.isRequired,
// Profile data for form fields
// Country form data
country: PropTypes.string,
visibilityCountry: PropTypes.string.isRequired,
// Education form data
education: PropTypes.string,
visibilityEducation: PropTypes.string.isRequired,
// Language proficiency form data
languageProficiencies: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
})),
visibilityLanguageProficiencies: PropTypes.string.isRequired,
// Name form data
name: PropTypes.string,
visibilityName: PropTypes.string.isRequired,
// Social links form data
socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
bio: PropTypes.string,
visibility: PropTypes.objectOf(PropTypes.string),
draftSocialLinksByPlatform: PropTypes.objectOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
visibilitySocialLinks: PropTypes.string.isRequired,
// Other data we need
profileImage: PropTypes.string,
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
savePhotoState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
// Page state helpers
errors: PropTypes.objectOf(PropTypes.string),
// Actions
fetchProfile: PropTypes.func.isRequired,
@@ -168,8 +244,7 @@ ProfilePage.propTypes = {
deleteProfilePhoto: PropTypes.func.isRequired,
openForm: PropTypes.func.isRequired,
closeForm: PropTypes.func.isRequired,
updateVisibilityDraft: PropTypes.func.isRequired,
updateAccountDraft: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
// Router
match: PropTypes.shape({
@@ -177,57 +252,28 @@ ProfilePage.propTypes = {
username: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
yearOfBirth: PropTypes.number,
requiresParentalConsent: PropTypes.bool,
};
ProfilePage.defaultProps = {
currentlyEditingField: null,
saveState: null,
savePhotoState: null,
errors: {},
profileImage: null,
name: null,
username: null,
education: null,
country: null,
socialLinks: [],
draftSocialLinksByPlatform: {},
bio: null,
certificates: null,
accountPrivacy: null,
visibility: {}, // eslint-disable-line
yearOfBirth: null,
languageProficiencies: [],
courseCertificates: null,
requiresParentalConsent: null,
dateJoined: null,
};
const mapStateToProps = (state) => {
const profileImage =
state.profilePage.account.profileImage != null
// TODO: This will change back to camelcase in the future
? state.profilePage.account.profileImage.image_url_large
: null;
return {
isCurrentUserProfile: state.userAccount.username === state.profilePage.account.username,
currentlyEditingField: state.profilePage.currentlyEditingField,
saveState: state.profilePage.saveState,
savePhotoState: state.profilePage.savePhotoState,
error: state.profilePage.error,
profileImage,
username: state.profilePage.account.username,
education: state.profilePage.account.levelOfEducation,
socialLinks: state.profilePage.account.socialLinks,
bio: state.profilePage.account.bio,
certificates: state.profilePage.account.certificates,
accountPrivacy: state.profilePage.preferences.accountPrivacy,
visibility: state.profilePage.preferences.visibility || {},
yearOfBirth: state.profilePage.account.yearOfBirth,
requiresParentalConsent: state.profilePage.account.requiresParentalConsent,
dateJoined: state.profilePage.account.dateJoined,
};
};
export default connect(
mapStateToProps,
profilePageSelector,
{
fetchProfile,
saveProfilePhoto,
@@ -235,7 +281,6 @@ export default connect(
saveProfile,
openForm,
closeForm,
updateVisibilityDraft,
updateAccountDraft,
updateDraft,
},
)(ProfilePage);

View File

@@ -27,7 +27,7 @@ class Bio extends React.Component {
handleChange(e) {
const { name, value } = e.target;
this.props.changeHandler(this.props.formId, name, value);
this.props.changeHandler(name, value);
}
handleSubmit(e) {
@@ -45,7 +45,7 @@ class Bio extends React.Component {
render() {
const {
formId, value, visibility, editMode, saveState, error, intl,
formId, bio, visibilityBio, editMode, saveState, error, intl,
} = this.props;
return (
@@ -61,16 +61,16 @@ class Bio extends React.Component {
type="textarea"
id={formId}
name={formId}
value={value}
value={bio || ''}
invalid={error != null}
onChange={this.handleChange}
/>
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
visibilityId="visibilityBio"
saveState={saveState}
visibility={visibility}
visibility={visibilityBio}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
@@ -82,10 +82,10 @@ class Bio extends React.Component {
content={intl.formatMessage(messages['profile.bio.about.me'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
showVisibility={visibilityBio !== null}
visibility={visibilityBio}
/>
<p className="lead">{value}</p>
<p className="lead">{bio}</p>
</React.Fragment>
),
empty: (
@@ -100,7 +100,7 @@ class Bio extends React.Component {
static: (
<React.Fragment>
<EditableItemHeader content={intl.formatMessage(messages['profile.bio.about.me'])} />
<p className="lead">{value}</p>
<p className="lead">{bio}</p>
</React.Fragment>
),
}}
@@ -117,8 +117,8 @@ Bio.propTypes = {
formId: PropTypes.string.isRequired,
// From Selector
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
bio: PropTypes.string,
visibilityBio: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
@@ -136,8 +136,8 @@ Bio.propTypes = {
Bio.defaultProps = {
editMode: 'static',
saveState: null,
value: null,
visibility: 'private',
bio: null,
visibilityBio: 'private',
error: null,
};

View File

@@ -26,7 +26,7 @@ class Certificates extends React.Component {
handleChange(e) {
const { name, value } = e.target;
this.props.changeHandler(this.props.formId, name, value);
this.props.changeHandler(name, value);
}
handleSubmit(e) {
@@ -87,7 +87,7 @@ class Certificates extends React.Component {
render() {
const {
formId, visibility, editMode, saveState, intl,
visibilityCourseCertificates, editMode, saveState, intl,
} = this.props;
return (
@@ -100,9 +100,9 @@ class Certificates extends React.Component {
<EditableItemHeader content={intl.formatMessage(messages['profile.certificates.my.certificates'])} />
{this.renderCertificates()}
<FormControls
formId={formId}
visibilityId="visibilityCourseCertificates"
saveState={saveState}
visibility={visibility}
visibility={visibilityCourseCertificates}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
@@ -114,8 +114,8 @@ class Certificates extends React.Component {
content={intl.formatMessage(messages['profile.certificates.my.certificates'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
showVisibility={visibilityCourseCertificates !== null}
visibility={visibilityCourseCertificates}
/>
{this.renderCertificates()}
</React.Fragment>
@@ -152,7 +152,7 @@ Certificates.propTypes = {
certificates: PropTypes.arrayOf(PropTypes.shape({
title: PropTypes.string,
})),
visibility: PropTypes.oneOf(['private', 'all_users']),
visibilityCourseCertificates: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
@@ -169,7 +169,7 @@ Certificates.propTypes = {
Certificates.defaultProps = {
editMode: 'static',
saveState: null,
visibility: 'private',
visibilityCourseCertificates: 'private',
certificates: null,
};

View File

@@ -31,7 +31,7 @@ class Country extends React.Component {
name,
value,
} = e.target;
this.props.changeHandler(this.props.formId, name, value);
this.props.changeHandler(name, value);
}
handleSubmit(e) {
@@ -49,7 +49,7 @@ class Country extends React.Component {
render() {
const {
formId, value, visibility, editMode, saveState, error,
formId, country, visibilityCountry, editMode, saveState, error,
} = this.props;
return (
@@ -65,7 +65,7 @@ class Country extends React.Component {
type="select"
name={formId}
className="w-100"
value={value}
value={country}
invalid={error != null}
onChange={this.handleChange}
>
@@ -76,9 +76,9 @@ class Country extends React.Component {
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
visibilityId="visibilityCountry"
saveState={saveState}
visibility={visibility}
visibility={visibilityCountry}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
@@ -90,10 +90,10 @@ class Country extends React.Component {
content="Location"
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
showVisibility={visibilityCountry !== null}
visibility={visibilityCountry}
/>
<h5>{ALL_COUNTRIES[value]}</h5>
<h5>{ALL_COUNTRIES[country]}</h5>
</React.Fragment>
),
empty: (
@@ -108,7 +108,7 @@ class Country extends React.Component {
static: (
<React.Fragment>
<EditableItemHeader content="Location" />
<h5>{ALL_COUNTRIES[value]}</h5>
<h5>{ALL_COUNTRIES[country]}</h5>
</React.Fragment>
),
}}
@@ -125,8 +125,8 @@ Country.propTypes = {
formId: PropTypes.string.isRequired,
// From Selector
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
country: PropTypes.string,
visibilityCountry: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
@@ -141,8 +141,8 @@ Country.propTypes = {
Country.defaultProps = {
editMode: 'static',
saveState: null,
value: null,
visibility: 'private',
country: null,
visibilityCountry: 'private',
error: null,
};

View File

@@ -33,7 +33,7 @@ class Education extends React.Component {
name,
value,
} = e.target;
this.props.changeHandler(this.props.formId, name, value);
this.props.changeHandler(name, value);
}
handleSubmit(e) {
@@ -51,7 +51,7 @@ class Education extends React.Component {
render() {
const {
formId, value, visibility, editMode, saveState, error, intl,
formId, education, visibilityEducation, editMode, saveState, error, intl,
} = this.props;
return (
@@ -69,7 +69,7 @@ class Education extends React.Component {
type="select"
name={formId}
className="w-100"
value={value}
value={education}
invalid={error != null}
onChange={this.handleChange}
>
@@ -80,9 +80,9 @@ class Education extends React.Component {
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
visibilityId="visibilityEducation"
saveState={saveState}
visibility={visibility}
visibility={visibilityEducation}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
@@ -94,10 +94,10 @@ class Education extends React.Component {
content={intl.formatMessage(messages['profile.education.education'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
showVisibility={visibilityEducation !== null}
visibility={visibilityEducation}
/>
<h5>{EDUCATION[value]}</h5>
<h5>{EDUCATION[education]}</h5>
</React.Fragment>
),
empty: (
@@ -112,7 +112,7 @@ class Education extends React.Component {
static: (
<React.Fragment>
<EditableItemHeader content={intl.formatMessage(messages['profile.education.education'])} />
<h5>{EDUCATION[value]}</h5>
<h5>{EDUCATION[education]}</h5>
</React.Fragment>
),
}}
@@ -129,8 +129,8 @@ Education.propTypes = {
formId: PropTypes.string.isRequired,
// From Selector
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
education: PropTypes.string,
visibilityEducation: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
@@ -148,8 +148,8 @@ Education.propTypes = {
Education.defaultProps = {
editMode: 'static',
saveState: null,
value: null,
visibility: 'private',
education: null,
visibilityEducation: 'private',
error: null,
};

View File

@@ -30,7 +30,7 @@ class Name extends React.Component {
name,
value,
} = e.target;
this.props.changeHandler(this.props.formId, name, value);
this.props.changeHandler(name, value);
}
handleSubmit(e) {
@@ -48,7 +48,7 @@ class Name extends React.Component {
render() {
const {
formId, value, visibility, editMode, saveState, error, intl,
formId, name, visibilityName, editMode, saveState, error, intl,
} = this.props;
return (
@@ -60,7 +60,7 @@ class Name extends React.Component {
<Form onSubmit={this.handleSubmit}>
<FormGroup>
<Label for="name">Full Name</Label>
<Input type="text" name={formId} value={value} invalid={error != null} onChange={this.handleChange} />
<Input type="text" name={formId} value={name} invalid={error != null} onChange={this.handleChange} />
<FormText>
<FormattedMessage
id="profile.name.details"
@@ -71,9 +71,9 @@ class Name extends React.Component {
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
visibilityId="visibilityName"
saveState={saveState}
visibility={visibility}
visibility={visibilityName}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
@@ -85,10 +85,10 @@ class Name extends React.Component {
content={intl.formatMessage(messages['profile.name.full.name'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
showVisibility={visibilityName !== null}
visibility={visibilityName}
/>
<h5>{value}</h5>
<h5>{name}</h5>
</React.Fragment>
),
empty: (
@@ -103,7 +103,7 @@ class Name extends React.Component {
static: (
<React.Fragment>
<EditableItemHeader content={intl.formatMessage(messages['profile.name.full.name'])} />
<h5>{value}</h5>
<h5>{name}</h5>
</React.Fragment>
),
}}
@@ -120,8 +120,8 @@ Name.propTypes = {
formId: PropTypes.string.isRequired,
// From Selector
value: PropTypes.string,
visibility: PropTypes.oneOf(['private', 'all_users']),
name: PropTypes.string,
visibilityName: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
@@ -139,8 +139,8 @@ Name.propTypes = {
Name.defaultProps = {
editMode: 'static',
saveState: null,
value: null,
visibility: 'private',
name: null,
visibilityName: 'private',
error: null,
};

View File

@@ -39,7 +39,7 @@ class PreferredLanguage extends React.Component {
value = [{ code: rawValue }];
}
this.props.changeHandler(this.props.formId, name, value);
this.props.changeHandler(name, value);
}
handleSubmit(e) {
@@ -57,10 +57,10 @@ class PreferredLanguage extends React.Component {
render() {
const {
formId, value: valueArr, visibility, editMode, saveState, error,
formId, languageProficiencies, visibilityLanguageProficiencies, editMode, saveState, error,
} = this.props;
const value = valueArr.length ? valueArr[0].code : '';
const value = languageProficiencies.length ? languageProficiencies[0].code : '';
return (
<SwitchContent
@@ -92,9 +92,9 @@ class PreferredLanguage extends React.Component {
<FormFeedback>{error}</FormFeedback>
</FormGroup>
<FormControls
formId={formId}
visibilityId="visibilityLanguageProficiencies"
saveState={saveState}
visibility={visibility}
visibility={visibilityLanguageProficiencies}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
@@ -112,8 +112,8 @@ class PreferredLanguage extends React.Component {
)}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
showVisibility={visibilityLanguageProficiencies !== null}
visibility={visibilityLanguageProficiencies}
/>
<h5>{ALL_LANGUAGES[value]}</h5>
</React.Fragment>
@@ -155,13 +155,13 @@ PreferredLanguage.propTypes = {
formId: PropTypes.string.isRequired,
// From Selector
value: PropTypes.oneOfType([
languageProficiencies: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.shape({ code: PropTypes.string })),
// TODO: ProfilePageSelector should supply null values
// instead of empty strings when no value exists
PropTypes.oneOf(['']),
]),
visibility: PropTypes.oneOf(['private', 'all_users']),
visibilityLanguageProficiencies: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
@@ -176,8 +176,8 @@ PreferredLanguage.propTypes = {
PreferredLanguage.defaultProps = {
editMode: 'static',
saveState: null,
value: null,
visibility: 'private',
languageProficiencies: [],
visibilityLanguageProficiencies: 'private',
error: null,
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, Input, FormFeedback } from 'reactstrap';
import { Form, Input, FormFeedback, Alert } from 'reactstrap';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
@@ -43,24 +43,40 @@ class SocialLinks extends React.Component {
}
handleChange(e) {
const {
name,
value,
} = e.target;
const { name, value } = e.target;
if (name !== 'visibility') {
const updatedList = this.props.committedValue.map((socialLink) => {
if (socialLink.platform === name) {
return { platform: name, social_link: value };
}
return socialLink;
});
this.props.changeHandler(this.props.formId, 'socialLinks', updatedList);
// The social links are a bit special. If we're updating them, we need to merge them
// with any existing social link drafts, essentially sending a fresh copy of the whole
// data structure back to the reducer. This helps the reducer stay simple and keeps
// special cases out of it, concentrating them here, where they began.
if (name !== 'visibilitySocialLinks') {
this.props.changeHandler(
'socialLinks',
this.mergeWithDrafts({
platform: name,
// If it's an empty string, send it as null.
// The empty string is just for the input. We want nulls.
socialLink: value,
}),
);
} else {
this.props.changeHandler(this.props.formId, name, value);
this.props.changeHandler(name, value);
}
}
mergeWithDrafts(newSocialLink) {
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const updated = [];
knownPlatforms.forEach((platform) => {
if (newSocialLink.platform === platform) {
updated.push(newSocialLink);
} else if (this.props.draftSocialLinksByPlatform[platform] !== undefined) {
updated.push(this.props.draftSocialLinksByPlatform[platform]);
}
});
return updated;
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
@@ -76,7 +92,7 @@ class SocialLinks extends React.Component {
render() {
const {
formId, value: values, visibility, editMode, saveState, error, intl,
socialLinks, visibilitySocialLinks, editMode, saveState, error, intl,
} = this.props;
return (
@@ -86,7 +102,7 @@ class SocialLinks extends React.Component {
cases={{
empty: (
<ul className="list-unstyled">
{values.map(({ platform }) => (
{socialLinks.map(({ platform }) => (
<EmptyListItem
key={platform}
onClick={this.handleOpen}
@@ -97,9 +113,11 @@ class SocialLinks extends React.Component {
),
static: (
<React.Fragment>
<EditableItemHeader content={intl.formatMessage(messages['profile.sociallinks.social.links'])} />
<EditableItemHeader
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
/>
<ul className="list-unstyled">
{values.map(({ platform, social_link: socialLink }) => (
{socialLinks.map(({ platform, socialLink }) => (
<StaticListItem
key={platform}
name={platformDisplayInfo[platform].name}
@@ -116,11 +134,11 @@ class SocialLinks extends React.Component {
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
showEditButton
onClickEdit={this.handleOpen}
showVisibility={visibility !== null}
visibility={visibility}
showVisibility={visibilitySocialLinks !== null}
visibility={visibilitySocialLinks}
/>
<ul className="list-unstyled">
{values.map(({ platform, social_link: socialLink }) => (
{socialLinks.map(({ platform, socialLink }) => (
<EditableListItem
key={platform}
platform={platform}
@@ -134,23 +152,27 @@ class SocialLinks extends React.Component {
),
editing: (
<Form onSubmit={this.handleSubmit}>
<EditableItemHeader content={intl.formatMessage(messages['profile.sociallinks.social.links'])} />
<EditableItemHeader
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
/>
{/* TODO: Replace this alert with per-field errors. Needs API update. */}
{error !== null ? <Alert color="danger">{error}</Alert> : null}
<ul className="list-unstyled">
{values.map(({ platform, social_link: socialLink }) => (
{socialLinks.map(({ platform, socialLink }) => (
<EditingListItem
key={platform}
name={platformDisplayInfo[platform].name}
platform={platform}
value={socialLink}
error={error !== null ? error[platform] : null}
/* TODO: Per-field errors: error={error !== null ? error[platform] : null} */
onChange={this.handleChange}
/>
))}
</ul>
<FormControls
formId={formId}
visibilityId="visibilitySocialLinks"
saveState={saveState}
visibility={visibility}
visibility={visibilitySocialLinks}
cancelHandler={this.handleClose}
changeHandler={this.handleChange}
/>
@@ -170,15 +192,15 @@ SocialLinks.propTypes = {
formId: PropTypes.string.isRequired,
// From Selector
value: PropTypes.arrayOf(PropTypes.shape({
socialLinks: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})).isRequired,
draftSocialLinksByPlatform: PropTypes.objectOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
committedValue: PropTypes.arrayOf(PropTypes.shape({
platform: PropTypes.string,
socialLink: PropTypes.string,
})),
visibility: PropTypes.oneOf(['private', 'all_users']),
visibilitySocialLinks: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']),
saveState: PropTypes.string,
error: PropTypes.string,
@@ -196,9 +218,8 @@ SocialLinks.propTypes = {
SocialLinks.defaultProps = {
editMode: 'static',
saveState: null,
value: [],
committedValue: [],
visibility: 'private',
draftSocialLinksByPlatform: {},
visibilitySocialLinks: 'private',
error: null,
};
@@ -222,16 +243,14 @@ SocialLink.propTypes = {
name: PropTypes.string.isRequired,
};
function EditableListItem({
url,
platform,
onClickEmptyContent,
name,
url, platform, onClickEmptyContent, name,
}) {
const linkDisplay = url ?
<SocialLink name={name} url={url} platform={platform} /> :
<EmptyContent onClick={onClickEmptyContent}>Add {name}</EmptyContent>;
const linkDisplay = url ? (
<SocialLink name={name} url={url} platform={platform} />
) : (
<EmptyContent onClick={onClickEmptyContent}>Add {name}</EmptyContent>
);
return <li className="form-group">{linkDisplay}</li>;
}
@@ -247,13 +266,8 @@ EditableListItem.defaultProps = {
onClickEmptyContent: null,
};
function EditingListItem({
platform,
name,
value,
onChange,
error,
platform, name, value, onChange, error,
}) {
return (
<li className="form-group">
@@ -261,7 +275,7 @@ function EditingListItem({
<Input
type="text"
name={platform}
value={value}
value={value || ''}
onChange={onChange}
invalid={error != null}
/>
@@ -283,7 +297,6 @@ EditingListItem.defaultProps = {
error: null,
};
function EmptyListItem({ onClick, name }) {
return (
<li className="mb-4">
@@ -292,7 +305,7 @@ function EmptyListItem({ onClick, name }) {
id="profile.sociallinks.add"
defaultMessage="Add {network}"
values={{
network: { name },
network: name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
@@ -306,7 +319,6 @@ EmptyListItem.propTypes = {
onClick: PropTypes.func.isRequired,
};
function StaticListItem({ name, url, platform }) {
return (
<li className="mb-2">
@@ -317,6 +329,10 @@ function StaticListItem({ name, url, platform }) {
StaticListItem.propTypes = {
name: PropTypes.string.isRequired,
url: PropTypes.string.isRequired,
url: PropTypes.string,
platform: PropTypes.string.isRequired,
};
StaticListItem.defaultProps = {
url: null,
};

View File

@@ -9,9 +9,11 @@ import AsyncActionButton from './AsyncActionButton';
import { VisibilitySelect } from './Visibility';
function FormControls({
formId, cancelHandler, changeHandler, visibility, saveState, intl,
cancelHandler, changeHandler, visibility, visibilityId, saveState, intl,
}) {
const visibilityId = `${formId}-visibility`;
// Eliminate error/failed state for save button
const buttonState = saveState === 'error' ? null : saveState;
return (
<React.Fragment>
<FormGroup className="mb-4">
@@ -22,7 +24,7 @@ function FormControls({
id={visibilityId}
className="w-auto"
type="select"
name="visibility"
name={visibilityId}
value={visibility}
onChange={changeHandler}
/>
@@ -30,12 +32,11 @@ function FormControls({
<FormGroup>
<AsyncActionButton
type="submit"
variant={saveState}
variant={buttonState}
labels={{
default: intl.formatMessage(messages['profile.formcontrols.button.save']),
pending: intl.formatMessage(messages['profile.formcontrols.button.saving']),
complete: intl.formatMessage(messages['profile.formcontrols.button.saved']),
error: intl.formatMessage(messages['profile.formcontrols.button.save.failed']),
}}
/>
<Button color="link" onClick={cancelHandler}>
@@ -49,9 +50,9 @@ function FormControls({
export default injectIntl(FormControls);
FormControls.propTypes = {
formId: PropTypes.string.isRequired,
saveState: PropTypes.oneOf([null, 'pending', 'complete', 'error']),
visibility: PropTypes.oneOf(['private', 'all_users']),
visibilityId: PropTypes.string.isRequired,
cancelHandler: PropTypes.func.isRequired,
changeHandler: PropTypes.func.isRequired,

View File

@@ -26,11 +26,6 @@ const messages = defineMessages({
defaultMessage: 'Saved',
description: 'A button label',
},
'profile.formcontrols.button.save.failed': {
id: 'profile.formcontrols.button.save.failed',
defaultMessage: 'Save Failed',
description: 'A button label',
},
});
export default messages;

View File

@@ -5,8 +5,7 @@ import {
CLOSE_FORM,
OPEN_FORM,
FETCH_PROFILE,
UPDATE_ACCOUNT_DRAFT,
UPDATE_VISIBILITY_DRAFT,
UPDATE_DRAFT,
RESET_DRAFTS,
} from '../actions/ProfileActions';
@@ -18,12 +17,9 @@ export const initialState = {
account: {
socialLinks: [],
},
preferences: {
visibility: {},
},
certificates: [],
accountDrafts: {},
visibilityDrafts: {},
preferences: {},
courseCertificates: [],
drafts: {},
};
const profilePage = (state = initialState, action) => {
@@ -33,7 +29,7 @@ const profilePage = (state = initialState, action) => {
...state,
account: action.account,
preferences: action.preferences,
certificates: action.certificates,
courseCertificates: action.courseCertificates,
};
case SAVE_PROFILE.BEGIN:
return {
@@ -49,13 +45,7 @@ const profilePage = (state = initialState, action) => {
// Account is always replaced completely.
account: action.payload.account !== null ? action.payload.account : state.account,
// Preferences changes get merged in.
preferences: Object.assign({}, state.preferences, {
visibility: Object.assign(
{},
state.preferences.visibility,
action.payload.preferences.visibility,
),
}),
preferences: Object.assign({}, state.preferences, action.payload.preferences),
};
case SAVE_PROFILE.FAILURE:
return {
@@ -120,18 +110,10 @@ const profilePage = (state = initialState, action) => {
errors: {},
};
case UPDATE_ACCOUNT_DRAFT:
case UPDATE_DRAFT:
return {
...state,
accountDrafts: Object.assign({}, state.accountDrafts, {
[action.payload.name]: action.payload.value,
}),
};
case UPDATE_VISIBILITY_DRAFT:
return {
...state,
visibilityDrafts: Object.assign({}, state.visibilityDrafts, {
drafts: Object.assign({}, state.drafts, {
[action.payload.name]: action.payload.value,
}),
};
@@ -139,13 +121,13 @@ const profilePage = (state = initialState, action) => {
case RESET_DRAFTS:
return {
...state,
accountDrafts: {},
visibilityDrafts: {},
drafts: {},
};
case OPEN_FORM:
return {
...state,
currentlyEditingField: action.payload.formId,
drafts: {},
};
case CLOSE_FORM:
// Only close if the field to close is undefined or matches the field that is currently open
@@ -153,6 +135,7 @@ const profilePage = (state = initialState, action) => {
return {
...state,
currentlyEditingField: null,
drafts: {},
};
}
return state;

View File

@@ -33,6 +33,9 @@ import { handleSaveProfileSelector } from '../selectors/ProfilePageSelector';
// Services
import * as ProfileApiService from '../services/ProfileApiService';
// Utils
import { keepKeys } from '../services/utils';
export function* handleFetchProfile(action) {
const { username } = action.payload;
const currentUsername = yield select(state => state.authentication.username); // eslint-disable-line
@@ -52,11 +55,11 @@ export function* handleFetchProfile(action) {
const result = yield all(calls);
if (result.length > 2) {
const [account, certificates, preferences] = result;
yield put(fetchProfileSuccess(account, preferences, certificates));
const [account, courseCertificates, preferences] = result;
yield put(fetchProfileSuccess(account, preferences, courseCertificates));
} else {
const [account, certificates] = result;
yield put(fetchProfileSuccess(account, { visibility: {} }, certificates));
const [account, courseCertificates] = result;
yield put(fetchProfileSuccess(account, {}, courseCertificates));
}
yield put(fetchProfileReset());
@@ -66,35 +69,62 @@ export function* handleFetchProfile(action) {
}
export function* handleSaveProfile(action) {
const { username, accountDrafts, visibilityDrafts } = yield select(handleSaveProfileSelector);
try {
const { username, drafts, preferences } = yield select(handleSaveProfileSelector);
const accountDrafts = keepKeys(drafts, [
'bio',
'courseCertificates',
'country',
'education',
'languageProficiencies',
'name',
'socialLinks',
]);
const preferencesDrafts = keepKeys(drafts, [
'visibilityBio',
'visibilityCourseCertificates',
'visibilityCountry',
'visibilityEducation',
'visibilityLanguageProficiencies',
'visibilityName',
'visibilitySocialLinks',
]);
if (Object.keys(preferencesDrafts).length > 0) {
preferencesDrafts.accountPrivacy = 'custom';
}
yield put(saveProfileBegin());
let accountResult = null;
// Build the visibility drafts into a structure the API expects.
const preferences = {
visibility: visibilityDrafts,
};
if (Object.keys(accountDrafts).length > 0) {
accountResult = yield call(ProfileApiService.patchProfile, username, accountDrafts);
}
if (Object.keys(visibilityDrafts).length > 0) {
yield call(ProfileApiService.patchPreferences, username, preferences);
let preferencesResult = preferences; // assume it hasn't changed.
if (Object.keys(preferencesDrafts).length > 0) {
yield call(ProfileApiService.patchPreferences, username, preferencesDrafts);
// TODO: Temporary deoptimization since the patchPreferences call doesn't return anything.
// Remove this second call once we can get a result from the one above.
preferencesResult = yield call(ProfileApiService.getPreferences, username);
}
// The account result is returned from the server.
// The preferences draft is valid if the server didn't complain, so
// pass it through directly.
yield put(saveProfileSuccess(accountResult, preferences));
yield put(saveProfileSuccess(accountResult, preferencesResult));
yield delay(300);
yield put(closeForm(action.payload.formId));
yield delay(300);
yield put(saveProfileReset());
yield put(resetDrafts());
} catch (e) {
yield put(saveProfileFailure(e.message));
// TODO: If this is any other kind of exception than a known validation error from the server,
// this code will fail gracelessly when it can't find fieldErrors on the error.
yield put(saveProfileFailure(e.fieldErrors));
}
}

View File

@@ -27,10 +27,14 @@ describe('RootSaga', () => {
it('should pass actions to the correct sagas', () => {
const gen = rootSaga();
expect(gen.next().value).toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile)); // eslint-disable-line
expect(gen.next().value).toEqual(takeEvery(profileActions.SAVE_PROFILE.BASE, handleSaveProfile)); // eslint-disable-line
expect(gen.next().value).toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto)); // eslint-disable-line
expect(gen.next().value).toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto)); // eslint-disable-line
expect(gen.next().value)
.toEqual(takeEvery(profileActions.FETCH_PROFILE.BASE, handleFetchProfile));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.SAVE_PROFILE.BASE, handleSaveProfile));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto));
expect(gen.next().value).toBeUndefined();
});
@@ -39,12 +43,12 @@ describe('RootSaga', () => {
describe('handleSaveProfile', () => {
const selectorData = {
username: 'my username',
accountDrafts: {
drafts: {
name: 'Full Name',
},
visibilityDrafts: {},
preferences: {},
};
beforeEach(() => {});
it('should successfully process a saveProfile request if there are no exceptions', () => {
const action = profileActions.saveProfile('ze form id');
const gen = handleSaveProfile(action);
@@ -59,9 +63,7 @@ describe('RootSaga', () => {
}));
// The library would supply the result of the above call
// as the parameter to the NEXT yield. Here:
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {
visibility: {},
})));
expect(gen.next(profile).value).toEqual(put(profileActions.saveProfileSuccess(profile, {})));
expect(gen.next().value).toEqual(delay(300));
expect(gen.next().value).toEqual(put(profileActions.closeForm('ze form id')));
expect(gen.next().value).toEqual(delay(300));
@@ -72,6 +74,9 @@ describe('RootSaga', () => {
it('should successfully publish a failure action on exception', () => {
const error = new Error('uhoh');
error.fieldErrors = {
uhoh: 'not good',
};
const action = profileActions.saveProfile(
'my username',
{
@@ -85,7 +90,7 @@ describe('RootSaga', () => {
expect(gen.next().value).toEqual(select(handleSaveProfileSelector));
expect(gen.next(selectorData).value).toEqual(put(profileActions.saveProfileBegin()));
const result = gen.throw(error);
expect(result.value).toEqual(put(profileActions.saveProfileFailure('uhoh')));
expect(result.value).toEqual(put(profileActions.saveProfileFailure({ uhoh: 'not good' })));
expect(gen.next().value).toBeUndefined();
});
});

View File

@@ -3,11 +3,14 @@ import { createSelector } from 'reselect';
export const formIdSelector = (state, props) => props.formId;
export const authenticationUsernameSelector = state => state.authentication.username;
export const profileAccountSelector = state => state.profilePage.account;
export const profileCertificatesSelector = state => state.profilePage.certificates;
export const profileDraftsSelector = state => state.profilePage.drafts;
export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy;
export const profilePreferencesSelector = state => state.profilePage.preferences;
export const profileCourseCertificatesSelector = state => state.profilePage.courseCertificates;
export const profileAccountDraftsSelector = state => state.profilePage.accountDrafts;
export const profileVisibilityDraftsSelector = state => state.profilePage.visibilityDrafts;
export const profileVisibilitySelector = state => state.profilePage.preferences.visibility;
export const saveStateSelector = state => state.profilePage.saveState;
export const savePhotoStateSelector = state => state.profilePage.savePhotoState;
export const currentlyEditingFieldSelector = state => state.profilePage.currentlyEditingField;
export const accountErrorsSelector = state => state.profilePage.errors;
@@ -19,7 +22,7 @@ export const isCurrentUserProfileSelector = createSelector(
export const editableFormModeSelector = createSelector(
profileAccountSelector,
profileCertificatesSelector,
profileCourseCertificatesSelector,
formIdSelector,
isCurrentUserProfileSelector,
currentlyEditingFieldSelector,
@@ -51,8 +54,8 @@ export const editableFormModeSelector = createSelector(
export const accountDraftsFieldSelector = createSelector(
formIdSelector,
profileAccountDraftsSelector,
(formId, accountDrafts) => accountDrafts[formId],
profileDraftsSelector,
(formId, drafts) => drafts[formId],
);
export const visibilityDraftsFieldSelector = createSelector(
@@ -69,26 +72,9 @@ export const formErrorSelector = createSelector(
export const editableFormSelector = createSelector(
editableFormModeSelector,
profileAccountSelector,
profileVisibilitySelector,
formIdSelector,
formErrorSelector,
saveStateSelector,
accountDraftsFieldSelector,
visibilityDraftsFieldSelector,
(
editMode,
account,
visibility,
formId,
error,
saveState,
accountDraftsField,
visibilityDraftsField,
) => ({
value: accountDraftsField || account[formId] || '',
committedValue: account[formId] || '',
visibility: visibilityDraftsField || visibility[formId] || 'private',
(editMode, error, saveState) => ({
editMode,
error,
saveState,
@@ -97,7 +83,7 @@ export const editableFormSelector = createSelector(
export const certificatesSelector = createSelector(
editableFormSelector,
profileCertificatesSelector,
profileCourseCertificatesSelector,
(editableForm, certificates) => ({
...editableForm,
certificates,
@@ -105,16 +91,223 @@ export const certificatesSelector = createSelector(
}),
);
export const profileImageSelector = createSelector(
profileAccountSelector,
account => (account.profileImage != null ? account.profileImage.imageUrlLarge : null),
);
/**
* This is used by a saga to pull out data to process.
*/
export const handleSaveProfileSelector = createSelector(
authenticationUsernameSelector,
profileAccountDraftsSelector,
profileVisibilityDraftsSelector,
(username, accountDrafts, visibilityDrafts) => ({
profileDraftsSelector,
profilePreferencesSelector,
(username, drafts, preferences) => ({
username,
accountDrafts,
visibilityDrafts,
drafts,
preferences,
}),
);
// Reformats the social links in a platform-keyed hash.
const socialLinksByPlatformSelector = createSelector(
profileAccountSelector,
(account) => {
const linksByPlatform = {};
if (account.socialLinks !== undefined) {
account.socialLinks.forEach((socialLink) => {
linksByPlatform[socialLink.platform] = socialLink;
});
}
return linksByPlatform;
},
);
const draftSocialLinksByPlatformSelector = createSelector(
profileDraftsSelector,
(drafts) => {
const linksByPlatform = {};
if (drafts.socialLinks !== undefined) {
drafts.socialLinks.forEach((socialLink) => {
linksByPlatform[socialLink.platform] = socialLink;
});
}
return linksByPlatform;
},
);
// Fleshes out our list of existing social links with all the other ones the user can set.
export const formSocialLinksSelector = createSelector(
socialLinksByPlatformSelector,
draftSocialLinksByPlatformSelector,
(linksByPlatform, draftLinksByPlatform) => {
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const socialLinks = [];
// For each known platform
knownPlatforms.forEach((platform) => {
// If the link is in our drafts.
if (draftLinksByPlatform[platform] !== undefined) {
// Use the draft one.
socialLinks.push(draftLinksByPlatform[platform]);
} else if (linksByPlatform[platform] !== undefined) {
// Otherwise use the real one.
socialLinks.push(linksByPlatform[platform]);
} else {
// And if it's not in either, use a stub.
socialLinks.push({
platform,
socialLink: null,
});
}
});
return socialLinks;
},
);
export const visibilitiesSelector = createSelector(
profilePreferencesSelector,
accountPrivacySelector,
(preferences, accountPrivacy) => {
switch (accountPrivacy) {
case 'all_users':
return {
visibilityBio: 'all_users',
visibilityCourseCertificates: 'all_users',
visibilityCountry: 'all_users',
visibilityEducation: 'all_users',
visibilityLanguageProficiencies: 'all_users',
visibilityName: 'all_users',
visibilitySocialLinks: 'all_users',
};
case 'custom':
return {
visibilityBio: preferences.visibilityBio || 'private',
visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'private',
visibilityCountry: preferences.visibilityCountry || 'private',
visibilityEducation: preferences.visibilityEducation || 'private',
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'private',
visibilityName: preferences.visibilityName || 'private',
visibilitySocialLinks: preferences.visibilitySocialLinks || 'private',
};
case 'private':
default:
// If there is some other value for accountPrivacy set, we're going to assume
// it's private, since that's safest.
return {
visibilityBio: 'private',
visibilityCourseCertificates: 'private',
visibilityCountry: 'private',
visibilityEducation: 'private',
visibilityLanguageProficiencies: 'private',
visibilityName: 'private',
visibilitySocialLinks: 'private',
};
}
},
);
/**
* If there's no draft present at all (undefined), use the original committed value.
*/
function chooseFormValue(draft, committed) {
return draft !== undefined ? draft : committed;
}
export const formValuesSelector = createSelector(
profileAccountSelector,
visibilitiesSelector,
profileDraftsSelector,
profileCourseCertificatesSelector,
formSocialLinksSelector,
(account, visibilities, drafts, courseCertificates, socialLinks) => ({
bio: chooseFormValue(drafts.bio, account.bio),
visibilityBio: chooseFormValue(drafts.visibilityBio, visibilities.visibilityBio),
courseCertificates,
visibilityCourseCertificates: chooseFormValue(
drafts.visibilityCourseCertificates,
visibilities.visibilityCourseCertificates,
),
country: chooseFormValue(drafts.country, account.country),
visibilityCountry: chooseFormValue(drafts.visibilityCountry, visibilities.visibilityCountry),
education: chooseFormValue(drafts.education, account.education),
visibilityEducation: chooseFormValue(
drafts.visibilityEducation,
visibilities.visibilityEducation,
),
languageProficiencies: chooseFormValue(
drafts.languageProficiencies,
account.languageProficiencies,
),
visibilityLanguageProficiencies: chooseFormValue(
drafts.visibilityLanguageProficiencies,
visibilities.visibilityLanguageProficiencies,
),
name: chooseFormValue(drafts.name, account.name),
visibilityName: chooseFormValue(drafts.visibilityName, visibilities.visibilityName),
socialLinks, // Social links is calculated in its own selector, since it's complicated.
visibilitySocialLinks: chooseFormValue(
drafts.visibilitySocialLinks,
visibilities.visibilitySocialLinks,
),
}),
);
export const profilePageSelector = createSelector(
profileAccountSelector,
formValuesSelector,
profileImageSelector,
saveStateSelector,
savePhotoStateSelector,
isCurrentUserProfileSelector,
draftSocialLinksByPlatformSelector,
(
account,
formValues,
profileImage,
saveState,
savePhotoState,
isCurrentUserProfile,
draftSocialLinksByPlatform,
) => ({
// Account data we need
username: account.username,
profileImage,
requiresParentalConsent: account.requiresParentalConsent,
dateJoined: account.dateJoined,
// Bio form data
bio: formValues.bio,
visibilityBio: formValues.visibilityBio,
// Certificates form data
courseCertificates: formValues.courseCertificates,
visibilityCourseCertificates: formValues.visibilityCourseCertificates,
// Country form data
country: formValues.country,
visibilityCountry: formValues.visibilityCountry,
// Education form data
education: formValues.education,
visibilityEducation: formValues.visibilityEducation,
// Language proficiency form data
languageProficiencies: formValues.languageProficiencies,
visibilityLanguageProficiencies: formValues.visibilityLanguageProficiencies,
// Name form data
name: formValues.name,
visibilityName: formValues.visibilityName,
// Social links form data
socialLinks: formValues.socialLinks,
visibilitySocialLinks: formValues.visibilitySocialLinks,
draftSocialLinksByPlatform,
// Other data we need
saveState,
savePhotoState,
isCurrentUserProfile,
}),
);

View File

@@ -1,38 +1,121 @@
import camelcaseKeys from 'camelcase-keys';
import apiClient from '../config/apiClient';
import CERTIFICATE_TYPES from '../constants/certificates';
import { configuration } from '../config/environment';
import { unflattenAndTransformKeys, flattenAndTransformKeys } from './utils';
import {
camelCaseObject,
convertKeyNames,
snakeCaseObject,
} from './utils';
const clientToServerKeyMap = {
socialLinks: 'social_links',
education: 'level_of_education',
profileImage: 'profile_image',
dateJoined: 'date_joined',
languageProficiencies: 'language_proficiencies',
accountPrivacy: 'account_privacy',
yearOfBirth: 'year_of_birth',
requiresParentalConsent: 'requires_parental_consent',
};
const serverToClientKeyMap = Object.entries(clientToServerKeyMap).reduce((acc, [key, value]) => {
acc[value] = key;
return acc;
}, {});
export function mapServerKey(key) {
return serverToClientKeyMap[key] || key;
function processAccountData(data) {
const result = camelCaseObject(data);
return convertKeyNames(result, {
levelOfEducation: 'education',
});
}
export function mapClientKey(key) {
return clientToServerKeyMap[key] || key;
// GET ACCOUNT
export async function getAccount(username) {
const { data } = await apiClient.get(`${configuration.ACCOUNTS_API_BASE_URL}/${username}`);
// Process response data
return processAccountData(data);
}
// PATCH PROFILE
export async function patchProfile(username, params) {
let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, {
education: 'level_of_education',
});
const { data } = await apiClient.patch(
`${configuration.ACCOUNTS_API_BASE_URL}/${username}`,
processedParams,
{
headers: {
'Content-Type': 'application/merge-patch+json',
},
},
).catch((error) => {
const processedError = Object.create(error);
const fieldErrors = Object.entries(processAccountData(error.response.data.field_errors))
.reduce((acc, [fieldKey, messages]) => {
acc[fieldKey] = messages.userMessage;
return acc;
}, {});
processedError.fieldErrors = fieldErrors;
throw processedError;
});
// Process response data
return processAccountData(data);
}
// POST PROFILE PHOTO
export async function postProfilePhoto(username, formData) {
const { data } = await apiClient.post(
`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
return camelCaseObject(data);
}
// DELETE PROFILE PHOTO
export async function deleteProfilePhoto(username) {
const { data } = await apiClient.delete(`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`);
return camelCaseObject(data);
}
// GET PREFERENCES
export async function getPreferences(username) {
const { data } = await apiClient.get(`${configuration.PREFERENCES_API_BASE_URL}/${username}`);
const result = camelCaseObject(data);
return convertKeyNames(result, {
visibilityLevelOfEducation: 'visibilityEducation',
});
}
// PATCH PREFERENCES
export async function patchPreferences(username, params) {
let processedParams = snakeCaseObject(params);
processedParams = convertKeyNames(processedParams, {
visibility_bio: 'visibility.bio',
visibility_course_certificates: 'visibility.course_certificates',
visibility_country: 'visibility.country',
visibility_date_joined: 'visibility.date_joined',
visibility_education: 'visibility.level_of_education',
visibility_language_proficiencies: 'visibility.language_proficiencies',
visibility_name: 'visibility.name',
visibility_social_links: 'visibility.social_links',
visibility_time_zone: 'visibility.time_zone',
});
await apiClient.patch(
`${configuration.PREFERENCES_API_BASE_URL}/${username}`,
processedParams,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
);
return params; // TODO: Once the server returns the updated preferences object, return that.
}
// GET COURSE CERTIFICATES
function transformCertificateData(data) {
const transformedData = [];
data.forEach((cert) => {
transformedData.push({
...camelcaseKeys(cert),
...camelCaseObject(cert),
certificateType: CERTIFICATE_TYPES[cert.certificate_type],
downloadUrl: `${configuration.LMS_BASE_URL}${cert.download_url}`,
});
@@ -40,130 +123,10 @@ function transformCertificateData(data) {
return transformedData;
}
export function getAccount(username) {
return new Promise((resolve, reject) => {
apiClient
.get(`${configuration.ACCOUNTS_API_BASE_URL}/${username}`)
.then((response) => {
resolve(unflattenAndTransformKeys(response.data, key => mapServerKey(key)));
})
.catch((error) => {
reject(error);
});
});
}
export const mapSaveProfileRequestData = (props) => {
const PROFILE_REQUEST_DATA_MAP = {
education: 'levelOfEducation',
socialLinks: socialLinks =>
Object.entries(socialLinks)
.filter(([platform, value]) => value !== null) // eslint-disable-line no-unused-vars
.reduce((acc, [platform, value]) => {
acc.push({ socialLink: value, platform });
return acc;
}, []),
};
const state = {};
Object.keys(props).forEach((prop) => {
const propModifier = PROFILE_REQUEST_DATA_MAP[prop] || prop;
if (typeof propModifier === 'function') {
state[prop] = propModifier(props[prop]);
} else {
state[propModifier] = props[prop];
}
});
return state;
};
export function patchProfile(username, data) {
return new Promise((resolve, reject) => {
apiClient
.patch(
`${configuration.ACCOUNTS_API_BASE_URL}/${username}`,
flattenAndTransformKeys(data, key => mapClientKey(key)),
{
headers: {
'Content-Type': 'application/merge-patch+json',
},
},
)
.then((response) => {
resolve(unflattenAndTransformKeys(response.data, key => mapServerKey(key)));
})
.catch((error) => {
reject(error);
});
});
}
export function postProfilePhoto(username, formData) {
return apiClient.post(`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
}
export function deleteProfilePhoto(username) {
return apiClient.delete(`${configuration.ACCOUNTS_API_BASE_URL}/${username}/image`);
}
export function getPreferences(username) {
const url = `${configuration.PREFERENCES_API_BASE_URL}/${username}`;
return new Promise((resolve, reject) => {
apiClient
.get(url)
.then(({ data }) => {
// Unflatten server response
// visibility.social_links: 'value' becomes { visibility: { socialLinks: 'value' }}
const preferences = unflattenAndTransformKeys(data, key => mapServerKey(key));
if (preferences.visibility === undefined) {
preferences.visibility = {};
}
resolve(preferences);
})
.catch((error) => {
reject(error);
});
});
}
export function patchPreferences(username, preferences) {
const url = `${configuration.PREFERENCES_API_BASE_URL}/${username}`;
// Flatten object for server
// { visibility: { socialLinks: 'value' }} becomes visibility.social_links: 'value'
const data = flattenAndTransformKeys(preferences, key => mapClientKey(key));
return new Promise((resolve, reject) => {
apiClient
.patch(url, data, { headers: { 'Content-Type': 'application/merge-patch+json' } })
.then(() => {
// eslint-disable-line no-unused-vars
// Server response is blank on success
// resolve(response.data);
resolve(preferences);
})
.catch((error) => {
reject(error);
});
});
}
export function getCourseCertificates(username) {
export async function getCourseCertificates(username) {
const url = `${configuration.CERTIFICATES_API_BASE_URL}/${username}/`;
const { data } = await apiClient.get(url);
return new Promise((resolve, reject) => {
apiClient
.get(url)
.then(({ data }) => {
resolve(transformCertificateData(data));
})
.catch((error) => {
reject(error);
});
});
return transformCertificateData(data);
}

View File

@@ -1,33 +0,0 @@
import { mapSaveProfileRequestData } from './ProfileApiService';
describe('mapSaveProfileRequestData', () => {
it('should modify props according to prop modifier strings and functions', () => {
const props = {
favoriteColor: 'red',
age: 30,
petName: 'Donkey',
name: 'Donkey McWafflebatter',
country: 'US',
education: 'BS',
socialLinks: {
twitter: null,
facebook: 'https://www.facebook.com',
},
};
const result = mapSaveProfileRequestData(props);
expect(result).toEqual({
favoriteColor: 'red',
age: 30,
petName: 'Donkey',
name: 'Donkey McWafflebatter',
country: 'US',
levelOfEducation: 'BS',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com',
},
],
});
});
});

View File

@@ -1,30 +1,48 @@
import set from 'lodash.set';
import camelCase from 'lodash.camelcase';
import snakeCase from 'lodash.snakecase';
export function unflattenAndTransformKeys(obj, transformer) {
const newObj = {};
export function modifyObjectKeys(object, modify) {
// If the passed in object is not an object, return it.
if (
object === undefined ||
object === null ||
(typeof object !== 'object' && !Array.isArray(object))
) {
return object;
}
Object.entries(obj).forEach(([key, value]) => {
set(newObj, key.split('.').map(transformer), value);
if (Array.isArray(object)) {
return object.map(value => modifyObjectKeys(value, modify));
}
// Otherwise, process all its keys.
const result = {};
Object.entries(object).forEach(([key, value]) => {
result[modify(key)] = modifyObjectKeys(value, modify);
});
return newObj;
return result;
}
export function flattenAndTransformKeys(srcObj, transformer = key => key) {
const flatten = (obj, prevKeys = []) => (Object.entries(obj).reduce((acc, [key, value]) => {
const tKey = transformer(key);
const keys = prevKeys.concat(tKey);
export function camelCaseObject(object) {
return modifyObjectKeys(object, camelCase);
}
if (Array.isArray(value)) {
acc[keys.join('.')] = value;
} else if (value && typeof value === 'object') {
Object.assign(acc, flatten(value, keys));
} else {
acc[keys.join('.')] = value;
export function snakeCaseObject(object) {
return modifyObjectKeys(object, snakeCase);
}
export function convertKeyNames(object, nameMap) {
const transformer = key => (nameMap[key] === undefined ? key : nameMap[key]);
return modifyObjectKeys(object, transformer);
}
export function keepKeys(data, whitelist) {
const result = {};
Object.keys(data).forEach((key) => {
if (whitelist.indexOf(key) > -1) {
result[key] = data[key];
}
return acc;
}, {}));
return flatten(srcObj);
});
return result;
}

View File

@@ -1,47 +1,105 @@
import { flattenAndTransformKeys, unflattenAndTransformKeys } from './utils';
import { modifyObjectKeys, camelCaseObject, snakeCaseObject, convertKeyNames, keepKeys } from './utils';
describe('modifyObjectKeys', () => {
it('should use the provided modify function to change all keys in and object and its children', () => {
function meowKeys(key) {
return `${key}Meow`;
}
describe('unflattenAndTransformKeys', () => {
it('should unflatten objects and transform keys', () => {
const sourceObject = {
country: 'US',
'visibility.sociallinks': 'private',
'visibility.education': 'private',
'visibility.bio': 'private',
};
const result = unflattenAndTransformKeys(sourceObject, key => key.toUpperCase());
const result = modifyObjectKeys(
{
one: undefined,
two: null,
three: '',
four: 0,
five: NaN,
six: [1, 2, { seven: 'woof' }],
eight: { nine: { ten: 'bark' }, eleven: true },
},
meowKeys,
);
expect(result).toEqual({
COUNTRY: 'US',
VISIBILITY: {
SOCIALLINKS: 'private',
EDUCATION: 'private',
BIO: 'private',
},
oneMeow: undefined,
twoMeow: null,
threeMeow: '',
fourMeow: 0,
fiveMeow: NaN,
sixMeow: [1, 2, { sevenMeow: 'woof' }],
eightMeow: { nineMeow: { tenMeow: 'bark' }, elevenMeow: true },
});
});
});
describe('flattenAndTransformKeys', () => {
it('should flatten objects and transform keys', () => {
const sourceObject = {
COUNTRY: 'US',
VISIBILITY: {
SOCIALLINKS: 'private',
EDUCATION: 'private',
BIO: 'private',
},
};
const result = flattenAndTransformKeys(sourceObject, key => key.toLowerCase());
describe('camelCaseObject', () => {
it('should make everything camelCase', () => {
const result = camelCaseObject({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
country: 'US',
'visibility.sociallinks': 'private',
'visibility.education': 'private',
'visibility.bio': 'private',
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
dotDotDot: 123,
});
});
});
describe('snakeCaseObject', () => {
it('should make everything snake_case', () => {
const result = snakeCaseObject({
whatNow: 'brown cow',
butWho: { saysYouPeople: 'okay then', butHow: { willWeEvenKnow: 'the song is over' } },
'dot.dot.dot': 123,
});
expect(result).toEqual({
what_now: 'brown cow',
but_who: { says_you_people: 'okay then', but_how: { will_we_even_know: 'the song is over' } },
dot_dot_dot: 123,
});
});
});
describe('convertKeyNames', () => {
it('should replace the specified keynames', () => {
const result = convertKeyNames(
{
one: { two: { three: 'four' } },
five: 'six',
},
{
two: 'blue',
five: 'alive',
seven: 'heaven',
},
);
expect(result).toEqual({
one: { blue: { three: 'four' } },
alive: 'six',
});
});
});
describe('keepKeys', () => {
it('should keep the specified keys only', () => {
const result = keepKeys({
one: 123,
two: { three: 'skip me' },
four: 'five',
six: null,
8: 'sneaky',
}, [
'one', 'three', 'six', 'seven', '8', // yup, the 8 integer will be converted to a string.
]);
expect(result).toEqual({
one: 123,
six: null,
8: 'sneaky',
});
});
});