Merge pull request #4901 from seotts/automod-feedback-modal

Automod feedback modal steps
This commit is contained in:
Sarah Otts 2021-02-02 08:58:47 -05:00 committed by GitHub
commit f60a8c71a0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 479 additions and 43 deletions

View file

@ -0,0 +1,113 @@
const PropTypes = require('prop-types');
const React = require('react');
const classNames = require('classnames');
import {Formik} from 'formik';
const FormikInput = require('../../../components/formik-forms/formik-input.jsx');
const bindAll = require('lodash.bindall');
require('./modal.scss');
class FeedbackForm extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleSetFeedbackRef',
'handleValidSubmit',
'validateFeedback',
'validateForm'
]);
}
// called after feedback validation passes with no errors
handleValidSubmit (formData, formikBag) {
formikBag.setSubmitting(false); // formik makes us do this ourselves
this.props.onSubmit(formData.feedback);
}
handleSetFeedbackRef (feedbackInputRef) {
this.feedbackInput = feedbackInputRef;
}
validateFeedback (feedback) {
if (feedback.length < this.props.minLength) {
return this.props.emptyErrorMessage;
}
return null;
}
validateForm (values) {
return this.validateFeedback(values.feedback);
}
render () {
return (
<Formik
initialValues={{
feedback: ''
}}
validate={this.validateForm}
validateOnBlur={false}
validateOnChange={false}
onSubmit={this.handleValidSubmit}
>
{props => {
const {
errors,
handleSubmit,
setFieldError,
setFieldTouched,
setFieldValue
} = props;
return (
<form
id="feedback-form"
onSubmit={handleSubmit}
>
<FormikInput
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className={classNames(
'compose-feedback',
)}
component="textarea"
error={errors.feedback}
id="feedback"
maxLength={this.props.maxLength}
name="feedback"
rows={5}
type="text"
validate={this.validateFeedback}
validationClassName="validation-full-width-input"
/* eslint-disable react/jsx-no-bind */
onChange={e => {
setFieldValue('feedback', e.target.value);
setFieldTouched('feedback');
setFieldError('feedback', null);
}}
/* eslint-enable react/jsx-no-bind */
onSetRef={this.handleSetFeedbackRef}
/>
</form>
);
}}
</Formik>
);
}
}
FeedbackForm.propTypes = {
emptyErrorMessage: PropTypes.string,
maxLength: PropTypes.number,
minLength: PropTypes.number,
onSubmit: PropTypes.func.isRequired
};
FeedbackForm.defaultProps = {
minLength: 1
};
module.exports = FeedbackForm;

View file

@ -10,22 +10,34 @@ const Button = require('../../forms/button.jsx');
const Progression = require('../../progression/progression.jsx');
const FlexRow = require('../../flex-row/flex-row.jsx');
const MuteStep = require('./mute-step.jsx');
const FeedbackForm = require('./feedback-form.jsx');
const classNames = require('classnames');
require('./modal.scss');
const steps = {
COMMENT_ISSUE: 0,
MUTE_INFO: 1,
BAN_WARNING: 2,
USER_FEEDBACK: 3,
FEEDBACK_SENT: 4
};
const MAX_FEEDBACK_LENGTH = 500;
class MuteModal extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleNext',
'handlePrevious'
'handlePrevious',
'handleGoToFeedback',
'handleFeedbackSubmit'
]);
this.numSteps = 2;
if (this.props.showWarning) {
this.numSteps++;
}
this.numSteps = this.props.showWarning ? steps.BAN_WARNING : steps.MUTE_INFO;
this.state = {
step: 0
step: steps.COMMENT_ISSUE
};
}
handleNext () {
@ -40,7 +52,36 @@ class MuteModal extends React.Component {
step: Math.max(0, this.state.step - 1)
});
}
handleGoToFeedback () {
this.setState({
step: steps.USER_FEEDBACK
});
}
handleFeedbackSubmit (feedback) {
/* eslint-disable no-console */
console.log(feedback);
/* eslint-enable no-console */
this.setState({
step: steps.FEEDBACK_SENT
});
}
render () {
const feedbackPrompt = (
<p className="feedback-prompt">
<FormattedMessage
id="comments.muted.mistake"
values={{feedbackLink: (
<a onClick={this.handleGoToFeedback}>
<FormattedMessage id="comments.muted.feedbackLinkText" />
</a>
)}}
/>
</p>
);
return (
<Modal
isOpen
@ -85,46 +126,67 @@ class MuteModal extends React.Component {
)}}
/>
</p>
{this.state.step === this.numSteps ? feedbackPrompt : null}
</MuteStep>
<MuteStep
bottomImg="/svgs/commenting/warning.svg"
bottomImgClass="bottom-img"
header={this.props.intl.formatMessage({id: 'comments.muted.warningBlocked'})}
>
<p>
<FormattedMessage
id="comments.muted.warningCareful"
values={{CommunityGuidelinesLink: (
<a href="/community_guidelines">
<FormattedMessage id="report.CommunityGuidelinesLinkText" />
</a>
)}}
/>
</p>
{this.state.step === this.numSteps ? feedbackPrompt : null}
</MuteStep>
<MuteStep
header={this.props.intl.formatMessage({id: 'comments.muted.mistakeHeader'})}
>
<p className="feedback-text">
<FormattedMessage id="comments.muted.mistakeInstructions" />
</p>
<FeedbackForm
emptyErrorMessage={this.props.intl.formatMessage({id: 'comments.muted.feedbackEmpty'})}
maxLength={MAX_FEEDBACK_LENGTH}
onSubmit={this.handleFeedbackSubmit}
/>
<div className="character-limit">
<FormattedMessage id="comments.muted.characterLimit" />
</div>
</MuteStep>
<MuteStep
header={this.props.intl.formatMessage({id: 'comments.muted.thanksFeedback'})}
sideImg="/svgs/commenting/thank_you_envelope.svg"
sideImgClass="side-img"
>
<p>
<FormattedMessage id="comments.muted.thanksInfo" />
</p>
</MuteStep>
{this.props.showWarning ? (
<MuteStep
bottomImg="/svgs/commenting/warning.svg"
bottomImgClass="bottom-img"
header={this.props.intl.formatMessage({id: 'comments.muted.warningBlocked'})}
>
<p>
<FormattedMessage
id="comments.muted.warningCareful"
values={{CommunityGuidelinesLink: (
<a href="/community_guidelines">
<FormattedMessage id="report.CommunityGuidelinesLinkText" />
</a>
)}}
/>
</p>
</MuteStep>) : null}
</Progression>
<FlexRow className={classNames('nav-divider')} />
<FlexRow className={classNames('mute-nav')}>
{this.state.step > 0 ? (
<Button
className={classNames(
'back-button',
)}
onClick={this.handlePrevious}
>
<div className="action-button-text">
<FormattedMessage id="general.back" />
</div>
</Button>
) : null }
{this.state.step >= this.numSteps - 1 ? (
<FlexRow
className={classNames(
this.state.step === steps.USER_FEEDBACK ? 'feedback-nav' : 'mute-nav'
)}
>
{this.state.step >= this.numSteps ? (
<Button
className={classNames('close-button')}
onClick={this.props.onRequestClose}
>
<div className="action-button-text">
<FormattedMessage id="general.close" />
{this.state.step === steps.USER_FEEDBACK ? (
<FormattedMessage id="general.cancel" />
) : (
<FormattedMessage id="general.close" />
)}
</div>
</Button>
) : (
@ -137,6 +199,31 @@ class MuteModal extends React.Component {
</div>
</Button>
)}
{this.state.step > 0 && this.state.step < steps.USER_FEEDBACK ? (
<Button
className={classNames(
'back-button',
)}
onClick={this.handlePrevious}
>
<div className="action-button-text">
<FormattedMessage id="general.back" />
</div>
</Button>
) : this.state.step === steps.USER_FEEDBACK ? (
<Button
className={classNames(
'send-button',
)}
form="feedback-form"
type="submit"
>
<div className="action-button-text">
<FormattedMessage id="general.send" />
</div>
</Button>
) : null}
</FlexRow>
</ModalInnerContent>
</Modal>

View file

@ -48,8 +48,26 @@
.mute-nav {
display:flex;
justify-content: space-between;
flex-direction: row-reverse;
padding: 20px 0;
}
.feedback-nav {
width: 100%;
display: flex;
justify-content: center;
flex-direction: row-reverse;
padding: 24px;
button {
margin: 0 4px;
}
.close-button {
background-color: $ui-dark-gray;
}
}
.back-button {
margin-top: 0;
margin-bottom: 0;
@ -59,4 +77,26 @@
margin-top: 0;
margin-bottom: 0;
}
.feedback-text {
text-align: center;
}
#feedback-form, textarea {
height: 180px;
width: 100%;
}
textarea {
padding: 1rem;
}
.character-limit {
font-size: .75rem;
}
.validation-message {
top: 52px;
left: 36px;
}
}

View file

@ -7,6 +7,8 @@
"general.birthMonth": "Birth Month",
"general.birthYear": "Birth Year",
"general.donate": "Donate",
"general.monthDecember": "December",
"general.cancel": "Cancel",
"general.close": "Close",
"general.collaborators": "Collaborators",
"general.community": "Community",
@ -82,6 +84,7 @@
"general.scratchStore": "Scratch Store",
"general.search": "Search",
"general.searchEmpty": "Nothing found",
"general.send": "Send",
"general.signIn": "Sign in",
"general.startOver": "Start over",
"general.statistics": "Statistics",
@ -356,7 +359,14 @@
"comments.muted.clickHereLinkText": "click here",
"comments.muted.warningBlocked": "If you continue to post comments like this, it will cause you to be blocked from using Scratch",
"comments.muted.warningCareful": "We don't want that to happen, so please be careful and make sure you have read and understand the {CommunityGuidelinesLink} before you try to post again!",
"comments.muted.mistake": "Think this was a mistake? {feedbackLink}.",
"comments.muted.feedbackLinkText": "Let us know",
"comments.muted.mistakeHeader": "Think this was a mistake?",
"comments.muted.mistakeInstructions": "Sometimes the filter catches things by accident. Your feedback will help prevent this from happening in the future.",
"comments.muted.thanksFeedback": "Thanks for letting us know!",
"comments.muted.thanksInfo": "Your feedback will help us make Scratch better.",
"comments.muted.characterLimit": "500 characters max",
"comments.muted.feedbackEmpty": "Can't be empty",
"social.embedLabel": "Embed",
"social.copyEmbedLinkText": "Copy embed",

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 132 200" style="enable-background:new 0 0 132 200;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
.st1{opacity:0.25;fill:#FF8C1A;}
.st2{fill:#FF8C1A;}
.st3{opacity:0.25;fill:#0EB989;}
.st4{fill:#FFC709;}
.st5{fill:none;stroke:#5C6671;stroke-linecap:round;stroke-miterlimit:10;}
.st6{fill:#5C6671;}
.st7{opacity:0.5;fill:#6E7B8A;}
.st8{fill-rule:evenodd;clip-rule:evenodd;fill:#ED5F87;}
.st9{opacity:0.25;fill-rule:evenodd;clip-rule:evenodd;fill:#0EB989;}
.st10{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
.st11{fill-rule:evenodd;clip-rule:evenodd;fill:#0EBD8C;fill-opacity:0.1;}
.st12{fill-rule:evenodd;clip-rule:evenodd;fill:#575E75;fill-opacity:0.25;}
.st13{fill-rule:evenodd;clip-rule:evenodd;fill:#0EBD8C;fill-opacity:0.25;}
.st14{fill:#FFBF00;}
.st15{fill:#2F9B73;}
.st16{fill-rule:evenodd;clip-rule:evenodd;fill:#CF8B17;}
.st17{fill-rule:evenodd;clip-rule:evenodd;fill:#5C6771;}
.st18{opacity:0.25;fill-rule:evenodd;clip-rule:evenodd;fill:#0FBD8C;}
.st19{fill-rule:evenodd;clip-rule:evenodd;fill:#FFBF00;}
.st20{fill-rule:evenodd;clip-rule:evenodd;fill:#36A97E;}
.st21{opacity:0.6;fill-rule:evenodd;clip-rule:evenodd;fill:#0FBD8C;}
.st22{fill:#9966FF;}
.st23{fill-rule:evenodd;clip-rule:evenodd;fill:#575E75;}
.st24{fill-rule:evenodd;clip-rule:evenodd;fill:#C2C5C6;}
.st25{fill-rule:evenodd;clip-rule:evenodd;fill:#EEF3F8;}
.st26{fill:#575E75;}
.st27{opacity:0.3;fill:#575E75;}
.st28{fill:#EEF3F8;}
.st29{fill:#B5875C;}
.st30{fill:#CCAA93;}
.st31{fill:#5C6671;stroke:#FFBF00;stroke-width:0.5465;stroke-miterlimit:10;}
.st32{opacity:0.8;fill:#FFFFFF;}
.st33{fill:#DCDDDE;}
.st34{fill:none;stroke:#DCDDDE;stroke-width:2.0722;stroke-miterlimit:10;}
.st35{clip-path:url(#SVGID_2_);}
.st36{opacity:0.6;}
.st37{fill:#3CB98A;}
.st38{fill:#3CB98A;stroke:#2F9B73;stroke-miterlimit:10;}
.st39{fill-rule:evenodd;clip-rule:evenodd;fill:#DCDDDE;}
.st40{clip-path:url(#SVGID_4_);}
</style>
<g id="Layer_2">
</g>
<g id="Layer_1">
<g>
<defs>
<rect id="SVGID_3_" width="132" height="200"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_3_" style="overflow:visible;"/>
</clipPath>
<g class="st35">
<path class="st9" d="M-0.3,191.7c-63-3.3-56-14.6-59.5-91.5c-2.9-64.7,28.6-96,90.2-96c76.8,0,98.5,30.4,96.3,95.3
C124.3,168.5,47.3,194.2-0.3,191.7z"/>
<path class="st8" d="M104,69.8c0.5,6.9-6.5,13.5-8.4,13.8c-2,0.3-10.5-3.9-12.2-10.6c-0.1-0.2-0.1-0.4-0.1-0.7
c-0.5-3.3,1.8-6.4,5.1-6.9c1.7-0.3,3.3,0.2,4.6,1.2c0.9-1.3,2.3-2.2,4-2.5c3.3-0.5,6.5,1.8,7,5.1C104,69.3,104,69.6,104,69.8z"/>
<path class="st32" d="M14.7,35.6c1.8-0.5,3.3-1.9,3.8-3.8l1.1-4.2c0.5-2,3.3-2,3.9,0l1.1,4.2c0.5,1.8,1.9,3.3,3.8,3.8l4.2,1.1
c2,0.5,2,3.3,0,3.9l-4.2,1.1c-1.8,0.5-3.3,1.9-3.8,3.8l-1.1,4.2c-0.5,2-3.3,2-3.9,0l-1.1-4.2c-0.5-1.8-1.9-3.3-3.8-3.8l-4.2-1.1
c-2-0.5-2-3.3,0-3.9L14.7,35.6z"/>
<path class="st32" d="M74.2,142c1.2-0.3,2.1-1.2,2.4-2.4l0.7-2.7c0.3-1.3,2.1-1.3,2.5,0l0.7,2.7c0.3,1.2,1.2,2.1,2.4,2.4l2.7,0.7
c1.3,0.3,1.3,2.1,0,2.5l-2.7,0.7c-1.2,0.3-2.1,1.2-2.4,2.4l-0.7,2.7c-0.3,1.3-2.1,1.3-2.5,0l-0.7-2.7c-0.3-1.2-1.2-2.1-2.4-2.4
l-2.7-0.7c-1.3-0.3-1.3-2.1,0-2.5L74.2,142z"/>
<g>
<path class="st33" d="M-16.1,82.2l81.8-20.5c2.3-0.6,4.6,0.8,5.1,3.1l13.9,55.5c0.6,2.3-0.8,4.6-3.1,5.1l-81.8,20.5
c-2.3,0.6-4.6-0.8-5.1-3.1l-13.9-55.5C-19.8,85-18.4,82.7-16.1,82.2z"/>
<path class="st0" d="M-17.4,77l81.8-20.5c2.3-0.6,4.6,0.8,5.1,3.1l13.9,55.5c0.6,2.3-0.8,4.6-3.1,5.1l-81.8,20.5
c-2.3,0.6-4.6-0.8-5.1-3.1l-13.9-55.5C-21,79.9-19.7,77.6-17.4,77z"/>
<path class="st34" d="M80.4,120.3L32.2,91.8c-1.8-1-4-0.4-5.1,1.3l-28.9,47.7L80.4,120.3z"/>
<path class="st0" d="M64.5,56.6L-17.4,77c-1.1,0.3-2,1-2.6,1.9l50.4,25.9c1.9,0.8,4.2,0.3,5.4-1.4l31.9-46.3
C66.7,56.5,65.6,56.3,64.5,56.6z"/>
<path class="st33" d="M67.7,57.1l-31.8,46.4c-1.3,1.7-3.5,2.3-5.4,1.4L-19.9,79c-0.6,0.9-0.8,2-0.5,3l51.9,28.8
c2.1,0.9,4.5,0.3,5.9-1.5l32.3-49.6C69.3,58.6,68.7,57.7,67.7,57.1z"/>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,53 @@
import React from 'react';
import {mountWithIntl} from '../../helpers/intl-helpers.jsx';
import FeedbackForm from '../../../src/components/modal/mute/feedback-form';
describe('FeedbackFormTest', () => {
test('Feedback form empty feedback invalid', () => {
const submitFn = jest.fn();
const message = 'too short';
const component = mountWithIntl(
<FeedbackForm
emptyErrorMessage={message}
onSubmit={submitFn}
/>
);
expect(component.find('FeedbackForm').instance()
.validateFeedback('')
).toBe(message);
});
test('Feedback form shorter than minLength invalid', () => {
const submitFn = jest.fn();
const message = 'too short';
const min = 7;
const component = mountWithIntl(
<FeedbackForm
emptyErrorMessage={message}
minLength={min}
onSubmit={submitFn}
/>
);
expect(component.find('FeedbackForm').instance()
.validateFeedback('123456')
).toBe(message);
});
test('Feedback form greater than or equal to minLength invalid', () => {
const submitFn = jest.fn();
const message = 'too short';
const min = 7;
const component = mountWithIntl(
<FeedbackForm
emptyErrorMessage={message}
minLength={min}
onSubmit={submitFn}
/>
);
expect(component.find('FeedbackForm').instance()
.validateFeedback('1234567')
).toBeNull();
});
});

View file

@ -41,10 +41,13 @@ describe('MuteModalTest', () => {
/>
);
component.find('MuteModal').instance()
.setState({step: 2});
component.update();
expect(component.find('MuteStep').prop('bottomImg')).toEqual('/svgs/commenting/warning.svg');
expect(component.find('MuteStep').prop('totalSteps')).toEqual(3);
.setState({step: 1});
expect(component.find('button.next-button').exists()).toEqual(true);
expect(component.find('button.next-button').getElements()[0].props.onClick)
.toEqual(component.find('MuteModal').instance().handleNext);
component.find('MuteModal').instance()
.handleNext();
expect(component.find('MuteModal').instance().state.step).toEqual(2);
});
test('Mute Modal shows back & close button on last step', () => {
@ -113,4 +116,51 @@ describe('MuteModalTest', () => {
component.instance().handlePrevious();
expect(component.instance().state.step).toBe(0);
});
test('Mute modal asks for feedback', () => {
const component = mountWithIntl(
<MuteModal muteModalMessages={defaultMessages} />
);
component.find('MuteModal').instance()
.setState({step: 1});
component.update();
expect(component.find('p.feedback-prompt').exists()).toEqual(true);
});
test('Mute modal asks for feedback on extra showWarning step', () => {
const component = mountWithIntl(
<MuteModal
showWarning
muteModalMessages={defaultMessages}
/>
);
component.find('MuteModal').instance()
.setState({step: 1});
component.update();
expect(component.find('p.feedback-prompt').exists()).toEqual(false);
component.find('MuteModal').instance()
.setState({step: 2});
component.update();
expect(component.find('p.feedback-prompt').exists()).toEqual(true);
});
test('Mute modal handle go to feedback', () => {
const component = shallowWithIntl(
<MuteModal
muteModalMessages={defaultMessages}
/>
).dive();
component.instance().handleGoToFeedback();
expect(component.instance().state.step).toBe(3);
});
test('Mute modal submit feedback gives thank you step', () => {
const component = shallowWithIntl(
<MuteModal
muteModalMessages={defaultMessages}
/>
).dive();
component.instance().handleFeedbackSubmit('something');
expect(component.instance().state.step).toBe(4);
});
});