Unverified Commit c23805f5 authored by Paul Kaplan's avatar Paul Kaplan Committed by GitHub

Merge pull request #3753 from LLK/hotfix/multi-step-report

[Hotfix/Develop] Split project report flow into two steps
parents 507023fa 5670b455
...@@ -84,6 +84,12 @@ row to appear to contain overflow. */ ...@@ -84,6 +84,12 @@ row to appear to contain overflow. */
margin-bottom: .9375rem; margin-bottom: .9375rem;
} }
/* For action button row where left/right margin is handled by parent element */
.action-buttons.action-buttons-no-inset {
margin-left: 0;
margin-right: 0;
}
.action-button { .action-button {
margin: 0 0 0 .54625rem; margin: 0 0 0 .54625rem;
border-radius: .25rem; border-radius: .25rem;
......
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
const FormattedMessage = require('react-intl').FormattedMessage;
const classNames = require('classnames');
const Form = require('../../forms/form.jsx');
const Button = require('../../forms/button.jsx');
const Spinner = require('../../spinner/spinner.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
require('../../forms/button.scss');
/**
* Step to be used in a form progression. Provides wrapping form element,
* renders children input elements, then provides a next button row
* that responds to form validation and submission spinner.
*/
class FormStep extends React.Component {
constructor (props) {
super(props);
this.state = {
valid: false
};
bindAll(this, [
'handleValid',
'handleInvalid'
]);
}
handleValid () {
this.setState({valid: true});
}
handleInvalid () {
this.setState({valid: false});
}
render () {
const {onNext, children, isWaiting, nextLabel} = this.props;
// Submit button is enabled if form isn't already submitting, and either the form passes validation,
// or the submitEnabled prop is true. This lets submitEnabled prop override validation.
const submitEnabled = (this.props.submitEnabled || this.state.valid) && !isWaiting;
const submitDisabledParam = submitEnabled ? {} : {disabled: 'disabled'};
return (
<Form
onInvalid={this.handleInvalid}
onValid={this.handleValid}
onValidSubmit={onNext}
>
{children}
<FlexRow className={classNames('action-buttons', 'action-buttons-no-inset')}>
<Button
className={classNames(
'action-button',
'submit-button',
{disabled: !submitEnabled}
)}
{...submitDisabledParam}
key="submitButton"
type="submit"
>
{isWaiting ? (
<div className="action-button-text">
<Spinner />
<FormattedMessage id="report.sending" />
</div>
) : (
<div className="action-button-text">
<FormattedMessage {...nextLabel} />
</div>
)}
</Button>
</FlexRow>
</Form>
);
}
}
FormStep.propTypes = {
children: PropTypes.node.isRequired,
isWaiting: PropTypes.bool,
nextLabel: PropTypes.shape({id: PropTypes.string.isRequired}).isRequired,
onNext: PropTypes.func.isRequired,
submitEnabled: PropTypes.bool
};
FormStep.defaultProps = {
isWaiting: false,
submitEnabled: false
};
module.exports = FormStep;
...@@ -6,95 +6,50 @@ const FormattedMessage = require('react-intl').FormattedMessage; ...@@ -6,95 +6,50 @@ const FormattedMessage = require('react-intl').FormattedMessage;
const injectIntl = require('react-intl').injectIntl; const injectIntl = require('react-intl').injectIntl;
const intlShape = require('react-intl').intlShape; const intlShape = require('react-intl').intlShape;
const Modal = require('../base/modal.jsx'); const Modal = require('../base/modal.jsx');
const classNames = require('classnames');
const ModalTitle = require('../base/modal-title.jsx'); const ModalTitle = require('../base/modal-title.jsx');
const ModalInnerContent = require('../base/modal-inner-content.jsx'); const ModalInnerContent = require('../base/modal-inner-content.jsx');
const Form = require('../../forms/form.jsx');
const Button = require('../../forms/button.jsx');
const Select = require('../../forms/select.jsx'); const Select = require('../../forms/select.jsx');
const Spinner = require('../../spinner/spinner.jsx');
const TextArea = require('../../forms/textarea.jsx'); const TextArea = require('../../forms/textarea.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
const previewActions = require('../../../redux/preview.js'); const previewActions = require('../../../redux/preview.js');
const Progression = require('../../progression/progression.jsx');
const FormStep = require('./form-step.jsx');
const {reportOptionsShape, REPORT_OPTIONS} = require('./report-options.js');
require('../../forms/button.scss'); require('../../forms/button.scss');
require('./modal.scss'); require('./modal.scss');
const REPORT_OPTIONS = [ // The Progression component uses numbers to track which step it's on, but that's
{ // hard to read. Make the code easier to read by giving each step number a label.
value: '', const STEPS = {
label: {id: 'report.reasonPlaceHolder'}, category: 0,
prompt: {id: 'report.promptPlaceholder'} textInput: 1,
}, confirmation: 2
{ };
value: '0',
label: {id: 'report.reasonCopy'},
prompt: {id: 'report.promptCopy'}
},
{
value: '1',
label: {id: 'report.reasonUncredited'},
prompt: {id: 'report.promptUncredited'}
},
{
value: '2',
label: {id: 'report.reasonScary'},
prompt: {id: 'report.promptScary'}
},
{
value: '3',
label: {id: 'report.reasonLanguage'},
prompt: {id: 'report.promptLanguage'}
},
{
value: '4',
label: {id: 'report.reasonMusic'},
prompt: {id: 'report.promptMusic'}
},
{
value: '8',
label: {id: 'report.reasonImage'},
prompt: {id: 'report.promptImage'}
},
{
value: '5',
label: {id: 'report.reasonPersonal'},
prompt: {id: 'report.promptPersonal'}
},
{
value: '6',
label: {id: 'general.other'},
prompt: {id: 'report.promptGuidelines'}
}
];
class ReportModal extends React.Component { class ReportModal extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'handleCategorySelect', 'handleSetCategory',
'handleValid', 'handleSubmit'
'handleInvalid'
]); ]);
this.state = { this.state = {
category: '', step: STEPS.category,
notes: '', categoryValue: ''
valid: false
}; };
} }
handleCategorySelect (name, value) { handleSetCategory (formData) {
this.setState({category: value}); return this.setState({
} categoryValue: formData.category,
handleValid () { step: STEPS.textInput
this.setState({valid: true}); });
} }
handleInvalid () { handleSubmit (formData) {
this.setState({valid: false}); this.props.onReport({
} report_category: this.state.categoryValue,
lookupPrompt (value) { notes: formData.notes
const prompt = REPORT_OPTIONS.find(item => item.value === value).prompt; });
return this.props.intl.formatMessage(prompt);
} }
render () { render () {
const { const {
...@@ -103,14 +58,19 @@ class ReportModal extends React.Component { ...@@ -103,14 +58,19 @@ class ReportModal extends React.Component {
isError, isError,
isOpen, isOpen,
isWaiting, isWaiting,
onReport, // eslint-disable-line no-unused-vars
onRequestClose, onRequestClose,
type, type,
reportOptions,
...modalProps ...modalProps
} = this.props; } = this.props;
const submitEnabled = this.state.valid && !isWaiting;
const submitDisabledParam = submitEnabled ? {} : {disabled: 'disabled'};
const contentLabel = intl.formatMessage({id: `report.${type}`}); const contentLabel = intl.formatMessage({id: `report.${type}`});
const categoryRequiredMessage = intl.formatMessage({id: 'report.reasonMissing'});
const category = reportOptions.find(o => o.value === this.state.categoryValue) || reportOptions[0];
// Confirmation step is shown if a report has been submitted, even if state is reset by closing the modal.
// This prevents multiple report submission within the same session because submission is stored in redux.
const step = isConfirmed ? STEPS.confirmation : this.state.step;
return ( return (
<Modal <Modal
useStandardSizes useStandardSizes
...@@ -124,23 +84,18 @@ class ReportModal extends React.Component { ...@@ -124,23 +84,18 @@ class ReportModal extends React.Component {
<div className="report-modal-header modal-header"> <div className="report-modal-header modal-header">
<ModalTitle title={contentLabel} /> <ModalTitle title={contentLabel} />
</div> </div>
<Form
className="report"
onInvalid={this.handleInvalid}
onValid={this.handleValid}
onValidSubmit={onReport}
>
<ModalInnerContent className="report-modal-content"> <ModalInnerContent className="report-modal-content">
{isConfirmed ? ( {isError && (
<div className="received"> <div className="error-text">
<div className="received-header"> <FormattedMessage id="report.error" />
<FormattedMessage id="report.receivedHeader" />
</div>
<FormattedMessage id="report.receivedBody" />
</div> </div>
) : ( )}
<div> <Progression step={step}>
{/* Category selection step */}
<FormStep
nextLabel={{id: 'general.next'}}
onNext={this.handleSetCategory}
>
<div className="instructions"> <div className="instructions">
<FormattedMessage <FormattedMessage
id={`report.${type}Instructions`} id={`report.${type}Instructions`}
...@@ -158,86 +113,64 @@ class ReportModal extends React.Component { ...@@ -158,86 +113,64 @@ class ReportModal extends React.Component {
required required
elementWrapperClassName="report-modal-field" elementWrapperClassName="report-modal-field"
label={null} label={null}
name="report_category" name="category"
options={REPORT_OPTIONS.map(option => ({ options={reportOptions.map(option => ({
value: option.value, value: option.value,
label: this.props.intl.formatMessage(option.label), label: intl.formatMessage(option.label),
key: option.value key: option.value
}))} }))}
validationErrors={{ validationErrors={{
isDefaultRequiredValue: this.props.intl.formatMessage({ isDefaultRequiredValue: categoryRequiredMessage
id: 'report.reasonMissing'
})
}} }}
value={this.state.category}
onChange={this.handleCategorySelect}
/> />
</FormStep>
{/* Text input step */}
<FormStep
isWaiting={isWaiting}
nextLabel={{id: 'report.send'}}
onNext={this.handleSubmit}
>
<div className="instructions">
<div className="instructions-header">
<FormattedMessage {...category.label} />
</div>
<FormattedMessage {...category.prompt} />
</div>
<TextArea <TextArea
autoFocus
required required
className="report-text" className="report-text"
elementWrapperClassName="report-modal-field" elementWrapperClassName="report-modal-field"
label={null} label={null}
name="notes" name="notes"
placeholder={this.lookupPrompt(this.state.category)}
validationErrors={{ validationErrors={{
isDefaultRequiredValue: this.props.intl.formatMessage({ isDefaultRequiredValue: intl.formatMessage({id: 'report.textMissing'}),
id: 'report.textMissing' maxLength: intl.formatMessage({id: 'report.tooLongError'}),
}), minLength: intl.formatMessage({id: 'report.tooShortError'})
maxLength: this.props.intl.formatMessage({id: 'report.tooLongError'}),
minLength: this.props.intl.formatMessage({id: 'report.tooShortError'})
}} }}
validations={{ validations={{
maxLength: 500, maxLength: 500,
minLength: 20 minLength: 20
}} }}
value={this.state.notes}
/> />
</div> </FormStep>
)}
{isError && ( {/* Confirmation step */}
<div className="error-text"> <FormStep
<FormattedMessage id="report.error" /> submitEnabled
</div> nextLabel={{id: 'general.close'}}
)} onNext={onRequestClose}
</ModalInnerContent>
<FlexRow className="action-buttons">
<div className="action-buttons-overflow-fix">
{isConfirmed ? (
<Button
className="action-button submit-button"
type="button"
onClick={onRequestClose}
>
<div className="action-button-text">
<FormattedMessage id="general.close" />
</div>
</Button>
) : (
<Button
className={classNames(
'action-button',
'submit-button',
{disabled: !submitEnabled}
)}
{...submitDisabledParam}
key="submitButton"
type="submit"
> >
{isWaiting ? ( <div className="instructions">
<div className="action-button-text"> <div className="instructions-header">
<Spinner /> <FormattedMessage id="report.receivedHeader" />
<FormattedMessage id="report.sending" />
</div>
) : (
<div className="action-button-text">
<FormattedMessage id="report.send" />
</div> </div>
)} <FormattedMessage id="report.receivedBody" />
</Button>
)}
</div> </div>
</FlexRow> </FormStep>
</Form> </Progression>
</ModalInnerContent>
</div> </div>
</Modal> </Modal>
); );
...@@ -252,9 +185,14 @@ ReportModal.propTypes = { ...@@ -252,9 +185,14 @@ ReportModal.propTypes = {
isWaiting: PropTypes.bool, isWaiting: PropTypes.bool,
onReport: PropTypes.func, onReport: PropTypes.func,
onRequestClose: PropTypes.func, onRequestClose: PropTypes.func,
reportOptions: reportOptionsShape,
type: PropTypes.string type: PropTypes.string
}; };
ReportModal.defaultProps = {
reportOptions: REPORT_OPTIONS
};
const mapStateToProps = state => ({ const mapStateToProps = state => ({
isConfirmed: state.preview.status.report === previewActions.Status.FETCHED, isConfirmed: state.preview.status.report === previewActions.Status.FETCHED,
isError: state.preview.status.report === previewActions.Status.ERROR, isError: state.preview.status.report === previewActions.Status.ERROR,
......
...@@ -13,15 +13,8 @@ ...@@ -13,15 +13,8 @@
.instructions { .instructions {
line-height: 1.5rem; line-height: 1.5rem;
}
.received {
margin: 0 auto;
width: 90%;
text-align: center;
line-height: 1.65rem;
.received-header { .instructions-header {
font-weight: bold; font-weight: bold;
} }
} }
......
const PropTypes = require('prop-types');
const {
arrayOf,
string,
shape
} = PropTypes;
/**
* Define both the PropType shape and default value for report options
* to ensure structure is validated by PropType checking going forward.
*/
const messageShape = shape({
id: string.isRequired
});
const categoryShape = shape({
value: string.isRequired,
label: messageShape.isRequired,
prompt: messageShape.isRequired
});
const reportOptionsShape = arrayOf(categoryShape);
const REPORT_OPTIONS = [
{
value: '',
label: {id: 'report.reasonPlaceHolder'},
prompt: {id: 'report.promptPlaceholder'}
},
{
value: '0',
label: {id: 'report.reasonCopy'},
prompt: {id: 'report.promptCopy'}
},
{
value: '1',
label: {id: 'report.reasonUncredited'},
prompt: {id: 'report.promptUncredited'}
},
{
value: '2',
label: {id: 'report.reasonScary'},
prompt: {id: 'report.promptScary'}
},
{
value: '3',
label: {id: 'report.reasonLanguage'},
prompt: {id: 'report.promptLanguage'}
},
{
value: '4',
label: {id: 'report.reasonMusic'},
prompt: {id: 'report.promptMusic'}
},
{
value: '8',
label: {id: 'report.reasonImage'},
prompt: {id: 'report.promptImage'}
},
{
value: '5',
label: {id: 'report.reasonPersonal'},
prompt: {id: 'report.promptPersonal'}
},
{
value: '6',
label: {id: 'general.other'},
prompt: {id: 'report.promptGuidelines'}
}
];
module.exports = {
reportOptionsShape,
REPORT_OPTIONS
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment