diff --git a/package.json b/package.json index d8017df7..b3e57e1f 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "license": "AGPL-3.0", "dependencies": { "@edx/edx-bootstrap": "^0.4.0", - "@edx/paragon": "^1.3.0", + "@edx/paragon": "^1.4.5", "babel-polyfill": "^6.26.0", "classnames": "^2.2.5", "copy-to-clipboard": "^3.0.8", @@ -44,16 +44,17 @@ "enzyme-adapter-react-16": "^1.0.4", "eslint-config-edx": "4.0.0", "extract-text-webpack-plugin": "^3.0.0", + "fetch-mock": "^5.13.1", "file-loader": "^1.1.4", "html-webpack-harddisk-plugin": "^0.1.0", "html-webpack-plugin": "^2.30.1", "husky": "^0.14.3", "identity-obj-proxy": "^3.0.0", "jest": "^21.2.1", - "fetch-mock": "^5.13.1", "node-sass": "^4.5.3", "react-dev-utils": "^4.0.0", "react-test-renderer": "^16.1.0", + "redux-mock-store": "^1.3.0", "sass-loader": "^6.0.6", "style-loader": "^0.18.2", "webpack": "^3.5.5", @@ -61,7 +62,10 @@ "webpack-merge": "^4.1.0" }, "jest": { - "setupFiles": ["./shim.js", "./setupTests.js"], + "setupFiles": [ + "./shim.js", + "./setupTests.js" + ], "moduleNameMapper": { "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/__mocks__/fileMock.js", "\\.(css|scss)$": "identity-obj-proxy" diff --git a/public/index.html b/public/index.html index 3b82b839..65183f33 100644 --- a/public/index.html +++ b/public/index.html @@ -22,13 +22,6 @@ revision: "", base_url: "http://localhost:18010", }, - zendesk: { - zendeskTags: "", - customFields: { - course_id: "course-v1:edX+DemoX+Demo_Course", - }, - accessToken: "accessToken", - }, };
diff --git a/src/SFE.scss b/src/SFE.scss index 53e86312..fda083f8 100644 --- a/src/SFE.scss +++ b/src/SFE.scss @@ -3,7 +3,7 @@ /* The page-header class exists in bootstrap 3.3 but not in our alpha version currently */ .page-header { - padding-bottom: $spacer * 0.5; + padding-bottom: $spacer * 0.5; margin: ($spacer * 2) 0 $spacer; border-bottom: 1px solid #eee; } diff --git a/src/accessibilityIndex.jsx b/src/accessibilityIndex.jsx index 868c57d7..52d3af78 100644 --- a/src/accessibilityIndex.jsx +++ b/src/accessibilityIndex.jsx @@ -3,13 +3,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Provider } from 'react-redux'; -import WrappedAccessibilityPolicyPage from './components/AccessibilityPolicyPage'; +import AccessibilityPolicyPage from './components/AccessibilityPolicyPage'; import store from './data/store'; const AccessibilityApp = () => (
- +
); diff --git a/src/components/AccessibilityPolicyForm/AccessibilityPolicyForm.scss b/src/components/AccessibilityPolicyForm/AccessibilityPolicyForm.scss new file mode 100644 index 00000000..3bfb663f --- /dev/null +++ b/src/components/AccessibilityPolicyForm/AccessibilityPolicyForm.scss @@ -0,0 +1,20 @@ +@import '../../SFE.scss'; + +.list { + margin-left: $spacer * 2; +} + +.form-section { + padding: 0 $spacer $spacer; + font-size: $font-size-base; + + // set label and validation text to be larger in Studio + div, label { + font-size: $font-size-base; + } +} + +.bullet-list { + @extend .list; + list-style-type: disc; +} diff --git a/src/components/AccessibilityPolicyForm/AccessibilityPolicyForm.test.jsx b/src/components/AccessibilityPolicyForm/AccessibilityPolicyForm.test.jsx new file mode 100644 index 00000000..ca7d0e38 --- /dev/null +++ b/src/components/AccessibilityPolicyForm/AccessibilityPolicyForm.test.jsx @@ -0,0 +1,183 @@ +import React from 'react'; +import Enzyme from 'enzyme'; +import { AccessibilityPolicyForm } from './index'; +import { accessibilityActions } from '../../data/constants/actionTypes'; + +const mount = Enzyme.mount; + +const defaultProps = { + accessibilityStatus: {}, + accessibilityEmail: 'accessibilityTest@test.com', + clearAccessibilityStatus: () => {}, + submitAccessibilityForm: () => {}, +}; + +const formInputs = { + email: 'test@test.com', + fullName: 'test name', + message: 'feedback message', +}; + +const validationMessages = { + email: 'Valid email required.', + fullName: 'Full name required.', + message: 'Message required.', +}; + +const clearStatus = (wrapper) => { + wrapper.setProps({ accessibilityStatus: {} }); +}; + +const getMockForZendeskSuccess = (wrapper) => { + wrapper.setProps({ + accessibilityStatus: { + type: accessibilityActions.ACCESSIBILITY_FORM_SUBMIT_SUCCESS, + }, + }); +}; + +const getMockForZendeskRateLimit = (wrapper) => { + wrapper.setProps({ + accessibilityStatus: { + type: accessibilityActions.ACCESSIBILITY_FORM_SUBMIT_RATE_LIMIT_FAILURE, + }, + }); +}; + +let wrapper; + +describe('', () => { + describe('renders', () => { + beforeEach(() => { + wrapper = mount( + , + ); + }); + + it('correct number of form fields', () => { + const formSection = wrapper.find('section'); + expect(formSection.find('input')).toHaveLength(2); + expect(formSection.find('textarea')).toHaveLength(1); + expect(formSection.find('button')).toHaveLength(1); + }); + + it('hides StatusAlert on initial load', () => { + const statusAlert = wrapper.find('StatusAlert'); + expect(statusAlert.find('div').first().prop('hidden')).toEqual(true); + }); + + it('adds validation checking on each input field', () => { + const emailInput = wrapper.find('input#email'); + emailInput.simulate('blur'); + + const emailError = wrapper.find('div#error-email'); + expect(emailError.exists()).toEqual(true); + expect(emailError.text()).toEqual(validationMessages.email); + + const fullNameInput = wrapper.find('input#fullName'); + fullNameInput.simulate('blur'); + + const fullNameError = wrapper.find('div#error-fullName'); + expect(fullNameError.exists()).toEqual(true); + expect(fullNameError.text()).toEqual(validationMessages.fullName); + + const messageInput = wrapper.find('textarea#message'); + messageInput.simulate('blur'); + + const messageError = wrapper.find('div#error-message'); + expect(messageError.exists()).toEqual(true); + expect(messageError.text()).toEqual(validationMessages.message); + }); + + it('shows validation errors when trying to submit with empty fields', () => { + const formSection = wrapper.find('section'); + const submitButton = formSection.find('button'); + submitButton.simulate('click'); + + const statusAlert = wrapper.find('StatusAlert'); + expect(statusAlert.find('div').first().prop('hidden')).toEqual(false); + expect(statusAlert.find('div').first().hasClass('alert-danger')).toEqual(true); + expect(statusAlert.text()).toContain(`Please fill in all required fields.${validationMessages.email}${validationMessages.fullName}${validationMessages.message}`); + }); + + it('shows correct success message', () => { + wrapper.setProps({ + clearAccessibilityStatus: () => clearStatus(wrapper), + submitAccessibilityForm: () => getMockForZendeskSuccess(wrapper), + }); + wrapper.setState({ + submitterEmail: 'email@email.com', + submitterFullName: 'test name', + submitterMessage: 'feedback message', + }); + + const formSection = wrapper.find('section'); + const submitButton = formSection.find('button'); + submitButton.simulate('click'); + + const statusAlert = wrapper.find('StatusAlert'); + const statusAlertType = statusAlert.prop('alertType'); + expect(statusAlertType).toEqual('success'); + expect(statusAlert.find('div').first().hasClass('alert-success')).toEqual(true); + expect(statusAlert.text()).toContain('Thank you for contacting edX!Thank you for your feedback regarding the accessibility of Studio. We typically respond within one business day (Monday to Friday, 13:00 to 21:00 UTC).'); + }); + + it('shows correct rate limiting message', () => { + wrapper.setProps({ + clearAccessibilityStatus: () => clearStatus(wrapper), + submitAccessibilityForm: () => getMockForZendeskRateLimit(wrapper), + }); + wrapper.setState({ + submitterEmail: formInputs.email, + submitterFullName: formInputs.fullName, + submitterMessage: formInputs.message, + }); + + const formSection = wrapper.find('section'); + const submitButton = formSection.find('button'); + submitButton.simulate('click'); + + const statusAlert = wrapper.find('StatusAlert'); + const statusAlertType = statusAlert.prop('alertType'); + expect(statusAlertType).toEqual('danger'); + expect(statusAlert.find('div').first().hasClass('alert-danger')).toEqual(true); + expect(statusAlert.text()).toContain(`We are currently experiencing high volume. Try again later today or send an email message to ${wrapper.props().accessibilityEmail}`); + }); + + it('clears inputs on valid submit', () => { + const formSection = wrapper.find('section'); + const emailInput = wrapper.find('input#email'); + const fullNameInput = wrapper.find('input#fullName'); + const messageInput = wrapper.find('textarea#message'); + + wrapper.setState({ + submitterEmail: formInputs.email, + submitterFullName: formInputs.fullName, + submitterMessage: formInputs.message, + }); + + const submitButton = formSection.find('button'); + submitButton.simulate('click'); + + expect(emailInput.instance().value).toEqual(''); + expect(fullNameInput.instance().value).toEqual(''); + expect(messageInput.instance().value).toEqual(''); + }); + + it('clears accessibilityStatus as expected', () => { + wrapper.setProps({ + accessibilityStatus: { + type: accessibilityActions.ACCESSIBILITY_FORM_SUBMIT_SUCCESS, + }, + clearAccessibilityStatus: () => clearStatus(wrapper), + }); + + const formSection = wrapper.find('section'); + const submitButton = formSection.find('button'); + submitButton.simulate('click'); + expect(wrapper.props().accessibilityStatus).toEqual({}); + }); + }); +}); diff --git a/src/components/AccessibilityPolicyForm/index.jsx b/src/components/AccessibilityPolicyForm/index.jsx new file mode 100644 index 00000000..77e519df --- /dev/null +++ b/src/components/AccessibilityPolicyForm/index.jsx @@ -0,0 +1,298 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Button from '@edx/paragon/src/Button'; +import InputText from '@edx/paragon/src/InputText'; +import StatusAlert from '@edx/paragon/src/StatusAlert'; +import TextArea from '@edx/paragon/src/TextArea'; + +import { clearAccessibilityStatus, submitAccessibilityForm } from '../../data/actions/accessibility'; +import { accessibilityActions } from '../../data/constants/actionTypes'; +import styles from './AccessibilityPolicyForm.scss'; + +export class AccessibilityPolicyForm extends React.Component { + constructor(props) { + super(props); + this.state = { + submitterEmail: '', + submitterFullName: '', + submitterMessage: '', + isStatusAlertOpen: false, + isValidFormContent: true, + validationMessages: [], + }; + + this.statusAlertRef = {}; + this.emailInputRef = {}; + this.fullNameInputRef = {}; + this.messageInputRef = {}; + this.submitButtonRef = {}; + + this.handleEmailChange = this.handleEmailChange.bind(this); + this.handleFullNameChange = this.handleFullNameChange.bind(this); + this.handleMessageChange = this.handleMessageChange.bind(this); + this.validateEmail = this.validateEmail.bind(this); + this.validateFullName = this.validateFullName.bind(this); + this.validateMessage = this.validateMessage.bind(this); + this.closeStatusAlert = this.closeStatusAlert.bind(this); + this.renderStatusAlert = this.renderStatusAlert.bind(this); + } + + onSubmitClick() { + this.props.clearAccessibilityStatus(); + + const { submitterEmail, submitterFullName, submitterMessage } = this.state; + const isValidEmail = this.validateEmail(submitterEmail); + const isValidFullName = this.validateFullName(submitterFullName); + const isValidMessage = this.validateMessage(submitterMessage); + const isValidContent = isValidEmail.isValid && + isValidFullName.isValid && isValidMessage.isValid; + + this.setState({ + isStatusAlertOpen: true, + isValidFormContent: isValidContent, + validationMessages: [ + isValidEmail.validationMessage, + isValidFullName.validationMessage, + isValidMessage.validationMessage, + ], + }); + this.statusAlertRef.focus(); + + if (isValidContent) { + this.clearInputs(); + this.props.submitAccessibilityForm(submitterEmail, submitterFullName, submitterMessage); + } + } + + getStatusFields() { + const { accessibilityStatus, accessibilityEmail } = this.props; + const { isValidFormContent, validationMessages } = this.state; + let status = { + alertType: 'info', + alertDialog: 'Submitting feedback', + }; + + if (!isValidFormContent) { + status = { + alertType: 'danger', + alertDialog: ( +
+
Please fill in all required fields.
+
+
+
    + {validationMessages.map((error, index) => { + let errorMessage = ''; + if (error) { + /* eslint-disable react/no-array-index-key */ + errorMessage =
  • {error}
  • ; + } + return errorMessage; + })} +
+
+
+ ), + }; + } else if (accessibilityStatus.type === + accessibilityActions.ACCESSIBILITY_FORM_SUBMIT_RATE_LIMIT_FAILURE) { + status = { + alertType: 'danger', + alertDialog: ( +
+ We are currently experiencing high volume. Try again later today or + send an email message to {accessibilityEmail} +
+ ), + }; + } else if (accessibilityStatus.type === + accessibilityActions.ACCESSIBILITY_FORM_SUBMIT_SUCCESS) { + status = { + alertType: 'success', + alertDialog: ( +
+ Thank you for contacting edX! +
+
+ Thank you for your feedback regarding the accessibility of Studio. We typically respond + within one business day (Monday to Friday, 13:00 to 21:00 UTC). +
+ ), + }; + } + + return status; + } + + handleEmailChange(value) { + this.setState({ + submitterEmail: value, + }); + } + + handleFullNameChange(value) { + this.setState({ + submitterFullName: value, + }); + } + + handleMessageChange(value) { + this.setState({ + submitterMessage: value, + }); + } + + validateEmail(email) { + let feedback = { isValid: true }; + /* eslint-disable max-len */ + /* eslint-disable no-useless-escape */ + // TODO: Investigate using https://www.npmjs.com/package/isemail instead of regex + const emailRegEx = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + /* eslint-enable max-len */ + if (!emailRegEx.test(email)) { + feedback = { + isValid: false, + validationMessage: 'Valid email required.', + }; + } + return feedback; + } + + validateFullName(fullName) { + let feedback = { isValid: true }; + if (!fullName) { + feedback = { + isValid: false, + validationMessage: 'Full name required.', + }; + } + return feedback; + } + + validateMessage(message) { + let feedback = { isValid: true }; + if (!message) { + feedback = { + isValid: false, + validationMessage: 'Message required.', + }; + } + return feedback; + } + + clearInputs() { + // TODO: Add this functionality back in once Paragon handles clearing correctly + // current Paragon's asInput is overwriting the value field of our inputs + // this.setState({ // current this will clear on every valid submit (success/fail) - correct? + // submitterEmail: '', + // submitterFullName: '', + // submitterMessage: '', + // }); + } + + closeStatusAlert() { + this.emailInputRef.focus(); + this.setState({ + isStatusAlertOpen: false, + }); + } + + renderStatusAlert() { + const status = this.getStatusFields(); + + const statusAlert = ( + { this.statusAlertRef = input; }} + /> + ); + + return ( +
+ {statusAlert} +
+ ); + } + + render() { + return ( +
+

Studio Accessibility Feedback

+ {this.renderStatusAlert()} +
+

All fields required.

+ this.validateEmail(email)} + inputRef={(ref) => { this.emailInputRef = ref; }} + className={['something']} + /> + this.validateFullName(fullName)} + inputRef={(ref) => { this.fullNameInputRef = ref; }} + /> +