mirror of
https://github.com/scratchfoundation/scratch-www.git
synced 2024-11-22 23:27:54 -05:00
Merge pull request #3753 from LLK/hotfix/multi-step-report
[Hotfix/Develop] Split project report flow into two steps
This commit is contained in:
commit
c23805f543
5 changed files with 292 additions and 189 deletions
|
@ -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;
|
||||
|
|
89
src/components/modal/report/form-step.jsx
Normal file
89
src/components/modal/report/form-step.jsx
Normal 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;
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
77
src/components/modal/report/report-options.js
Normal file
77
src/components/modal/report/report-options.js
Normal 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
|
||||
};
|
Loading…
Reference in a new issue