diff --git a/src/components/forms/validations.jsx b/src/components/forms/validations.jsx index c83ce63cb..c882aa40d 100644 --- a/src/components/forms/validations.jsx +++ b/src/components/forms/validations.jsx @@ -28,8 +28,8 @@ module.exports.validationHOCFactory = defaultValidationErrors => (Component => { <Component validationErrors={defaults( {}, - defaultValidationErrors, - props.validationErrors + props.validationErrors, + defaultValidationErrors )} {...omit(props, ['validationErrors'])} /> diff --git a/src/components/modal/addtostudio/modal.scss b/src/components/modal/addtostudio/modal.scss index 7503271c6..08b4c1abc 100644 --- a/src/components/modal/addtostudio/modal.scss +++ b/src/components/modal/addtostudio/modal.scss @@ -35,7 +35,6 @@ .addToStudio-modal-content { margin: 0 auto; width: 100%; - line-height: 1.5rem; font-size: .875rem; } diff --git a/src/components/modal/base/modal.scss b/src/components/modal/base/modal.scss index 1a6e46262..cb878ae12 100644 --- a/src/components/modal/base/modal.scss +++ b/src/components/modal/base/modal.scss @@ -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; +} diff --git a/src/components/modal/report/modal.jsx b/src/components/modal/report/modal.jsx index 7b78379a0..7271458fd 100644 --- a/src/components/modal/report/modal.jsx +++ b/src/components/modal/report/modal.jsx @@ -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); diff --git a/src/components/modal/report/modal.scss b/src/components/modal/report/modal.scss index 6a9fe3abf..375ac6227 100644 --- a/src/components/modal/report/modal.scss +++ b/src/components/modal/report/modal.scss @@ -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; +} diff --git a/src/l10n.json b/src/l10n.json index 2c59f68de..a399ad6b2 100644 --- a/src/l10n.json +++ b/src/l10n.json @@ -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" } diff --git a/src/redux/preview.js b/src/redux/preview.js index 6ab017bf8..109635a12 100644 --- a/src/redux/preview.js +++ b/src/redux/preview.js @@ -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)); + }); +}); diff --git a/src/views/preview/presentation.jsx b/src/views/preview/presentation.jsx index b204eb831..a62aa663f 100644 --- a/src/views/preview/presentation.jsx +++ b/src/views/preview/presentation.jsx @@ -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 }; diff --git a/src/views/preview/preview.jsx b/src/views/preview/preview.jsx index 128e30854..37f20e7b3 100644 --- a/src/views/preview/preview.jsx +++ b/src/views/preview/preview.jsx @@ -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)); },