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

[Hotfix/Develop] Split project report flow into two steps
This commit is contained in:
Paul Kaplan 2020-03-18 11:58:01 -04:00 committed by GitHub
commit c23805f543
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 292 additions and 189 deletions

View file

@ -84,6 +84,12 @@ row to appear to contain overflow. */
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 {
margin: 0 0 0 .54625rem;
border-radius: .25rem;

View file

@ -0,0 +1,89 @@
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;

View file

@ -6,95 +6,50 @@ const FormattedMessage = require('react-intl').FormattedMessage;
const injectIntl = require('react-intl').injectIntl;
const intlShape = require('react-intl').intlShape;
const Modal = require('../base/modal.jsx');
const classNames = require('classnames');
const ModalTitle = require('../base/modal-title.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 Spinner = require('../../spinner/spinner.jsx');
const TextArea = require('../../forms/textarea.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
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('./modal.scss');
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'}
}
];
// 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.
const STEPS = {
category: 0,
textInput: 1,
confirmation: 2
};
class ReportModal extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleCategorySelect',
'handleValid',
'handleInvalid'
'handleSetCategory',
'handleSubmit'
]);
this.state = {
category: '',
notes: '',
valid: false
step: STEPS.category,
categoryValue: ''
};
}
handleCategorySelect (name, value) {
this.setState({category: value});
handleSetCategory (formData) {
return this.setState({
categoryValue: formData.category,
step: STEPS.textInput
});
}
handleValid () {
this.setState({valid: true});
}
handleInvalid () {
this.setState({valid: false});
}
lookupPrompt (value) {
const prompt = REPORT_OPTIONS.find(item => item.value === value).prompt;
return this.props.intl.formatMessage(prompt);
handleSubmit (formData) {
this.props.onReport({
report_category: this.state.categoryValue,
notes: formData.notes
});
}
render () {
const {
@ -103,14 +58,19 @@ class ReportModal extends React.Component {
isError,
isOpen,
isWaiting,
onReport, // eslint-disable-line no-unused-vars
onRequestClose,
type,
reportOptions,
...modalProps
} = this.props;
const submitEnabled = this.state.valid && !isWaiting;
const submitDisabledParam = submitEnabled ? {} : {disabled: 'disabled'};
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 (
<Modal
useStandardSizes
@ -124,120 +84,93 @@ class ReportModal extends React.Component {
<div className="report-modal-header modal-header">
<ModalTitle title={contentLabel} />
</div>
<ModalInnerContent className="report-modal-content">
{isError && (
<div className="error-text">
<FormattedMessage id="report.error" />
</div>
)}
<Progression step={step}>
{/* Category selection step */}
<FormStep
nextLabel={{id: 'general.next'}}
onNext={this.handleSetCategory}
>
<div className="instructions">
<FormattedMessage
id={`report.${type}Instructions`}
key={`report.${type}Instructions`}
values={{
CommunityGuidelinesLink: (
<a href="/community_guidelines">
<FormattedMessage id="report.CommunityGuidelinesLinkText" />
</a>
)
}}
/>
</div>
<Select
required
elementWrapperClassName="report-modal-field"
label={null}
name="category"
options={reportOptions.map(option => ({
value: option.value,
label: intl.formatMessage(option.label),
key: option.value
}))}
validationErrors={{
isDefaultRequiredValue: categoryRequiredMessage
}}
/>
</FormStep>
<Form
className="report"
onInvalid={this.handleInvalid}
onValid={this.handleValid}
onValidSubmit={onReport}
>
<ModalInnerContent className="report-modal-content">
{isConfirmed ? (
<div className="received">
<div className="received-header">
{/* 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
autoFocus
required
className="report-text"
elementWrapperClassName="report-modal-field"
label={null}
name="notes"
validationErrors={{
isDefaultRequiredValue: intl.formatMessage({id: 'report.textMissing'}),
maxLength: intl.formatMessage({id: 'report.tooLongError'}),
minLength: intl.formatMessage({id: 'report.tooShortError'})
}}
validations={{
maxLength: 500,
minLength: 20
}}
/>
</FormStep>
{/* Confirmation step */}
<FormStep
submitEnabled
nextLabel={{id: 'general.close'}}
onNext={onRequestClose}
>
<div className="instructions">
<div className="instructions-header">
<FormattedMessage id="report.receivedHeader" />
</div>
<FormattedMessage id="report.receivedBody" />
</div>
) : (
<div>
<div className="instructions">
<FormattedMessage
id={`report.${type}Instructions`}
key={`report.${type}Instructions`}
values={{
CommunityGuidelinesLink: (
<a href="/community_guidelines">
<FormattedMessage id="report.CommunityGuidelinesLinkText" />
</a>
)
}}
/>
</div>
<Select
required
elementWrapperClassName="report-modal-field"
label={null}
name="report_category"
options={REPORT_OPTIONS.map(option => ({
value: option.value,
label: this.props.intl.formatMessage(option.label),
key: option.value
}))}
validationErrors={{
isDefaultRequiredValue: this.props.intl.formatMessage({
id: 'report.reasonMissing'
})
}}
value={this.state.category}
onChange={this.handleCategorySelect}
/>
<TextArea
required
className="report-text"
elementWrapperClassName="report-modal-field"
label={null}
name="notes"
placeholder={this.lookupPrompt(this.state.category)}
validationErrors={{
isDefaultRequiredValue: this.props.intl.formatMessage({
id: 'report.textMissing'
}),
maxLength: this.props.intl.formatMessage({id: 'report.tooLongError'}),
minLength: this.props.intl.formatMessage({id: 'report.tooShortError'})
}}
validations={{
maxLength: 500,
minLength: 20
}}
value={this.state.notes}
/>
</div>
)}
{isError && (
<div className="error-text">
<FormattedMessage id="report.error" />
</div>
)}
</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="action-button-text">
<Spinner />
<FormattedMessage id="report.sending" />
</div>
) : (
<div className="action-button-text">
<FormattedMessage id="report.send" />
</div>
)}
</Button>
)}
</div>
</FlexRow>
</Form>
</FormStep>
</Progression>
</ModalInnerContent>
</div>
</Modal>
);
@ -252,9 +185,14 @@ ReportModal.propTypes = {
isWaiting: PropTypes.bool,
onReport: PropTypes.func,
onRequestClose: PropTypes.func,
reportOptions: reportOptionsShape,
type: PropTypes.string
};
ReportModal.defaultProps = {
reportOptions: REPORT_OPTIONS
};
const mapStateToProps = state => ({
isConfirmed: state.preview.status.report === previewActions.Status.FETCHED,
isError: state.preview.status.report === previewActions.Status.ERROR,

View file

@ -13,15 +13,8 @@
.instructions {
line-height: 1.5rem;
}
.received {
margin: 0 auto;
width: 90%;
text-align: center;
line-height: 1.65rem;
.received-header {
.instructions-header {
font-weight: bold;
}
}

View file

@ -0,0 +1,77 @@
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
};