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

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