Merge pull request #2015 from benjiwheeler/report_project_endpoint

report project POSTs to scratchr2, displays modal reactively
This commit is contained in:
Benjamin Wheeler 2018-08-09 12:50:36 -04:00 committed by GitHub
commit 0114d3ea2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 260 additions and 119 deletions

View file

@ -28,8 +28,8 @@ module.exports.validationHOCFactory = defaultValidationErrors => (Component => {
<Component
validationErrors={defaults(
{},
defaultValidationErrors,
props.validationErrors
props.validationErrors,
defaultValidationErrors
)}
{...omit(props, ['validationErrors'])}
/>

View file

@ -35,7 +35,6 @@
.addToStudio-modal-content {
margin: 0 auto;
width: 100%;
line-height: 1.5rem;
font-size: .875rem;
}

View file

@ -64,11 +64,20 @@ $modal-close-size: 1rem;
.action-buttons {
display: flex;
margin: 1.125rem .8275rem .9375rem .8275rem;
line-height: 1.5rem;
justify-content: flex-end !important;
align-items: flex-start;
flex-wrap: nowrap;
}
/* setting overall modal to contain overflow looks good, but isn't
compatible with elements (like validation popups) that need to bleed
past modal boundary. This class can be used to force modal button
row to appear to contain overflow. */
.action-buttons-overflow-fix {
margin-bottom: .9375rem;
}
.action-button {
margin: 0 0 0 .54625rem;
border-radius: .25rem;
@ -83,3 +92,19 @@ $modal-close-size: 1rem;
.action-button-text {
display: flex;
}
.action-button.disabled {
background-color: $active-dark-gray;
}
.error-text
{
display: block;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
min-height: 1rem;
overflow: visible;
color: $type-white;
}

View file

@ -1,10 +1,12 @@
const bindAll = require('lodash.bindall');
const PropTypes = require('prop-types');
const React = require('react');
const connect = require('react-redux').connect;
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 Form = require('../../forms/form.jsx');
const Button = require('../../forms/button.jsx');
@ -12,6 +14,7 @@ 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');
require('../../forms/button.scss');
require('./modal.scss');
@ -68,12 +71,24 @@ class ReportModal extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleReportCategorySelect'
'handleCategorySelect',
'handleValid',
'handleInvalid'
]);
this.state = {reportCategory: this.props.report.category};
this.state = {
category: '',
notes: '',
valid: false
};
}
handleReportCategorySelect (name, value) {
this.setState({reportCategory: value});
handleCategorySelect (name, value) {
this.setState({category: value});
}
handleValid () {
this.setState({valid: true});
}
handleInvalid () {
this.setState({valid: false});
}
lookupPrompt (value) {
const prompt = REPORT_OPTIONS.find(item => item.value === value).prompt;
@ -82,17 +97,24 @@ class ReportModal extends React.Component {
render () {
const {
intl,
isConfirmed,
isError,
isOpen,
isWaiting,
onReport, // eslint-disable-line no-unused-vars
report,
onRequestClose,
type,
...modalProps
} = this.props;
const submitEnabled = this.state.valid && !isWaiting;
const submitDisabledParam = submitEnabled ? {} : {disabled: 'disabled'};
const contentLabel = intl.formatMessage({id: `report.${type}`});
return (
<Modal
className="mod-report"
contentLabel={contentLabel}
isOpen={report.open}
isOpen={isOpen}
onRequestClose={onRequestClose}
{...modalProps}
>
<div>
@ -104,68 +126,115 @@ class ReportModal extends React.Component {
<Form
className="report"
onSubmit={onReport}
onInvalid={this.handleInvalid}
onValid={this.handleValid}
onValidSubmit={onReport}
>
<div className="report-modal-content">
<FormattedMessage
id={`report.${type}Instructions`}
values={{
CommunityGuidelinesLink: (
<a href="/community_guidelines">
<FormattedMessage id="report.CommunityGuidelinesLinkText" />
</a>
)
}}
/>
<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)
}))}
value={this.state.reportCategory}
onChange={this.handleReportCategorySelect}
/>
<TextArea
required
className="report-text"
elementWrapperClassName="report-modal-field"
label={null}
name="notes"
placeholder={this.lookupPrompt(this.state.reportCategory)}
validationErrors={{
maxLength: this.props.intl.formatMessage({id: 'report.tooLongError'}),
minLength: this.props.intl.formatMessage({id: 'report.tooShortError'})
}}
validations={{
maxLength: 500,
minLength: 20
}}
value={report.notes}
/>
{isConfirmed ? (
<div className="received">
<div className="received-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>
)}
</div>
<FlexRow className="action-buttons">
{report.waiting ? [
<Button
className="submit-button"
disabled="disabled"
key="submitButton"
type="submit"
>
<Spinner />
</Button>
] : [
<Button
className="submit-button"
key="submitButton"
type="submit"
>
<FormattedMessage id="report.send" />
</Button>
]}
<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 mode="smooth" />
<FormattedMessage id="report.sending" />
</div>
) : (
<div className="action-button-text">
<FormattedMessage id="report.send" />
</div>
)}
</Button>
)}
</div>
</FlexRow>
</Form>
</div>
@ -176,15 +245,26 @@ class ReportModal extends React.Component {
ReportModal.propTypes = {
intl: intlShape,
isConfirmed: PropTypes.bool,
isError: PropTypes.bool,
isOpen: PropTypes.bool,
isWaiting: PropTypes.bool,
onReport: PropTypes.func,
onRequestClose: PropTypes.func,
report: PropTypes.shape({
category: PropTypes.string,
notes: PropTypes.string,
open: PropTypes.bool,
waiting: PropTypes.bool
}),
type: PropTypes.string
};
module.exports = injectIntl(ReportModal);
const mapStateToProps = state => ({
isConfirmed: state.preview.status.report === previewActions.Status.FETCHED,
isError: state.preview.status.report === previewActions.Status.ERROR,
isWaiting: state.preview.status.report === previewActions.Status.FETCHING
});
const mapDispatchToProps = () => ({});
const ConnectedReportModal = connect(
mapStateToProps,
mapDispatchToProps
)(ReportModal);
module.exports = injectIntl(ConnectedReportModal);

View file

@ -9,7 +9,7 @@
margin: 100px auto;
outline: none;
padding: 0;
width: 30rem;
width: 36.25rem; /* 580px; */
user-select: none;
}
@ -34,26 +34,45 @@
.report-modal-content {
margin: 1rem auto;
width: 80%;
line-height: 1.5rem;
font-size: .875rem;
.instructions {
line-height: 1.5rem;
}
.received {
margin: 0 auto;
width: 90%;
text-align: center;
line-height: 1.65rem;
.received-header {
font-weight: bold;
}
}
.error-text {
margin-top: .9375rem;
}
.validation-message {
$arrow-border-width: 1rem;
display: block;
position: absolute;
top: 0;
left: 0;
transform: translate(23.5rem, 0);
left: 100%; /* position to the right of parent */
margin-left: $arrow-border-width;
border: 1px solid $active-gray;
border-radius: 5px;
background-color: $ui-orange;
padding: 1rem;
min-width: 12rem;
max-width: 18.75rem;
min-height: 1rem;
overflow: visible;
color: $type-white;
/* arrow on box that points to the left */
&:before {
display: block;
position: absolute;
@ -78,3 +97,13 @@
.report-modal-field {
position: relative;
}
.form-group.has-error {
.textarea, select {
border: 1px solid $ui-orange;
}
}
.report-text .textarea {
margin-bottom: 0;
}

View file

@ -172,6 +172,7 @@
"registration.welcomeStepTitle": "Hurray! Welcome to Scratch!",
"thumbnail.by": "by",
"report.error": "Something went wrong when trying to send your message. Please try again.",
"report.project": "Report Project",
"report.projectInstructions": "From the dropdown below, please select the reason why you feel this project is disrespectful or inappropriate or otherwise breaks the {CommunityGuidelinesLink}.",
"report.CommunityGuidelinesLinkText": "Scratch Community Guidelines",
@ -181,8 +182,11 @@
"report.reasonScary": "Too Violent or Scary",
"report.reasonLanguage": "Inappropriate Language",
"report.reasonMusic": "Inappropriate Music",
"report.reasonMissing": "Please select a reason",
"report.reasonImage": "Inappropriate Images",
"report.reasonPersonal": "Sharing Personal Contact Information",
"report.receivedHeader": "We have received your report!",
"report.receivedBody": "The Scratch Team will review the project based on the Scratch community guidelines.",
"report.promptPlaceholder": "Select a reason why above.",
"report.promptCopy": "Please provide a link to the original project",
"report.promptUncredited": "Please provide links to the uncredited content",
@ -194,5 +198,7 @@
"report.promptImage": "Please say the name of the sprite or the backdrop with the inappropriate image",
"report.tooLongError": "That's too long! Please find a way to shorten your text.",
"report.tooShortError": "That's too short. Please describe in detail what's inappropriate or disrespectful about the project.",
"report.send": "Send"
"report.send": "Send",
"report.sending": "Sending...",
"report.textMissing": "Please tell us why you are reporting this project"
}

View file

@ -1,3 +1,4 @@
const defaults = require('lodash.defaults');
const keyMirror = require('keymirror');
const async = require('async');
const merge = require('lodash.merge');
@ -21,6 +22,7 @@ module.exports.getInitialState = () => ({
original: module.exports.Status.NOT_FETCHED,
parent: module.exports.Status.NOT_FETCHED,
remixes: module.exports.Status.NOT_FETCHED,
report: module.exports.Status.NOT_FETCHED,
projectStudios: module.exports.Status.NOT_FETCHED,
curatedStudios: module.exports.Status.NOT_FETCHED,
studioRequests: {}
@ -324,6 +326,7 @@ module.exports.getReplies = (projectId, commentIds) => (dispatch => {
});
module.exports.setFavedStatus = (faved, id, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('faved', module.exports.Status.FETCHING));
if (faved) {
api({
uri: `/projects/${id}/favorites/user/${username}`,
@ -383,6 +386,7 @@ module.exports.getLovedStatus = (id, username, token) => (dispatch => {
});
module.exports.setLovedStatus = (loved, id, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('loved', module.exports.Status.FETCHING));
if (loved) {
api({
uri: `/projects/${id}/loves/user/${username}`,
@ -531,6 +535,7 @@ module.exports.leaveStudio = (studioId, projectId, token) => (dispatch => {
});
module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
dispatch(module.exports.setFetchStatus('project', module.exports.Status.FETCHING));
api({
uri: `/projects/${id}`,
authentication: token,
@ -556,3 +561,27 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => {
dispatch(module.exports.setProjectInfo(body));
});
});
module.exports.reportProject = (id, jsonData) => (dispatch => {
dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHING));
// scratchr2 will fail if no thumbnail base64 string provided. We don't yet have
// a way to get the actual project thumbnail in www/gui, so for now just submit
// a minimal base64 png string.
defaults(jsonData, {
thumbnail: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC' +
'0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII='
});
api({
host: '',
uri: `/site-api/projects/all/${id}/report/`,
method: 'POST',
json: jsonData,
useCsrf: true
}, (err, body, res) => {
if (err || res.statusCode !== 200) {
dispatch(module.exports.setFetchStatus('report', module.exports.Status.ERROR));
return;
}
dispatch(module.exports.setFetchStatus('report', module.exports.Status.FETCHED));
});
});

View file

@ -43,7 +43,7 @@ const PreviewPresentation = ({
projectId,
projectInfo,
remixes,
report,
reportOpen,
replies,
addToStudioOpen,
projectStudios,
@ -278,8 +278,8 @@ const PreviewPresentation = ({
Report
</Button>,
<ReportModal
isOpen={reportOpen}
key="report-modal"
report={report}
type="project"
onReport={onReportSubmit}
onRequestClose={onReportClose}
@ -377,12 +377,7 @@ PreviewPresentation.propTypes = {
projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object),
replies: PropTypes.objectOf(PropTypes.array),
report: PropTypes.shape({
category: PropTypes.string,
notes: PropTypes.string,
open: PropTypes.bool,
waiting: PropTypes.bool
}),
reportOpen: PropTypes.bool,
studios: PropTypes.arrayOf(PropTypes.object),
userOwnsProject: PropTypes.bool
};

View file

@ -57,12 +57,7 @@ class Preview extends React.Component {
loveCount: 0,
projectId: parts[1] === 'editor' ? 0 : parts[1],
addToStudioOpen: false,
report: {
category: '',
notes: '',
open: false,
waiting: false
}
reportOpen: false
};
this.getExtensions(this.state.projectId);
this.addEventListeners();
@ -87,7 +82,6 @@ class Preview extends React.Component {
this.props.getRemixes(this.state.projectId);
this.props.getProjectStudios(this.state.projectId);
}
}
if (this.props.projectInfo.id !== prevProps.projectInfo.id) {
this.getExtensions(this.state.projectId);
@ -118,7 +112,7 @@ class Preview extends React.Component {
getExtensions (projectId) {
storage
.load(storage.AssetType.Project, projectId, storage.DataFormat.JSON)
.then(projectAsset => {
.then(projectAsset => { // NOTE: this is turning up null, breaking the line below.
let input = projectAsset.data;
if (typeof input === 'object' && !(input instanceof ArrayBuffer) &&
!ArrayBuffer.isView(input)) { // taken from scratch-vm
@ -148,10 +142,10 @@ class Preview extends React.Component {
});
}
handleReportClick () {
this.setState({report: {...this.state.report, open: true}});
this.setState({reportOpen: true});
}
handleReportClose () {
this.setState({report: {...this.state.report, open: false}});
this.setState({reportOpen: false});
}
handleAddToStudioClick () {
this.setState({addToStudioOpen: true});
@ -159,27 +153,8 @@ class Preview extends React.Component {
handleAddToStudioClose () {
this.setState({addToStudioOpen: false});
}
// NOTE: this is a copy, change it
handleReportSubmit (formData) {
this.setState({report: {
category: formData.report_category,
notes: formData.notes,
open: this.state.report.open,
waiting: true}
});
const data = {
...formData,
id: this.state.projectId,
user: this.props.user.username
};
console.log('submit report data', data); // eslint-disable-line no-console
this.setState({report: {
category: '',
notes: '',
open: false,
waiting: false}
});
this.props.reportProject(this.state.projectId, formData);
}
handlePopState () {
const path = window.location.pathname.toLowerCase();
@ -335,7 +310,7 @@ class Preview extends React.Component {
projectStudios={this.props.projectStudios}
remixes={this.props.remixes}
replies={this.props.replies}
report={this.state.report}
reportOpen={this.state.reportOpen}
studios={this.props.studios}
user={this.props.user}
userOwnsProject={this.userOwnsProject()}
@ -383,6 +358,7 @@ Preview.propTypes = {
projectStudios: PropTypes.arrayOf(PropTypes.object),
remixes: PropTypes.arrayOf(PropTypes.object),
replies: PropTypes.objectOf(PropTypes.array),
reportProject: PropTypes.func,
sessionStatus: PropTypes.string,
setFavedStatus: PropTypes.func.isRequired,
setFullScreen: PropTypes.func.isRequired,
@ -461,7 +437,6 @@ const mapStateToProps = state => ({
fullScreen: state.scratchGui.mode.isFullScreen
});
const mapDispatchToProps = dispatch => ({
getOriginalInfo: id => {
dispatch(previewActions.getOriginalInfo(id));
@ -506,6 +481,9 @@ const mapDispatchToProps = dispatch => ({
refreshSession: () => {
dispatch(sessionActions.refreshSession());
},
reportProject: (id, formData) => {
dispatch(previewActions.reportProject(id, formData));
},
setOriginalInfo: info => {
dispatch(previewActions.setOriginalInfo(info));
},