├── .babelrc ├── .gitignore ├── README.md ├── error-handling.txt ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── actions │ ├── answerOptionActions.js │ ├── authActions.js │ ├── questionActions.js │ └── surveyActions │ │ ├── surveyActions.js │ │ └── surveyActions.test.js ├── components │ ├── Header │ │ ├── Header.js │ │ ├── Header.test.js │ │ └── Navigation.js │ ├── ResponsesPage │ │ ├── AnswerChartRenderer │ │ │ ├── AnswerBarChart.js │ │ │ ├── AnswerChartRenderer.js │ │ │ ├── AnswerChartRenderer.test.js │ │ │ └── AnswerDoughnutChart.js │ │ ├── AnswerList │ │ │ ├── AnswerList.js │ │ │ └── AnswerList.test.js │ │ ├── QuestionResponseList │ │ │ ├── QuestionResponseList.js │ │ │ └── QuestionResponseList.test.js │ │ ├── ResponsesPage.js │ │ ├── ResponsesPage.test.js │ │ └── ResponsesPageQuestionList │ │ │ ├── ResponsesPageQuestionList.js │ │ │ └── ResponsesPageQuestionList.test.js │ ├── Root.js │ ├── SurveyBuilderPage │ │ ├── AnswerOptionListBuilder │ │ │ ├── AnswerOptionListBuilder.js │ │ │ └── AnswerOptionListBuilder.test.js │ │ ├── QuestionBuilder │ │ │ ├── QuestionBuilder.js │ │ │ └── QuestionBuilder.test.js │ │ ├── QuestionTypeSelector │ │ │ ├── QuestionTypeSelector.js │ │ │ └── QuestionTypeSelector.test.js │ │ ├── SurveyBuilderPage.js │ │ ├── SurveyBuilderPage.test.js │ │ └── SurveyBuilderQuestionList │ │ │ ├── SurveyBuilderQuestionLIst.test.js │ │ │ └── SurveyBuilderQuestionList.js │ ├── SurveyPage │ │ ├── SurveyAnswerOptionList │ │ │ ├── SurveyAnswerOptionList.js │ │ │ └── SurveyAnswerOptionList.test.js │ │ ├── SurveyPage.js │ │ ├── SurveyPage.test.js │ │ ├── SurveyQuestion │ │ │ ├── SurveyQuestion.js │ │ │ └── SurveyQuestion.test.js │ │ └── SurveyQuestionList │ │ │ ├── SurveyQuestionList.js │ │ │ └── SurveyQuestionList.test.js │ ├── auth │ │ ├── RequireAuth.js │ │ ├── SignInPage.js │ │ ├── SignOutPage.js │ │ └── SignUpPage.js │ └── shared │ │ ├── DefaultSpinner.js │ │ ├── ErrorMessage.js │ │ └── FormInput.js ├── config │ └── appConfig.js ├── configureStore.js ├── constants │ ├── actionTypes.js │ ├── chartTypes.js │ ├── customPropTypes.js │ └── questionTypes.js ├── history.js ├── index.js ├── models │ └── schema.js ├── reducers │ ├── answerOptionsReducer.js │ ├── answersReducer.js │ ├── authReducer.js │ ├── index.js │ ├── questionsReducer.js │ └── surveysReducer │ │ ├── surveysReducer.js │ │ └── surveysReducer.test.js ├── selectors │ ├── getAnswerOptions.js │ ├── getDenormalizedSurvey.js │ └── getInitialFormBuilderValues.js ├── setupTests.js ├── styles │ ├── core │ │ ├── reset.scss │ │ └── variables.scss │ ├── layouts │ │ └── layout.scss │ ├── modules │ │ ├── buttons.scss │ │ ├── error-message.scss │ │ ├── form.scss │ │ ├── navigation.scss │ │ ├── spinner.scss │ │ └── typography.scss │ └── pages │ │ ├── App.scss │ │ ├── LoginForm.scss │ │ ├── SurveyBuilder.scss │ │ ├── SurveyForm.scss │ │ └── SurveyResponses.scss └── utils │ ├── chartUtils.js │ └── formDataUtils.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "react-app" 4 | ], 5 | "plugins": [ 6 | "transform-class-properties", 7 | "transform-es2015-modules-commonjs" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | 23 | src/**/*.css 24 | 25 | .idea -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Survey app frontend 2 | 3 | Survey app is a simplified alternative to Google Forms written with [React.js](http://facebook.github.io/react/index.html). 4 | 5 | Related to: [survey-backend](https://github.com/RokasLeng/survey-app-backend) 6 | 7 | ## Demo 8 | 9 | Live demo: [survey-app.lengvenis.me](http://survey-app.lengvenis.me) 10 | 11 | ## Scripts 12 | 13 | Webpack configuration is based on [create-react-app](https://github.com/facebookincubator/create-react-app) boilerplate. 14 | 15 | Install dependencies using yarn or npm manager: 16 | 17 | ```sh 18 | yarn install 19 | ``` 20 | 21 | Start development version: 22 | 23 | ```sh 24 | yarn start 25 | ``` 26 | 27 | For building a production version use: 28 | 29 | ```sh 30 | yarn build 31 | ``` 32 | 33 | >Note: [survey-backend](https://github.com/RokasLeng/survey-app-backend) instance has to be running on `localhost:3001` for application to work. 34 | 35 | ## Design notes 36 | 37 | ### Folder structure ### 38 | 39 | Due to a specific domain, reducers and actions are reused on different pages, therefore they are put to `src/reducers` and `src/actions` folder appropriately. In general domain/feature/page oriented component file structure is used: 40 | 41 | * `actions` - actions and their tests 42 | * `components` - organized by page and tests 43 | * `config` - cofiguration files 44 | * `constants` - action types and other constants 45 | * `models` - normalizr models 46 | * `reducers` - reducers and their tests 47 | * `selectors` - selectors used to select domain objects from normalized data 48 | * `styles` - css styles written using BEM and SCSS 49 | * `utils` - global utils 50 | 51 | More reasoning on folder structure: [The 100% correct way to structure a React app (or why there’s no such thing)](https://hackernoon.com/the-100-correct-way-to-structure-a-react-app-or-why-theres-no-such-thing-3ede534ef1ed) 52 | 53 | 54 | ### CSS design ### 55 | 56 | BEM methodology was used for writting CSS styles. CSS styles are located under `src/styles` folder. General structure is: 57 | 58 | * `core` - css variables and reset 59 | * `layouts` - reusable container layouts 60 | * `modules` - reusable modules 61 | * `pages` - page dependant styling 62 | 63 | Plain BEM was used due to the fact that BEM methodology itself solves global namespacing and other issues. Styled-components is an option I will consider using in future app versions. 64 | 65 | More reasoning about CSS usage: [Stop using CSS in JavaScript for web development](https://medium.com/@gajus/stop-using-css-in-javascript-for-web-development-fa32fb873dcc) 66 | 67 | 68 | ### Unit tests ### 69 | 70 | `src/setupTests.js` holds project test env configuration. Technologies used: 71 | 72 | * [enzyme](https://github.com/airbnb/enzyme) - a wrapper for React test utils 73 | * [chai](https://github.com/chaijs/chai) - assertion library 74 | * [mocha](https://github.com/mochajs/mocha) - a test framework 75 | * [jsdom](https://github.com/tmpvar/jsdom) - a javascript DOM implementation 76 | 77 | Component functionality was tested with focus to regression vulnerable logic. One action creator and one reducer where tested for example purposes. 78 | 79 | >Note: I have used `Mocha` instead of create-react-app native `Jest` due to `Jest` issues while using `jsdom` and rendering canvas related elements to DOM. 80 | 81 | ## Features log 82 | | Feature | Status | References | 83 | |:---|:---|:---| 84 | | JWT authentification| DONE | [jwt.io](https://jwt.io/) | 85 | | BEM and SCSS | DONE | [getbem.com](http://getbem.com/) | 86 | | Redux-form and validation | DONE | [github.com/erikras/redux-form](https://github.com/erikras/redux-form) | 87 | | Data normalization | DONE | [github.com/paularmstrong/normalizr](https://github.com/paularmstrong/normalizr) | 88 | | Fetch for API calls | DONE | [github.com/github/fetch](https://github.com/github/fetch) | 89 | | Async/await presets | DONE | [MDN Async Functions](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function) | 90 | | Router v4 | DONE | [github.com/ReactTraining/react-router](https://github.com/ReactTraining/react-router)| 91 | | Redux selectors | DONE | [Computing Derived Data](https://redux.js.org/docs/recipes/ComputingDerivedData.html) | 92 | | Mobile version | DONE || 93 | | Unit tests | DONE || 94 | | Error handling | DONE || 95 | | Offline usage and workers |||| 96 | | RxJS and Observable |||| 97 | | Docker container |||| -------------------------------------------------------------------------------- /error-handling.txt: -------------------------------------------------------------------------------- 1 | SEVER SIDE ERRORS 2 | Bad pwd: 3 | 422 => {error: 'Email is in use'} 4 | 5 | No pwd: 6 | 422 => {error: 'You must provide email and password'} 7 | 8 | Error happened: 9 | 500 => html template with error 10 | 11 | No rights: 12 | 401 => unouthorized 13 | 14 | CLIENT SIDE ERRORS 15 | If 500 16 | payload: status.code 17 | 18 | If 400 19 | payload: email or pwd is incorrect 20 | 21 | If 422 22 | payload: error -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "questionnaire-builder", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "babel-plugin-transform-class-properties": "^6.24.1", 7 | "babel-preset-es2017": "^6.24.1", 8 | "babel-preset-react-app": "^3.1.0", 9 | "chart.js": "^2.7.1", 10 | "classnames": "^2.2.5", 11 | "immutability-helper": "^2.4.0", 12 | "material-ui": "^0.19.0", 13 | "node-sass-chokidar": "^0.0.3", 14 | "normalizr": "^3.2.4", 15 | "npm-run-all": "^4.1.1", 16 | "prop-types": "^15.6.0", 17 | "query-string": "^5.0.1", 18 | "randomcolor": "^0.5.3", 19 | "react": "^16.1.1", 20 | "react-chartjs-2": "^2.6.4", 21 | "react-dom": "^16.1.1", 22 | "react-redux": "^5.0.6", 23 | "react-router-dom": "^4.2.2", 24 | "react-router-redux": "next", 25 | "react-scripts": "^1.0.17", 26 | "react-spinner-material": "^1.0.16", 27 | "redux": "^3.7.2", 28 | "redux-form": "^7.1.2", 29 | "redux-form-validators": "^2.1.0", 30 | "redux-thunk": "^2.2.0", 31 | "uuid": "^3.1.0" 32 | }, 33 | "scripts": { 34 | "build-css": "node-sass-chokidar src/ -o src/", 35 | "watch-css": "npm run build-css && node-sass-chokidar src/ -o src/ --watch --recursive", 36 | "start-js": "react-scripts start", 37 | "start": "npm-run-all -p watch-css start-js", 38 | "build": "npm run build-css && react-scripts build", 39 | "test": "NODE_ENV=development mocha --watch --require babel-core/register src/setupTests.js 'src/**/*.test.js'", 40 | "eject": "react-scripts eject" 41 | }, 42 | "devDependencies": { 43 | "canvas-prebuilt": "^1.6.5-prerelease.1", 44 | "chai": "^4.1.2", 45 | "chai-exclude": "^1.0.3", 46 | "enzyme": "^3.2.0", 47 | "enzyme-adapter-react-16": "^1.1.0", 48 | "fetch-mock": "^5.13.1", 49 | "jsdom": "^11.5.1", 50 | "mocha": "^4.0.1", 51 | "react-test-renderer": "^16.2.0", 52 | "redux-mock-store": "^1.4.0", 53 | "sinon": "^4.1.3" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rlengvenis/survey-app-frontend/99f7fa62c3b81eb6a63c36e4827a1440e616d0bd/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | 23 | 24 | 25 | 26 | Survey App - Simplified alternative to google forms 27 | 28 | 29 | 30 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/actions/answerOptionActions.js: -------------------------------------------------------------------------------- 1 | import {v4} from 'uuid'; 2 | 3 | import * as actionTypes from '../constants/actionTypes'; 4 | 5 | export const addNewAnswerOption = ({questionId}) => ({ 6 | type: actionTypes.ANSWER_OPTION_ADD_NEW, 7 | payload: { 8 | answerOptionId: v4(), 9 | title: '', 10 | questionId 11 | } 12 | }); 13 | 14 | -------------------------------------------------------------------------------- /src/actions/authActions.js: -------------------------------------------------------------------------------- 1 | import history from '../history'; 2 | 3 | import * as actionTypes from '../constants/actionTypes'; 4 | import {API_URL} from '../config/appConfig'; 5 | 6 | const authTypes = { 7 | SIGN_IN: 'signIn', 8 | SIGN_UP: 'signUp' 9 | }; 10 | 11 | export const signInUser = ({email, password}) => (dispatch) => { 12 | _authenticateUser(dispatch, {authType: authTypes.SIGN_IN, email, password}); 13 | }; 14 | 15 | export const signUpUser = ({email, password}) => (dispatch) => { 16 | _authenticateUser(dispatch, {authType: authTypes.SIGN_UP, email, password}); 17 | }; 18 | 19 | export const signOutUser = () => (dispatch) => { 20 | localStorage.removeItem('token'); 21 | 22 | dispatch({ 23 | type: actionTypes.AUTH_SIGN_OUT_SUCCESS 24 | }); 25 | }; 26 | 27 | export const clearErrors = () => ({ 28 | type: actionTypes.AUTH_CLEAR_ERRORS 29 | }); 30 | 31 | const _authenticateUser = async (dispatch, {authType, email, password}) => { 32 | const API_ENDPOINT = authType === authTypes.SIGN_IN 33 | ? `${API_URL}/api/signin` 34 | : `${API_URL}/api/signup`; 35 | 36 | try { 37 | const response = await fetch(API_ENDPOINT, { 38 | method: 'POST', 39 | headers: { 40 | 'Content-Type': 'application/json' 41 | }, 42 | body: JSON.stringify({ 43 | email, password 44 | }) 45 | }); 46 | 47 | if (response.status === 401) { 48 | throw Error('Email or password is invalid'); 49 | } 50 | 51 | if (String(response.status).startsWith(5)) { 52 | throw Error('Server error occurred'); 53 | } 54 | 55 | const data = await response.json(); 56 | 57 | localStorage.setItem('token', data.token); 58 | 59 | dispatch({ 60 | type: actionTypes.AUTH_SIGN_IN_SUCCESS 61 | }); 62 | 63 | history.push('/builder'); 64 | 65 | } catch (err) { 66 | dispatch({ 67 | type: actionTypes.AUTH_SIGN_IN_ERROR, 68 | payload: err.message 69 | }); 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /src/actions/questionActions.js: -------------------------------------------------------------------------------- 1 | import {v4} from 'uuid'; 2 | 3 | import questionTypes from '../constants/questionTypes'; 4 | import * as actionTypes from '../constants/actionTypes'; 5 | import {addNewAnswerOption} from './answerOptionActions'; 6 | 7 | export const addNewQuestion = ({surveyId}) => { 8 | return { 9 | type: actionTypes.QUESTION_ADD_NEW, 10 | payload: { 11 | surveyId, 12 | question: { 13 | _id: v4(), 14 | title: '', 15 | type: questionTypes.SHORT_ANSWER, 16 | answerOptions: [] 17 | } 18 | } 19 | }; 20 | }; 21 | 22 | export const changeQuestionType = ({type, questionId}) => (dispatch) => { 23 | dispatch({ 24 | type: actionTypes.QUESTION_CHANGE_TYPE, 25 | payload: {type, questionId} 26 | }); 27 | 28 | if (type === questionTypes.MULTIPLE_ANSWER) { 29 | dispatch(addNewAnswerOption({questionId})); 30 | } 31 | }; 32 | 33 | export const deleteQuestion = ({questionId, surveyId}) => ({ 34 | type: actionTypes.QUESTION_DELETE, 35 | payload: { 36 | questionId, 37 | surveyId 38 | } 39 | }); 40 | 41 | -------------------------------------------------------------------------------- /src/actions/surveyActions/surveyActions.js: -------------------------------------------------------------------------------- 1 | import {v4} from 'uuid'; 2 | import {normalize} from 'normalizr'; 3 | 4 | import history from '../../history'; 5 | import {API_URL} from '../../config/appConfig'; 6 | 7 | import getDenormalizedSurvey from '../../selectors/getDenormalizedSurvey'; 8 | import survey from '../../models/schema'; 9 | 10 | import * as actionTypes from '../../constants/actionTypes'; 11 | 12 | 13 | export const loadSurvey = () => (dispatch) => { 14 | return _loadSurvey(dispatch, '/api/survey'); 15 | }; 16 | 17 | export const loadSurveyById = ({surveyId}) => (dispatch) => { 18 | return _loadSurvey(dispatch, `/api/surveyById/${surveyId}`) 19 | }; 20 | 21 | export const saveSurvey = ({surveyFormData}) => async (dispatch, getState) => { 22 | dispatch({ 23 | type: actionTypes.SURVEY_BIND_FORM_DATA, 24 | payload: surveyFormData 25 | }); 26 | 27 | const state = getState(); 28 | const surveyId = Object.keys(state.surveys)[0]; // Current implementation allows only one survey 29 | 30 | await _saveSurvey(getDenormalizedSurvey(state)); 31 | 32 | history.push({ 33 | pathname: '/survey', 34 | search: surveyId && `id=${surveyId}` 35 | }) 36 | }; 37 | 38 | export const resetSurvey = () => ({ 39 | type: actionTypes.SURVEY_RESET 40 | }); 41 | 42 | export const saveSurveyAnswers = ({surveyId, surveyFormData}) => async (dispatch) => { 43 | const answers = Object.keys(surveyFormData).map((key) => { 44 | return { 45 | _id: v4(), 46 | answerText: surveyFormData[key], 47 | questionId: key 48 | }; 49 | }); 50 | 51 | await fetch(`${API_URL}/api/answers`, { 52 | method: 'PUT', 53 | headers: { 54 | 'Content-Type': 'application/json' 55 | }, 56 | body: JSON.stringify({ 57 | surveyId, 58 | answers 59 | }) 60 | }); 61 | }; 62 | 63 | const _saveSurvey = (survey) => { 64 | return fetch(`${API_URL}/api/survey`, { 65 | method: 'PUT', 66 | headers: { 67 | 'Content-Type': 'application/json', 68 | 'Authorization': localStorage.getItem('token') 69 | }, 70 | body: JSON.stringify({ 71 | survey 72 | }) 73 | }) 74 | }; 75 | 76 | const _loadSurvey = async (dispatch, urlPath) => { 77 | const response = await fetch(API_URL + urlPath, { 78 | method: 'GET', 79 | headers: { 80 | 'Authorization': localStorage.getItem('token') 81 | } 82 | }); 83 | 84 | if (response.ok) { 85 | const data = await response.json(); 86 | 87 | 88 | 89 | if (data.survey) { 90 | dispatch({ 91 | type: actionTypes.SURVEY_LOAD_SUCCESS, 92 | payload: normalize(data.survey, survey) 93 | }); 94 | 95 | } else { 96 | dispatch({ 97 | type: actionTypes.SURVEY_INIT_NEW, 98 | payload: { 99 | _id: v4(), 100 | name: '', 101 | description: '', 102 | questions: [] 103 | } 104 | }); 105 | } 106 | } 107 | }; 108 | -------------------------------------------------------------------------------- /src/actions/surveyActions/surveyActions.test.js: -------------------------------------------------------------------------------- 1 | import configureMockStore from 'redux-mock-store'; 2 | import thunk from 'redux-thunk'; 3 | import * as surveyActions from './surveyActions'; 4 | import * as types from '../../constants/actionTypes'; 5 | import fetchMock from 'fetch-mock'; 6 | 7 | import {API_URL} from '../../config/appConfig'; 8 | 9 | 10 | const middlewares = [thunk]; 11 | const mockStore = configureMockStore(middlewares); 12 | 13 | global.localStorage = { 14 | getItem: () => { 15 | } 16 | }; 17 | 18 | describe('surveyActions', () => { 19 | describe('loadSurvey', () => { 20 | let store; 21 | 22 | before(() => { 23 | store = mockStore({}); 24 | }); 25 | 26 | it('should call SURVEY_LOAD_SUCCESS action if survey loaded successfully', () => { 27 | fetchMock.getOnce(`${API_URL}/api/survey`, {body: {survey: {}}}); 28 | 29 | const expectedActions = [{ 30 | type: types.SURVEY_LOAD_SUCCESS, 31 | payload: { 32 | entities: { 33 | surveys: { 34 | undefined: {} 35 | } 36 | }, 37 | result: undefined 38 | } 39 | }]; 40 | 41 | return store.dispatch(surveyActions.loadSurvey()) 42 | .then(() => { 43 | expect(store.getActions()).to.eql(expectedActions); 44 | }) 45 | }); 46 | 47 | it('should fetch while loading survey', () => { 48 | fetchMock.getOnce(`${API_URL}/api/survey`, {body: {survey: {}}}); 49 | 50 | return store.dispatch(surveyActions.loadSurvey()) 51 | .then(() => { 52 | expect(fetchMock.done()); 53 | }) 54 | }); 55 | 56 | it('should call SURVEY_INIT_NEW if survey is undefined', () => { 57 | fetchMock.getOnce(`${API_URL}/api/survey`, {body: {survey: undefined}}); 58 | 59 | const expectedAction = { 60 | type: types.SURVEY_INIT_NEW, 61 | payload: { 62 | name: '', 63 | description: '', 64 | questions: [] 65 | } 66 | }; 67 | 68 | return store.dispatch(surveyActions.loadSurvey()) 69 | .then(() => { 70 | const storeActionsCalled = store.getActions(); 71 | 72 | expect(storeActionsCalled).to.have.length(1); 73 | expect(storeActionsCalled[0]).excludingEvery('_id').to.eql(expectedAction); 74 | }) 75 | }); 76 | 77 | afterEach(() => { 78 | fetchMock.restore(); 79 | store.clearActions(); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/components/Header/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | 5 | import Navigation from './Navigation'; 6 | 7 | export class Header extends React.Component { 8 | 9 | constructor(props) { 10 | super(props); 11 | 12 | this.state = { 13 | showNavigationDropdown: false 14 | }; 15 | } 16 | 17 | render() { 18 | const { 19 | surveyId, 20 | authenticated 21 | } = this.props; 22 | 23 | return ( 24 |
25 | 31 |
32 | ); 33 | } 34 | 35 | handleNavigationDropdownToggle = () => { 36 | this.setState({ 37 | showNavigationDropdown: !this.state.showNavigationDropdown 38 | }); 39 | } 40 | } 41 | 42 | Header.propTypes = { 43 | authenticated: PropTypes.bool, 44 | routing: PropTypes.object, 45 | surveyId: PropTypes.string 46 | }; 47 | 48 | const mapStateToProps = (state) => ({ 49 | authenticated: state.auth.authenticated, 50 | surveyId: Object.keys(state.surveys)[0], //Current implementation supports only one survey 51 | routing: state.routing //Otherwise active links are not updated, even though router setState happens 52 | }); 53 | 54 | export default connect(mapStateToProps, null)(Header); -------------------------------------------------------------------------------- /src/components/Header/Header.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {render} from 'enzyme'; 3 | import {Router} from 'react-router-dom'; 4 | 5 | import history from '../../history'; 6 | 7 | import {Header} from './Header'; 8 | 9 | 10 | describe('Header', () => { 11 | it('should render sign in and sign up when not authenticated', () => { 12 | const wrapper = render( 13 | 14 |
15 | 16 | ); 17 | 18 | expect(wrapper.text().includes('Sign up')).to.equal(true); 19 | expect(wrapper.text().includes('Sign in')).to.equal(true); 20 | }); 21 | 22 | it('should render sign out when authenticated', () => { 23 | const wrapper = render( 24 | 25 |
26 | 27 | ); 28 | 29 | expect(wrapper.text().includes('Sign out')).to.equal(true); 30 | }); 31 | }); 32 | 33 | -------------------------------------------------------------------------------- /src/components/Header/Navigation.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames'; 3 | import {NavLink} from 'react-router-dom'; 4 | 5 | 6 | const Navigation = ({ 7 | authenticated, 8 | surveyId, 9 | showNavigationDropdown, 10 | onToggleNavigationDropdown 11 | }) => { 12 | 13 | const navigationStyle = classnames('navigation__list', { 14 | 'navigation__list--visible': showNavigationDropdown 15 | }); 16 | 17 | return ( 18 | 64 | ); 65 | }; 66 | 67 | const renderAuthLinks = (authenticated, onToggleNavigationDropdown) => { 68 | return authenticated ? ( 69 |
  • 70 | 76 | Sign out 77 | 78 |
  • 79 | ) 80 | : [ 81 |
  • 82 | 88 | Sign in 89 | 90 |
  • , 91 |
  • 92 | 98 | Sign up 99 | 100 |
  • 101 | ]; 102 | }; 103 | 104 | export default Navigation; -------------------------------------------------------------------------------- /src/components/ResponsesPage/AnswerChartRenderer/AnswerBarChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Bar} from 'react-chartjs-2'; 3 | 4 | import {populateLabelValues, populateChartData} from '../../../utils/chartUtils'; 5 | import customPropTypes from '../../../constants/customPropTypes'; 6 | 7 | 8 | const AnswerBarChart = ({question}) => { 9 | const data = { 10 | labels: populateLabelValues({ 11 | answerOptions: question.answerOptions 12 | }), 13 | datasets: [{ 14 | label: 'Answers', 15 | backgroundColor: '#3F51B5', 16 | hoverBackgroundColor: '#4285f4', 17 | data: populateChartData({question}) 18 | }] 19 | }; 20 | 21 | return ( 22 |
    23 | 44 |
    45 | ) 46 | }; 47 | 48 | AnswerBarChart.propTypes = { 49 | question: customPropTypes.question.isRequired 50 | }; 51 | 52 | export default AnswerBarChart; 53 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/AnswerChartRenderer/AnswerChartRenderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import chartTypes from '../../../constants/chartTypes'; 5 | import customPropTypes from '../../../constants/customPropTypes'; 6 | 7 | import AnswerBarChart from './AnswerBarChart'; 8 | import AnswerDoughnutChart from './AnswerDoughnutChart'; 9 | 10 | 11 | const AnswerChartRenderer = ({question, chartType}) => { 12 | switch (chartType) { 13 | default: 14 | case chartTypes.BAR_CHART: { 15 | return 16 | } 17 | case chartTypes.DOUGHNUT_CHART: { 18 | return 19 | } 20 | } 21 | }; 22 | 23 | AnswerChartRenderer.propTypes = { 24 | chartType: PropTypes.number.isRequired, 25 | question: customPropTypes.question.isRequired 26 | }; 27 | 28 | 29 | export default AnswerChartRenderer; -------------------------------------------------------------------------------- /src/components/ResponsesPage/AnswerChartRenderer/AnswerChartRenderer.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import chartTypes from '../../../constants/chartTypes'; 5 | import surveyQuestionTypes from '../../../constants/questionTypes'; 6 | 7 | import AnswerChartRenderer from './AnswerChartRenderer'; 8 | 9 | 10 | describe('AnswerChartRenderer', () => { 11 | let question; 12 | 13 | before(() => { 14 | question = { 15 | _id: 'Id_q1', 16 | title: 'Test question 1', 17 | type: surveyQuestionTypes.SHORT_ANSWER 18 | }; 19 | }); 20 | 21 | it('should render AnswerBarChart', () => { 22 | const props = { 23 | question, 24 | chartType: chartTypes.BAR_CHART 25 | }; 26 | 27 | const wrapper = shallow( 28 | 29 | ); 30 | 31 | expect(wrapper.find('AnswerBarChart').exists()); 32 | }); 33 | 34 | it('should render AnswerDoughnutChart', () => { 35 | const props = { 36 | question, 37 | chartType: chartTypes.DOUGHNUT_CHART 38 | }; 39 | 40 | const wrapper = shallow(); 41 | 42 | expect(wrapper.find('AnswerDoughnutChart').exists()); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/AnswerChartRenderer/AnswerDoughnutChart.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Doughnut} from 'react-chartjs-2'; 3 | import randomColor from 'randomcolor'; 4 | 5 | import {populateLabelValues, populateChartData} from '../../../utils/chartUtils'; 6 | import customPropTypes from '../../../constants/customPropTypes'; 7 | 8 | 9 | const AnswerDoughnutChart = ({question}) => { 10 | const data = { 11 | labels: populateLabelValues({ 12 | answerOptions: question.answerOptions 13 | }), 14 | datasets: [{ 15 | data: populateChartData({question}), 16 | backgroundColor: Object.values(question.answerOptions).map(answerOption => randomColor({ 17 | luminosity: 'light', 18 | hue: '#3F51B5' 19 | })) 20 | }] 21 | }; 22 | 23 | return
    24 | 25 |
    26 | }; 27 | 28 | AnswerDoughnutChart.propTypes = { 29 | question: customPropTypes.question.isRequired 30 | }; 31 | 32 | export default AnswerDoughnutChart; 33 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/AnswerList/AnswerList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import customPropTypes from '../../../constants/customPropTypes'; 5 | 6 | 7 | const AnswerList = ({answers}) => { 8 | return ( 9 |
      10 | { 11 | answers.map(answer => { 12 | return ( 13 |
    • 17 | {answer.answerText} 18 |
    • 19 | ) 20 | }) 21 | } 22 |
    23 | ) 24 | }; 25 | 26 | AnswerList.propTypes = { 27 | answers: PropTypes.arrayOf(customPropTypes.answer).isRequired 28 | }; 29 | 30 | export default AnswerList; 31 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/AnswerList/AnswerList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import AnswerList from './AnswerList'; 5 | 6 | 7 | describe('AnswerList', () => { 8 | it('should render empty list if no answers provided', () => { 9 | const answers = []; 10 | 11 | const wrapper = shallow(); 12 | 13 | expect(wrapper.find('li').exists()).to.equal(false); 14 | }); 15 | 16 | it('should render list of answers provided', () => { 17 | const answers = [{ 18 | _id: 'Id_a1', 19 | answerText: 'Option A' 20 | }, { 21 | _id: 'Id_a2', 22 | answerText: 'Option B' 23 | }]; 24 | 25 | const wrapper = shallow(); 26 | const answerTexts = wrapper.find('li').map(node => node.text()); 27 | 28 | expect(answerTexts).to.eql(['Option A', 'Option B']); 29 | }); 30 | 31 | }); -------------------------------------------------------------------------------- /src/components/ResponsesPage/QuestionResponseList/QuestionResponseList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import surveyQuestionTypes from '../../../constants/questionTypes'; 4 | import customPropTypes from '../../../constants/customPropTypes'; 5 | import {CHART_TYPE_CONFIGURED} from '../../../config/appConfig'; 6 | 7 | import AnswerList from '../AnswerList/AnswerList'; 8 | import AnswerChartRenderer from '../AnswerChartRenderer/AnswerChartRenderer'; 9 | 10 | 11 | const QuestionResponseList = ({question}) => { 12 | return ( 13 |
    14 |

    {question.title}

    15 | 16 | { 17 | question.type === surveyQuestionTypes.MULTIPLE_ANSWER 18 | ? ( 19 | 23 | ) 24 | : 25 | } 26 | 27 |
    28 | ) 29 | }; 30 | 31 | QuestionResponseList.propTypes = { 32 | question: customPropTypes.question.isRequired 33 | }; 34 | 35 | export default QuestionResponseList; 36 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/QuestionResponseList/QuestionResponseList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import surveyQuestionTypes from '../../../constants/questionTypes'; 5 | 6 | import QuestionResponseList from './QuestionResponseList'; 7 | 8 | 9 | describe('QuestionResponseList', () => { 10 | it('should render multiple answer question response', () => { 11 | const props = { 12 | question: { 13 | _id: 'Id_q1', 14 | title: 'Test question 1', 15 | type: surveyQuestionTypes.MULTIPLE_ANSWER 16 | } 17 | }; 18 | 19 | const wrapper = shallow(); 20 | 21 | expect(wrapper.find('AnswerChartRenderer').exists()); 22 | }); 23 | 24 | it('should render answer list as question response', () => { 25 | const props = { 26 | question: { 27 | _id: 'Id_q2', 28 | title: 'Test question 2', 29 | type: surveyQuestionTypes.SHORT_ANSWER, 30 | answers: [{ 31 | _id: 'Id_a3', 32 | answerText: 'First answer' 33 | }] 34 | } 35 | }; 36 | 37 | const wrapper = shallow(); 38 | 39 | expect(wrapper.find('AnswerList').exists()); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/ResponsesPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {bindActionCreators} from 'redux'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import * as surveyActions from '../../actions/surveyActions/surveyActions'; 7 | import getDenormalizedSurvey from '../../selectors/getDenormalizedSurvey'; 8 | import customPropTypes from '../../constants/customPropTypes'; 9 | 10 | import DefaultSpinner from '../shared/DefaultSpinner'; 11 | import ResponsesPageQuestionList from './ResponsesPageQuestionList/ResponsesPageQuestionList'; 12 | 13 | 14 | export class ResponsesPage extends React.Component { 15 | componentDidMount() { 16 | this.props.surveyActions.loadSurvey(); 17 | } 18 | 19 | componentWillUnmount() { 20 | this.props.surveyActions.resetSurvey(); 21 | } 22 | 23 | render() { 24 | const {survey} = this.props; 25 | 26 | if (!survey) { 27 | return ; 28 | } 29 | 30 | 31 | if (!survey.questions.length) { 32 | return

    Survey responses not ready.

    ; 33 | } 34 | 35 | return ( 36 |
    37 |

    {survey.name}

    38 |

    {survey.description}

    39 | 40 | 41 |
    42 | ) 43 | } 44 | } 45 | 46 | ResponsesPage.propTypes = { 47 | survey: PropTypes.shape({ 48 | description: PropTypes.string.isRequired, 49 | name: PropTypes.string.isRequired, 50 | questions: PropTypes.arrayOf(customPropTypes.question).isRequired 51 | }), 52 | surveyActions: PropTypes.shape({ 53 | loadSurvey: PropTypes.func.isRequired, 54 | resetSurvey: PropTypes.func.isRequired 55 | }) 56 | }; 57 | 58 | const mapStateToProps = (state) => ({ 59 | survey: getDenormalizedSurvey(state) 60 | }); 61 | 62 | const mapDispatchToProps = (dispatch) => ({ 63 | surveyActions: bindActionCreators(surveyActions, dispatch) 64 | }); 65 | 66 | export default connect(mapStateToProps, mapDispatchToProps)(ResponsesPage); 67 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/ResponsesPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import {spy} from 'sinon'; 4 | 5 | import {ResponsesPage} from './ResponsesPage'; 6 | 7 | 8 | describe('ResponsesPage', () => { 9 | let props; 10 | 11 | beforeEach(() => { 12 | props = { 13 | survey: { 14 | _id: 'Id_s1', 15 | name: 'Test', 16 | description: 'Test Description', 17 | questions: [] 18 | }, 19 | surveyActions: { 20 | loadSurvey: spy(), 21 | resetSurvey: spy() 22 | } 23 | }; 24 | }); 25 | 26 | it('should show loader when props are not loaded', () => { 27 | const propsWithoutSurvey = {...props, survey: undefined}; 28 | const wrapper = shallow(); 29 | 30 | expect(wrapper.find('DefaultSpinner').exists()); 31 | }); 32 | 33 | it('should load survey when component is mounted', () => { 34 | const wrapper = shallow(); 35 | expect(props.surveyActions.loadSurvey.calledOnce); 36 | }); 37 | 38 | it('should render ResponsesPageQuestionList when mounted', () => { 39 | const wrapper = shallow(); 40 | expect(wrapper.find('ResponsesPageQuestionList').exists()); 41 | }); 42 | 43 | it('should reset survey data when component is unmounted', () => { 44 | const wrapper = shallow(); 45 | wrapper.unmount(); 46 | expect(props.surveyActions.resetSurvey.callOnce); 47 | }); 48 | 49 | }); 50 | -------------------------------------------------------------------------------- /src/components/ResponsesPage/ResponsesPageQuestionList/ResponsesPageQuestionList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import customPropTypes from '../../../constants/customPropTypes'; 5 | 6 | import QuestionResponseList from '../QuestionResponseList/QuestionResponseList' 7 | 8 | 9 | const ResponsesPageQuestionList = ({questions}) => { 10 | return ( 11 |
    12 | {questions.map(question => { 13 | return ( 14 | 18 | ); 19 | })} 20 |
    21 | ) 22 | }; 23 | 24 | ResponsesPageQuestionList.propTypes = { 25 | questions: PropTypes.arrayOf(customPropTypes.question).isRequired 26 | }; 27 | 28 | 29 | export default ResponsesPageQuestionList; -------------------------------------------------------------------------------- /src/components/ResponsesPage/ResponsesPageQuestionList/ResponsesPageQuestionList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow, mount, render} from 'enzyme'; 3 | 4 | import surveyQuestionTypes from '../../../constants/questionTypes'; 5 | 6 | import ResponsePageQuestionList from './ResponsesPageQuestionList'; 7 | 8 | 9 | describe('ResponsesPageQuestionList', () => { 10 | it('should render question response element for each question', () => { 11 | const questions = [{ 12 | _id: 'Id_q1', 13 | title: 'Test question 1', 14 | type: surveyQuestionTypes.SHORT_ANSWER 15 | }, { 16 | _id: 'Id_q2', 17 | title: 'Test question 2', 18 | type: surveyQuestionTypes.SHORT_ANSWER 19 | }]; 20 | 21 | const wrapper = shallow(); 22 | expect(wrapper.find('QuestionResponseList')).to.have.length(2); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/Root.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Provider} from 'react-redux' 3 | import {ConnectedRouter} from 'react-router-redux'; 4 | import {Route, Switch, Redirect} from 'react-router-dom'; 5 | 6 | import store from '../configureStore' 7 | import * as actionTypes from '../constants/actionTypes'; 8 | import history from '../history'; 9 | 10 | import SurveyPage from './SurveyPage/SurveyPage' 11 | import SurveyBuilderPage from './SurveyBuilderPage/SurveyBuilderPage'; 12 | import ResponsesPage from './ResponsesPage/ResponsesPage'; 13 | import Header from './Header/Header'; 14 | import SignInPage from './auth/SignInPage'; 15 | import SignOutPage from './auth/SignOutPage'; 16 | import SignUpPage from './auth/SignUpPage'; 17 | import RequireAuth from './auth/RequireAuth'; 18 | 19 | import '../styles/core/reset.css'; 20 | import '../styles/layouts/layout.css'; 21 | import '../styles/modules/buttons.css'; 22 | import '../styles/modules/typography.css'; 23 | import '../styles/modules/form.css'; 24 | import '../styles/modules/navigation.css'; 25 | import '../styles/modules/spinner.css'; 26 | import '../styles/modules/error-message.css'; 27 | import '../styles/pages/App.css'; 28 | import '../styles/pages/SurveyBuilder.css'; 29 | import '../styles/pages/SurveyForm.css'; 30 | import '../styles/pages/LoginForm.css'; 31 | import '../styles/pages/SurveyResponses.css'; 32 | 33 | 34 | if (localStorage.getItem('token')) { 35 | store.dispatch({ 36 | type: actionTypes.AUTH_SIGN_IN_SUCCESS 37 | }); 38 | } 39 | 40 | const Root = () => ( 41 | 42 | 43 |
    44 |
    45 |
    46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |
    56 |
    57 |
    58 |
    59 | ); 60 | 61 | export default Root; -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/AnswerOptionListBuilder/AnswerOptionListBuilder.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {Field} from 'redux-form'; 4 | import {required} from 'redux-form-validators' 5 | 6 | import customPropTypes from '../../../constants/customPropTypes'; 7 | 8 | import FormInput from '../../shared/FormInput'; 9 | 10 | 11 | const AnswerOptionListBuilder = ({ 12 | questionId, 13 | answerOptions, 14 | onAddNewAnswerOption 15 | }) => { 16 | 17 | const handleAddNewAnswerOption = () => { 18 | return onAddNewAnswerOption({questionId}); 19 | }; 20 | 21 | return ( 22 |
    23 |
      24 | {answerOptions.map((answerOption, index) => { 25 | return ( 26 |
    • 27 | radio_button_unchecked 28 |
      29 | 37 |
      38 |
    • ); 39 | })} 40 |
    41 | 42 |
    43 | radio_button_unchecked 44 | 50 |
    51 |
    52 | ); 53 | }; 54 | 55 | AnswerOptionListBuilder.propTypes = { 56 | answerOptions: PropTypes.arrayOf(customPropTypes.answerOption).isRequired, 57 | onAddNewAnswerOption: PropTypes.func.isRequired, 58 | questionId: PropTypes.string.isRequired 59 | }; 60 | 61 | export default AnswerOptionListBuilder; -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/AnswerOptionListBuilder/AnswerOptionListBuilder.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import sinon from 'sinon'; 4 | 5 | import AnswerOptionListBuilder from './AnswerOptionListBuilder'; 6 | 7 | 8 | describe('AnswerOptionListBuilder', () => { 9 | let props; 10 | let wrapper; 11 | 12 | before(() => { 13 | props = { 14 | answerOptions: [{ 15 | _id: 'Id_ao1', 16 | title: 'Option A', 17 | }, { 18 | _id: 'Id_ao2', 19 | title: 'Option B' 20 | }], 21 | onAddNewAnswerOption: sinon.spy(), 22 | questionId: 'Id_q1' 23 | }; 24 | 25 | wrapper = shallow(); 26 | }); 27 | 28 | 29 | it('should render given options', () => { 30 | expect(wrapper.find('.survey-builder__answer-option')).to.have.length(2); 31 | }); 32 | 33 | it('should call onAddNewAnswerOption when button is clicked', () => { 34 | wrapper.find('button').simulate('click'); 35 | expect(props.onAddNewAnswerOption.calledOnce); 36 | }) 37 | }); -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/QuestionBuilder/QuestionBuilder.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {Field} from 'redux-form'; 4 | import PropTypes from 'prop-types'; 5 | import {bindActionCreators} from 'redux'; 6 | import {required} from 'redux-form-validators' 7 | 8 | import * as questionActions from '../../../actions/questionActions'; 9 | import * as answerOptionActions from '../../../actions/answerOptionActions'; 10 | import questionTypes from '../../../constants/questionTypes'; 11 | import customPropTypes from '../../../constants/customPropTypes'; 12 | 13 | import QuestionTypeSelector from '../QuestionTypeSelector/QuestionTypeSelector'; 14 | import AnswerOptionListBuilder from '../AnswerOptionListBuilder/AnswerOptionListBuilder'; 15 | import FormInput from "../../shared/FormInput"; 16 | 17 | 18 | export const QuestionBuilder = ({ 19 | question, 20 | surveyId, 21 | questionActions, 22 | answerOptionActions 23 | }) => { 24 | 25 | const handleQuestionTypeChange = ({type}) => { 26 | return questionActions.changeQuestionType({ 27 | questionId: question._id, 28 | type 29 | }); 30 | }; 31 | 32 | const handleDeleteQuestion = () => { 33 | return questionActions.deleteQuestion({ 34 | questionId: question._id, 35 | surveyId: surveyId 36 | }) 37 | }; 38 | 39 | return ( 40 |
    41 |
    42 | 46 | 50 | delete 51 | 52 |
    53 | 54 | 55 | 62 | 63 | { 64 | question.type === questionTypes.MULTIPLE_ANSWER && 65 | 66 | 72 | } 73 |
    74 | ); 75 | }; 76 | 77 | QuestionBuilder.propTypes = { 78 | answerOptionActions: PropTypes.shape({ 79 | addNewAnswerOption: PropTypes.func.isRequired 80 | }).isRequired, 81 | question: customPropTypes.question.isRequired, 82 | questionActions: PropTypes.shape({ 83 | changeQuestionType: PropTypes.func.isRequired, 84 | deleteQuestion: PropTypes.func.isRequired 85 | }).isRequired 86 | }; 87 | 88 | const mapDispatchToProps = (dispatch) => ({ 89 | questionActions: bindActionCreators(questionActions, dispatch), 90 | answerOptionActions: bindActionCreators(answerOptionActions, dispatch) 91 | }); 92 | 93 | 94 | export default connect(null, mapDispatchToProps)(QuestionBuilder); -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/QuestionBuilder/QuestionBuilder.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import sinon from 'sinon'; 4 | 5 | import surveyQuestionTypes from '../../../constants/questionTypes'; 6 | 7 | import {QuestionBuilder} from './QuestionBuilder'; 8 | 9 | 10 | describe('QuestionBuilder', () => { 11 | let props; 12 | 13 | before(() => { 14 | props = { 15 | answerOptionActions: { 16 | addNewAnswerOption: sinon.spy() 17 | }, 18 | question: { 19 | _id: 'Id_q1', 20 | title: 'Test question 1', 21 | type: surveyQuestionTypes.MULTIPLE_ANSWER, 22 | answerOptions: [{ 23 | _id: 'Id_ao1', 24 | title: 'Option A', 25 | }, { 26 | _id: 'Id_ao2', 27 | title: 'Option B' 28 | }] 29 | }, 30 | questionActions: { 31 | changeQuestionType: sinon.spy(), 32 | deleteQuestion: sinon.spy() 33 | } 34 | }; 35 | }); 36 | 37 | it('should render question with AnswerOptionListBuilder', () => { 38 | const wrapper = shallow(); 39 | expect(wrapper.find('AnswerOptionListBuilder').exists()); 40 | }); 41 | 42 | it('should render question without AnswerOptionListBuilder', () => { 43 | const propsWithShortAnswer = { 44 | ...props, 45 | question: { 46 | ...props.question, 47 | type: surveyQuestionTypes.SHORT_ANSWER 48 | } 49 | }; 50 | const wrapper = shallow(); 51 | 52 | expect(wrapper.find('AnswerOptionListBuilder').exists()).to.equal(false); 53 | }); 54 | 55 | it('should call handleDeleteQuestion callback when delete button pressed', () => { 56 | const wrapper = shallow(); 57 | wrapper.find('.survey-builder__remove-question-button').simulate('click'); 58 | 59 | expect(props.questionActions.deleteQuestion.callOnce); 60 | }); 61 | }); -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/QuestionTypeSelector/QuestionTypeSelector.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classnames from 'classnames'; 4 | 5 | import questionTypes from '../../../constants/questionTypes'; 6 | 7 | 8 | const QuestionTypeSelector = ({ 9 | onSelectQuestionType, 10 | questionType 11 | }) => { 12 | 13 | const shortAnswerButtonStyle = classnames({ 14 | 'button-flat button-flat--active': questionType === questionTypes.SHORT_ANSWER, 15 | 'button-flat': questionType !== questionTypes.SHORT_ANSWER 16 | }); 17 | 18 | const multipleAnswerButtonStyle = classnames({ 19 | 'button-flat button-flat--active': questionType === questionTypes.MULTIPLE_ANSWER, 20 | 'button-flat': questionType !== questionTypes.MULTIPLE_ANSWER 21 | }); 22 | 23 | return ( 24 |
      25 |
    • 26 | 35 |
    • 36 |
    • 37 | 46 |
    • 47 |
    48 | ) 49 | }; 50 | 51 | QuestionTypeSelector.propTypes = { 52 | onSelectQuestionType: PropTypes.func.isRequired, 53 | questionType: PropTypes.oneOf([ 54 | questionTypes.SHORT_ANSWER, 55 | questionTypes.MULTIPLE_ANSWER 56 | ]).isRequired 57 | }; 58 | 59 | export default QuestionTypeSelector; 60 | -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/QuestionTypeSelector/QuestionTypeSelector.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import sinon from 'sinon'; 4 | 5 | import questionTypes from '../../../constants/questionTypes'; 6 | 7 | import QuestionTypeSelector from './QuestionTypeSelector'; 8 | 9 | 10 | describe('QuestionTypeSelector', () => { 11 | let props; 12 | let wrapper; 13 | 14 | before(() => { 15 | props = { 16 | onSelectQuestionType: sinon.spy(), 17 | questionType: questionTypes.SHORT_ANSWER 18 | }; 19 | 20 | wrapper = shallow(); 21 | }); 22 | 23 | it('should call first btn onSelectQuestionType callback with SHORT_ANSWER type', () => { 24 | wrapper.find('button').first().simulate('click'); 25 | 26 | expect(props.onSelectQuestionType.calledWith({ 27 | type: questionTypes.SHORT_ANSWER 28 | })); 29 | }); 30 | 31 | 32 | it('should call last btn onSelectQuestionType callback with SHORT_ANSWER type', () => { 33 | wrapper.find('button').last().simulate('click'); 34 | 35 | expect(props.onSelectQuestionType.calledWith({ 36 | type: questionTypes.MULTIPLE_ANSWER 37 | })); 38 | }); 39 | }); -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/SurveyBuilderPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {reduxForm, Field} from 'redux-form'; 4 | import {bindActionCreators} from 'redux'; 5 | import PropTypes from 'prop-types'; 6 | import {required} from 'redux-form-validators' 7 | 8 | import * as surveyActions from '../../actions/surveyActions/surveyActions'; 9 | import * as questionActions from '../../actions/questionActions'; 10 | import customPropTypes from '../../constants/customPropTypes'; 11 | 12 | import getDenormalizedSurvey from '../../selectors/getDenormalizedSurvey'; 13 | import getInitialFormBuilderValues from '../../selectors/getInitialFormBuilderValues'; 14 | import SurveyBuilderQuestionList from './SurveyBuilderQuestionList/SurveyBuilderQuestionList'; 15 | import DefaultSpinner from '../shared/DefaultSpinner'; 16 | import FormInput from '../shared/FormInput'; 17 | 18 | export class SurveyBuilderPage extends React.Component { 19 | componentDidMount() { 20 | this.props.surveyActions.loadSurvey(); 21 | } 22 | 23 | componentWillUnmount() { 24 | this.props.surveyActions.resetSurvey(); 25 | } 26 | 27 | render() { 28 | const { 29 | survey, 30 | handleSubmit, 31 | submitting 32 | } = this.props; 33 | 34 | if (!survey) { 35 | return ; 36 | } 37 | 38 | return ( 39 |
    40 |
    41 |
    42 | 50 |
    51 | 59 |
    60 | 61 | { 62 | survey && survey.questions && 63 | 67 | } 68 | 69 | 70 | 73 | add_circle_outline 74 | 75 | 76 |
    77 | 84 |
    85 | 86 |
    87 | ); 88 | } 89 | 90 | handleAddNewQuestion = () => { 91 | this.props.questionActions.addNewQuestion({ 92 | surveyId: this.props.survey._id 93 | }); 94 | }; 95 | 96 | handleSaveSurvey = (surveyFormData) => { 97 | return this.props.surveyActions.saveSurvey({surveyFormData}); 98 | }; 99 | } 100 | 101 | SurveyBuilderPage.propTypes = { 102 | handleSubmit: PropTypes.func.isRequired, 103 | questionActions: PropTypes.shape({ 104 | addNewQuestion: PropTypes.func.isRequired, 105 | }).isRequired, 106 | submitting: PropTypes.bool.isRequired, 107 | survey: PropTypes.shape({ 108 | description: PropTypes.string.isRequired, 109 | questions: PropTypes.arrayOf(customPropTypes.question), 110 | name: PropTypes.string.isRequired 111 | }), 112 | surveyActions: PropTypes.shape({ 113 | loadSurvey: PropTypes.func.isRequired, 114 | resetSurvey: PropTypes.func.isRequired, 115 | saveSurvey: PropTypes.func.isRequired 116 | }).isRequired 117 | }; 118 | 119 | const mapStateToProps = (state) => ({ 120 | survey: getDenormalizedSurvey(state), 121 | initialValues: getInitialFormBuilderValues(state), 122 | }); 123 | 124 | const mapDispatchToProps = (dispatch) => ({ 125 | surveyActions: bindActionCreators(surveyActions, dispatch), 126 | questionActions: bindActionCreators(questionActions, dispatch) 127 | }); 128 | 129 | export const SurveyBuilderPageForm = reduxForm({ 130 | form: 'surveyBuilderForm', 131 | shouldValidate: () => true // Due to bug https://github.com/erikras/redux-form/issues/3276 132 | })(SurveyBuilderPage); 133 | 134 | const ConnectedSurveyBuilderPageForm = connect( 135 | mapStateToProps, 136 | mapDispatchToProps 137 | )(SurveyBuilderPageForm); 138 | 139 | export default ConnectedSurveyBuilderPageForm; 140 | -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/SurveyBuilderPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import sinon from 'sinon'; 4 | 5 | import {SurveyBuilderPage} from './SurveyBuilderPage'; 6 | 7 | 8 | describe('SurveyBuilderPage', () => { 9 | let props; 10 | 11 | before(() => { 12 | props = { 13 | survey: { 14 | _id: 'Id_s1', 15 | name: '', 16 | description: '' 17 | }, 18 | handleSubmit: sinon.spy(), 19 | submitting: false, 20 | questionActions: { 21 | addNewQuestion: sinon.spy() 22 | }, 23 | surveyActions: { 24 | loadSurvey: sinon.spy(), 25 | resetSurvey: sinon.spy(), 26 | saveSurvey: sinon.spy() 27 | } 28 | }; 29 | }); 30 | 31 | it('should show default spinner when survey not loaded', () => { 32 | const propsWithoutSurvey = {...props, survey: undefined}; 33 | const wrapper = shallow(); 34 | 35 | expect(wrapper.find('DefaultSpinner').exists()); 36 | }); 37 | 38 | it('should not show SurveyBuilderQuestionList when survey is new', () => { 39 | const wrapper = shallow(); 40 | 41 | expect(wrapper.find('SurveyBuilderQuestionList').exists()).to.equal(false); 42 | }); 43 | 44 | it('should call addNewQuestion handler when Add new question button is clicked', () => { 45 | const wrapper = shallow(); 46 | wrapper.find('.survey-builder__add-question-button').simulate('click'); 47 | 48 | expect(props.questionActions.addNewQuestion.calledOnce); 49 | }); 50 | 51 | it('should reset survey data when component is unmounted', () => { 52 | const wrapper = shallow(); 53 | wrapper.unmount(); 54 | 55 | expect(props.surveyActions.resetSurvey.calledOnce); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/SurveyBuilderQuestionList/SurveyBuilderQuestionLIst.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import surveyQuestionTypes from '../../../constants/questionTypes'; 5 | 6 | import SurveyBuilderQuestionList from './SurveyBuilderQuestionList'; 7 | 8 | 9 | describe('SurveyBuilderQuestionList', () => { 10 | let props; 11 | let wrapper; 12 | 13 | before(() => { 14 | props = { 15 | questions: [{ 16 | _id: 'Id_q1', 17 | title: 'Test question 1', 18 | type: surveyQuestionTypes.SHORT_ANSWER, 19 | }, { 20 | _id: 'Id_q2', 21 | title: 'Test question 2', 22 | type: surveyQuestionTypes.SHORT_ANSWER, 23 | }], 24 | surveyId: 'Id_s1' 25 | }; 26 | 27 | wrapper = shallow(); 28 | }); 29 | 30 | it('should render QuestionBuilder for each question', () => { 31 | expect(wrapper.find('Connect(QuestionBuilder)')).to.have.length(2); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/components/SurveyBuilderPage/SurveyBuilderQuestionList/SurveyBuilderQuestionList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import customPropTypes from '../../../constants/customPropTypes'; 5 | 6 | import QuestionBuilder from '../QuestionBuilder/QuestionBuilder'; 7 | 8 | 9 | const SurveyBuilderQuestionList = ({questions, surveyId}) => { 10 | return ( 11 |
    12 | {questions.map((question) => ( 13 | 18 | ))} 19 |
    20 | ); 21 | }; 22 | 23 | SurveyBuilderQuestionList.propTypes = { 24 | questions: PropTypes.arrayOf(customPropTypes.question).isRequired, 25 | surveyId: PropTypes.string.isRequired 26 | }; 27 | 28 | export default SurveyBuilderQuestionList; -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyAnswerOptionList/SurveyAnswerOptionList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import customPropTypes from '../../../constants/customPropTypes'; 5 | 6 | const SurveyAnswerOptionList = ({ 7 | input, 8 | meta, 9 | answerOptions 10 | }) => { 11 | return ( 12 |
      13 | { 14 | answerOptions.map(answerOption => ( 15 | 16 |
    • 20 | 27 | 28 | 34 |
    • 35 | )) 36 | } 37 | { 38 | meta.touched && meta.error && 39 | Error: {meta.error} 40 | } 41 |
    42 | ); 43 | }; 44 | 45 | SurveyAnswerOptionList.propTypes = { 46 | answerOptions: PropTypes.arrayOf(customPropTypes.answerOption).isRequired, 47 | input: PropTypes.object.isRequired, 48 | meta: PropTypes.object.isRequired 49 | }; 50 | 51 | export default SurveyAnswerOptionList; -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyAnswerOptionList/SurveyAnswerOptionList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import SurveyAnswerOptionList from './SurveyAnswerOptionList'; 5 | 6 | 7 | describe('SurveyAnswerOptionList', () => { 8 | let props; 9 | let wrapper; 10 | 11 | before(() => { 12 | props = { 13 | answerOptions: [{ 14 | _id: 'Id_ao1', 15 | title: 'Option A', 16 | }, { 17 | _id: 'Id_ao2', 18 | title: 'Option B' 19 | }], 20 | input: {}, 21 | meta: {} 22 | }; 23 | }); 24 | 25 | 26 | it('should render all answer options provided', () => { 27 | wrapper = shallow(); 28 | expect(wrapper.find('.survey-form__radio-group-list-item')).to.have.length(2); 29 | }); 30 | 31 | }); -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {reduxForm} from 'redux-form'; 3 | import {connect} from 'react-redux'; 4 | import {bindActionCreators} from 'redux'; 5 | import queryString from 'query-string'; 6 | import PropTypes from 'prop-types'; 7 | 8 | import * as surveyActions from '../../actions/surveyActions/surveyActions'; 9 | import getDenormalizedSurvey from '../../selectors/getDenormalizedSurvey'; 10 | import customPropTypes from '../../constants/customPropTypes'; 11 | 12 | import SurveyQuestionList from './SurveyQuestionList/SurveyQuestionList'; 13 | import DefaultSpinner from '../shared/DefaultSpinner'; 14 | 15 | 16 | export class SurveyPage extends React.Component { 17 | componentDidMount() { 18 | const {location, history} = this.props; 19 | const surveyId = queryString.parse(location.search).id; 20 | 21 | if (!surveyId) { 22 | history.push('/sign-in'); 23 | } 24 | 25 | this.props.surveyActions.loadSurveyById({surveyId}); 26 | } 27 | 28 | componentWillUnmount() { 29 | this.props.surveyActions.resetSurvey(); 30 | } 31 | 32 | render() { 33 | const { 34 | survey, 35 | handleSubmit, 36 | submitting, 37 | submitSucceeded 38 | } = this.props; 39 | 40 | if (!survey) { 41 | return ; 42 | } 43 | 44 | if (!survey.questions.length) { 45 | return

    Survey not ready.

    ; 46 | } 47 | 48 | if (submitSucceeded) { 49 | return ( 50 |

    Thank you for participating in a survey :)

    51 | ); 52 | } 53 | 54 | return ( 55 |
    56 |
    57 |

    {survey.name}

    58 |

    {survey.description}

    59 |
    60 |
    64 | 65 |
    66 | 73 |
    74 | 75 |
    76 | ); 77 | } 78 | 79 | handleSurveyAnswers = (surveyFormData) => { 80 | return this.props.surveyActions.saveSurveyAnswers({ 81 | surveyId: this.props.survey._id, 82 | surveyFormData 83 | }); 84 | }; 85 | } 86 | 87 | SurveyPage.propTypes = { 88 | handleSubmit: PropTypes.func.isRequired, 89 | history: PropTypes.object.isRequired, 90 | location: PropTypes.object.isRequired, 91 | survey: PropTypes.shape({ 92 | description: PropTypes.string.isRequired, 93 | name: PropTypes.string.isRequired, 94 | questions: PropTypes.arrayOf(customPropTypes.question).isRequired 95 | }), 96 | surveyActions: PropTypes.shape({ 97 | loadSurveyById: PropTypes.func.isRequired, 98 | resetSurvey: PropTypes.func.isRequired, 99 | saveSurvey: PropTypes.func.isRequired, 100 | saveSurveyAnswers: PropTypes.func.isRequired 101 | }).isRequired 102 | }; 103 | 104 | const mapStateToProps = (state) => ({ 105 | survey: getDenormalizedSurvey(state) 106 | }); 107 | 108 | const mapDispatchToProps = (dispatch) => ({ 109 | surveyActions: bindActionCreators(surveyActions, dispatch) 110 | }); 111 | 112 | const SurveyPageForm = reduxForm({ 113 | form: 'SurveyPage', 114 | shouldValidate: () => true // Due to bug https://github.com/erikras/redux-form/issues/3276 115 | })(SurveyPage); 116 | 117 | const ConnectedSurveyPageForm = connect(mapStateToProps, mapDispatchToProps)(SurveyPageForm); 118 | 119 | export default ConnectedSurveyPageForm; 120 | -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyPage.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | import sinon from 'sinon'; 4 | 5 | import {SurveyPage} from './SurveyPage'; 6 | 7 | 8 | describe('SurveyPage', () => { 9 | let props; 10 | 11 | beforeEach(() => { 12 | props = { 13 | history: {}, 14 | location: { 15 | search: '?id=xxx' 16 | }, 17 | handleSubmit: sinon.spy(), 18 | survey: { 19 | description: '', 20 | name: '', 21 | questions: [] 22 | }, 23 | surveyActions: { 24 | loadSurveyById: sinon.spy(), 25 | resetSurvey: sinon.spy(), 26 | saveSurvey: sinon.spy(), 27 | saveSurveyAnswers: sinon.spy() 28 | } 29 | }; 30 | }); 31 | 32 | it('should show loader when props are not loaded', () => { 33 | const propsWithoutSurvey = {...props, survey: undefined}; 34 | const wrapper = shallow(); 35 | 36 | expect(wrapper.find('DefaultSpinner').exists()); 37 | }); 38 | 39 | it('should load survey when component is mounted', () => { 40 | const wrapper = shallow(); 41 | 42 | expect(props.surveyActions.loadSurveyById.calledOnce); 43 | }); 44 | 45 | it('should reset data when component is unMounted', () => { 46 | const wrapper = shallow(); 47 | wrapper.unmount(); 48 | 49 | expect(props.surveyActions.resetSurvey.calledOnce); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyQuestion/SurveyQuestion.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Field} from 'redux-form'; 3 | import {required} from 'redux-form-validators' 4 | 5 | import questionTypes from '../../../constants/questionTypes'; 6 | import customPropTypes from '../../../constants/customPropTypes'; 7 | 8 | import SurveyAnswerOptionList from '../SurveyAnswerOptionList/SurveyAnswerOptionList'; 9 | import FormInput from '../../shared/FormInput'; 10 | 11 | const SurveyQuestion = ({question}) => { 12 | return ( 13 |
    14 |

    {question.title} *

    15 | 16 | { 17 | question.type !== questionTypes.MULTIPLE_ANSWER && 18 | 19 | 26 | } 27 | 28 | { 29 | question.answerOptions.length > 0 && 30 | 31 | 37 | } 38 | 39 |
    40 | ); 41 | }; 42 | 43 | SurveyQuestion.propTypes = { 44 | question: customPropTypes.question.isRequired 45 | }; 46 | 47 | export default SurveyQuestion; -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyQuestion/SurveyQuestion.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import surveyQuestionTypes from '../../../constants/questionTypes'; 5 | 6 | import SurveyQuestion from './SurveyQuestion'; 7 | 8 | 9 | describe('SurveyQuestion', () => { 10 | it('should render question input for short question', () => { 11 | const props = { 12 | question: { 13 | _id: 'Id_q1', 14 | title: 'Test question 1', 15 | type: surveyQuestionTypes.MULTIPLE_ANSWER, 16 | answerOptions: [{ 17 | _id: 'Id_ao1', 18 | title: 'Option A', 19 | }] 20 | } 21 | }; 22 | 23 | const wrapper = shallow(); 24 | 25 | expect(wrapper.find('Field[answerOptions]').exists()); 26 | expect(wrapper.find('Field[type=\'text\']').exists()).is.equal(false); 27 | }); 28 | 29 | it('should render answer option list for multiple answer question', () => { 30 | const props = { 31 | question: { 32 | _id: 'Id_q1', 33 | title: 'Test question 1', 34 | type: surveyQuestionTypes.SHORT_ANSWER, 35 | answerOptions: [] 36 | } 37 | }; 38 | 39 | const wrapper = shallow(); 40 | 41 | expect(wrapper.find('Field[answerOptions]').exists()).is.equal(false); 42 | expect(wrapper.find('Field[type=\'text\']').exists()); 43 | }); 44 | 45 | }); -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyQuestionList/SurveyQuestionList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import customPropTypes from '../../../constants/customPropTypes'; 5 | 6 | import SurveyQuestion from '../SurveyQuestion/SurveyQuestion'; 7 | 8 | 9 | const SurveyQuestionList = ({questions}) => { 10 | return ( 11 |
    12 | { 13 | questions.map((question) => ( 14 | 18 | )) 19 | } 20 |
    21 | ); 22 | }; 23 | 24 | SurveyQuestionList.propTypes = { 25 | questions: PropTypes.arrayOf(customPropTypes.question).isRequired 26 | }; 27 | 28 | export default SurveyQuestionList; -------------------------------------------------------------------------------- /src/components/SurveyPage/SurveyQuestionList/SurveyQuestionList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import surveyQuestionTypes from '../../../constants/questionTypes'; 5 | 6 | import SurveyQuestionList from './SurveyQuestionList'; 7 | 8 | 9 | describe('SurveyQuestionList', () => { 10 | it('should render question list for all questions', () => { 11 | const props = { 12 | questions: [{ 13 | _id: 'Id_q1', 14 | title: 'Test question 1', 15 | type: surveyQuestionTypes.MULTIPLE_ANSWER 16 | }, { 17 | _id: 'Id_q2', 18 | title: 'Test question 2', 19 | type: surveyQuestionTypes.SHORT_ANSWER 20 | }] 21 | }; 22 | 23 | const wrapper = shallow(); 24 | 25 | expect(wrapper.find('SurveyQuestion')).to.have.length(2); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/components/auth/RequireAuth.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import {withRouter} from 'react-router-dom'; 4 | 5 | 6 | export default function (ComposedComponent) { 7 | 8 | class Authentication extends Component { 9 | componentWillMount() { 10 | if (!this.props.authenticated) { 11 | this.props.history.push('/sign-in'); 12 | } 13 | } 14 | 15 | componentWillUpdate(nextProps) { 16 | if (!nextProps.authenticated) { 17 | this.props.history.push('/sign-in'); 18 | } 19 | } 20 | 21 | render() { 22 | return 23 | } 24 | } 25 | 26 | const mapStateToProps = (state) => { 27 | return {authenticated: state.auth.authenticated}; 28 | }; 29 | 30 | return withRouter(connect(mapStateToProps)(Authentication)); 31 | } 32 | -------------------------------------------------------------------------------- /src/components/auth/SignInPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {reduxForm, Field} from 'redux-form'; 3 | import {bindActionCreators} from 'redux'; 4 | import {connect} from 'react-redux'; 5 | import PropTypes from 'prop-types'; 6 | 7 | import * as authActions from '../../actions/authActions'; 8 | import {NOTIFICATION_DISPLAY_INTERVAL} from '../../config/appConfig'; 9 | 10 | import FormInput from '../shared/FormInput'; 11 | import ErrorMessage from '../shared/ErrorMessage'; 12 | 13 | 14 | 15 | class SignInPage extends React.Component { 16 | componentWillUnmount() { 17 | this.props.authActions.clearErrors(); 18 | } 19 | 20 | componentWillReceiveProps(nextProps) { 21 | if (nextProps.errorMessage) { 22 | 23 | setTimeout(() => { 24 | this.props.authActions.clearErrors(); 25 | }, NOTIFICATION_DISPLAY_INTERVAL); 26 | } 27 | } 28 | 29 | render() { 30 | const { 31 | handleSubmit, 32 | errorMessage 33 | } = this.props; 34 | 35 | return ( 36 |
    37 | { 38 | errorMessage && 39 | 40 | } 41 | 42 |
    46 |
    47 | 53 |
    54 |
    55 | 61 |
    62 | 63 | 69 |
    70 |
    71 | ); 72 | } 73 | 74 | handleFormSubmit = ({email, password}) => { 75 | this.props.authActions.signInUser({email, password}); 76 | }; 77 | 78 | } 79 | 80 | SignInPage.propTypes = { 81 | authActions: PropTypes.shape({ 82 | clearErrors: PropTypes.func.isRequired, 83 | signInUser: PropTypes.func.isRequired 84 | }).isRequired, 85 | errorMessage: PropTypes.string, 86 | handleSubmit: PropTypes.func.isRequired 87 | }; 88 | 89 | const validate = (formProps) => { 90 | const errors = {}; 91 | 92 | if (!formProps.email) { 93 | errors.email = 'Please enter an email'; 94 | } 95 | 96 | if (!formProps.password) { 97 | errors.password = 'Please enter a password'; 98 | } 99 | 100 | return errors; 101 | }; 102 | 103 | const mapStateToProps = (state) => ({ 104 | errorMessage: state.auth.error 105 | }); 106 | 107 | const mapDispatchToProps = (dispatch) => ({ 108 | authActions: bindActionCreators(authActions, dispatch) 109 | }); 110 | 111 | SignInPage = connect(mapStateToProps, mapDispatchToProps)(SignInPage); 112 | 113 | export default reduxForm({ 114 | form: 'signInForm', 115 | validate 116 | })(SignInPage); -------------------------------------------------------------------------------- /src/components/auth/SignOutPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {connect} from 'react-redux' 3 | import {bindActionCreators} from 'redux'; 4 | import PropTypes from 'prop-types'; 5 | 6 | import * as authActions from '../../actions/authActions'; 7 | 8 | 9 | class SignOutPage extends React.Component { 10 | componentDidMount() { 11 | this.props.authActions.signOutUser(); 12 | } 13 | 14 | render() { 15 | return ( 16 |

    Sorry to see you go...

    17 | ); 18 | } 19 | } 20 | 21 | SignOutPage.propTypes = { 22 | authActions: PropTypes.shape({ 23 | signOutUser: PropTypes.func.isRequired 24 | }).isRequired 25 | }; 26 | 27 | const mapDispatchToProps = (dispatch) => ({ 28 | authActions: bindActionCreators(authActions, dispatch) 29 | }); 30 | 31 | export default connect(null, mapDispatchToProps)(SignOutPage); -------------------------------------------------------------------------------- /src/components/auth/SignUpPage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {reduxForm, Field} from 'redux-form'; 3 | import {bindActionCreators} from 'redux'; 4 | import {connect} from 'react-redux'; 5 | import PropTypes from 'prop-types'; 6 | 7 | import * as authActions from '../../actions/authActions'; 8 | import {NOTIFICATION_DISPLAY_INTERVAL} from '../../config/appConfig'; 9 | 10 | import FormInput from '../shared/FormInput'; 11 | import ErrorMessage from '../shared/ErrorMessage'; 12 | 13 | 14 | class SignUpPage extends React.Component { 15 | componentWillUnmount() { 16 | this.props.authActions.clearErrors(); 17 | } 18 | 19 | componentWillReceiveProps(nextProps) { 20 | if (nextProps.errorMessage) { 21 | 22 | setTimeout(() => { 23 | this.props.authActions.clearErrors(); 24 | }, NOTIFICATION_DISPLAY_INTERVAL); 25 | } 26 | } 27 | 28 | render() { 29 | const { 30 | handleSubmit, 31 | errorMessage 32 | } = this.props; 33 | 34 | return ( 35 |
    36 | { 37 | errorMessage && 38 | 39 | } 40 | 41 |
    45 |
    46 | 52 |
    53 |
    54 | 60 |
    61 |
    62 | 68 |
    69 | 70 | 76 |
    77 | 78 |
    79 | ); 80 | } 81 | 82 | handleFormSubmit = ({email, password}) => { 83 | this.props.authActions.signUpUser({email, password}); 84 | }; 85 | } 86 | 87 | SignUpPage.propTypes = { 88 | authActions: PropTypes.shape({ 89 | clearErrors: PropTypes.func.isRequired, 90 | signUpUser: PropTypes.func.isRequired 91 | }).isRequired, 92 | errorMessage: PropTypes.string, 93 | handleSubmit: PropTypes.func.isRequired 94 | }; 95 | 96 | const validate = (formProps) => { 97 | const errors = {}; 98 | 99 | if (!formProps.email) { 100 | errors.email = 'Please enter an email'; 101 | } 102 | 103 | if (!formProps.password) { 104 | errors.password = 'Please enter a password'; 105 | } 106 | 107 | if (!formProps.passwordConfirm) { 108 | errors.passwordConfirm = 'Please enter a password confirmation'; 109 | } 110 | 111 | if (formProps.password !== formProps.passwordConfirm) { 112 | errors.passwordConfirm = 'Passwords must match' 113 | } 114 | 115 | return errors; 116 | }; 117 | 118 | const mapStateToProps = (state) => ({ 119 | errorMessage: state.auth.error 120 | }); 121 | 122 | const mapDispatchToProps = (dispatch) => ({ 123 | authActions: bindActionCreators(authActions, dispatch) 124 | }); 125 | 126 | SignUpPage = connect(mapStateToProps, mapDispatchToProps)(SignUpPage); 127 | 128 | export default reduxForm({ 129 | form: 'signUpForm', 130 | validate 131 | })(SignUpPage); -------------------------------------------------------------------------------- /src/components/shared/DefaultSpinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Spinner from 'react-spinner-material'; 3 | 4 | 5 | const DefaultSpinner = () => { 6 | return ( 7 |
    8 | 14 |
    15 | ); 16 | }; 17 | 18 | export default DefaultSpinner; -------------------------------------------------------------------------------- /src/components/shared/ErrorMessage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const ErrorMessage = ({message}) => { 5 | return ( 6 |

    {message}

    7 | ); 8 | }; 9 | 10 | ErrorMessage.propTypes = { 11 | message: PropTypes.string.isRequired 12 | }; 13 | 14 | 15 | export default ErrorMessage; -------------------------------------------------------------------------------- /src/components/shared/FormInput.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classnames from 'classnames' 3 | import PropTypes from 'prop-types'; 4 | 5 | const FormInput = ({meta, type, input, placeholder}) => { 6 | const inputSyle = classnames('input', { 7 | 'input__has-error': meta.touched && meta.error 8 | }); 9 | 10 | return ( 11 |
    12 | 18 |
    19 | { 20 | meta.touched && meta.error && 21 | {`Error: ${meta.error}`} 22 | } 23 |
    24 |
    25 | ) 26 | }; 27 | 28 | FormInput.propTypes = { 29 | input: PropTypes.object.isRequired, 30 | meta: PropTypes.object.isRequired, 31 | placeholder: PropTypes.string.isRequired, 32 | type: PropTypes.string.isRequired 33 | }; 34 | 35 | export default FormInput; -------------------------------------------------------------------------------- /src/config/appConfig.js: -------------------------------------------------------------------------------- 1 | import chartTypes from '../constants/chartTypes'; 2 | 3 | export const API_URL = 'http://localhost:3001'; 4 | export const NOTIFICATION_DISPLAY_INTERVAL = 5000; 5 | export const CHART_TYPE_CONFIGURED = chartTypes.BAR_CHART; 6 | 7 | -------------------------------------------------------------------------------- /src/configureStore.js: -------------------------------------------------------------------------------- 1 | import {createStore, applyMiddleware, compose} from 'redux'; 2 | import {routerMiddleware} from 'react-router-redux'; 3 | import history from './history'; 4 | import thunk from 'redux-thunk'; 5 | 6 | import rootReducer from './reducers'; 7 | 8 | const middlewares = [ 9 | thunk, 10 | routerMiddleware(history) 11 | ]; 12 | 13 | const enhancers = []; 14 | 15 | if (process.env.NODE_ENV === 'development') { 16 | const devToolsExtension = window.devToolsExtension 17 | 18 | if (typeof devToolsExtension === 'function') { 19 | enhancers.push(devToolsExtension()) 20 | } 21 | } 22 | 23 | const store = createStore( 24 | rootReducer, 25 | {}, //initialState 26 | compose( 27 | applyMiddleware(...middlewares), 28 | ...enhancers 29 | ) 30 | ); 31 | 32 | export default store; 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/constants/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const AUTH_SIGN_IN_SUCCESS = 'AUTH_SIGN_IN_SUCCESS'; 2 | export const AUTH_SIGN_IN_ERROR = 'AUTH_SIGN_IN_ERROR'; 3 | export const AUTH_SIGN_OUT_SUCCESS = 'AUTH_SIGN_OUT_SUCCESS'; 4 | export const AUTH_CLEAR_ERRORS = 'AUTH_CLEAR_ERRORS'; 5 | 6 | export const SURVEY_LOAD_SUCCESS = 'SURVEY_LOAD_SUCCESS'; 7 | export const SURVEY_INIT_NEW = 'SURVEY_INIT_NEW'; 8 | export const SURVEY_RESET = 'SURVEY_RESET'; 9 | export const SURVEY_BIND_FORM_DATA = 'SURVEY_BIND_FORM_DATA'; 10 | 11 | export const QUESTION_ADD_NEW = 'QUESTION_ADD_NEW'; 12 | export const QUESTION_CHANGE_TYPE = 'QUESTION_CHANGE_TYPE'; 13 | export const QUESTION_DELETE = 'QUESTION_DELETE'; 14 | 15 | export const ANSWER_OPTION_ADD_NEW = 'ANSWER_OPTION_ADD_NEW'; 16 | export const ANSWER_OPTION_CHANGE_TITLE = 'ANSWER_OPTION_CHANGE_TITLE'; 17 | 18 | -------------------------------------------------------------------------------- /src/constants/chartTypes.js: -------------------------------------------------------------------------------- 1 | const chartTypes = { 2 | DOUGHNUT_CHART: 1, 3 | BAR_CHART: 2 4 | }; 5 | 6 | export default chartTypes; 7 | -------------------------------------------------------------------------------- /src/constants/customPropTypes.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | /*** 4 | * Reusable custom prop types 5 | */ 6 | 7 | const answer = PropTypes.shape({ 8 | _id: PropTypes.string.isRequired, 9 | answerText: PropTypes.string.isRequired 10 | }); 11 | 12 | const answerOption = PropTypes.shape({ 13 | _id: PropTypes.string.isRequired, 14 | title: PropTypes.string.isRequired 15 | }); 16 | 17 | const question = PropTypes.shape({ 18 | _id: PropTypes.string.isRequired, 19 | answerOptions: PropTypes.arrayOf(answerOption), 20 | answers: PropTypes.arrayOf(answer), 21 | title: PropTypes.string.isRequired, 22 | type: PropTypes.number.isRequired 23 | }); 24 | 25 | export default { 26 | answer, 27 | answerOption, 28 | question 29 | }; 30 | -------------------------------------------------------------------------------- /src/constants/questionTypes.js: -------------------------------------------------------------------------------- 1 | const surveyQuestionTypes = { 2 | NOT_SELECTED: 0, 3 | SHORT_ANSWER: 1, 4 | MULTIPLE_ANSWER: 3 5 | }; 6 | 7 | export default surveyQuestionTypes; -------------------------------------------------------------------------------- /src/history.js: -------------------------------------------------------------------------------- 1 | import createHistory from 'history/createBrowserHistory'; 2 | 3 | export default createHistory(); 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import Root from './components/Root' 5 | 6 | ReactDOM.render( 7 | , 8 | document.querySelector('#root') 9 | ); -------------------------------------------------------------------------------- /src/models/schema.js: -------------------------------------------------------------------------------- 1 | import {schema} from 'normalizr'; 2 | 3 | 4 | const answerOption = new schema.Entity('answerOptions', undefined, { 5 | idAttribute: '_id' 6 | }); 7 | 8 | const answer = new schema.Entity('answers', undefined, { 9 | idAttribute: '_id' 10 | }); 11 | 12 | const question = new schema.Entity('questions', { 13 | answers: [answer], 14 | answerOptions: [answerOption] 15 | }, { 16 | idAttribute: '_id' 17 | }); 18 | 19 | const survey = new schema.Entity('surveys', 20 | {questions: [question]}, 21 | {idAttribute: '_id'} 22 | ); 23 | 24 | export default survey; -------------------------------------------------------------------------------- /src/reducers/answerOptionsReducer.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../constants/actionTypes'; 2 | import {bindFormDataToState} from '../utils/formDataUtils'; 3 | import update from 'immutability-helper'; 4 | 5 | 6 | const answerOptionsReducer = (state = {}, action) => { 7 | switch (action.type) { 8 | case actionTypes.SURVEY_LOAD_SUCCESS: { 9 | const {answerOptions} = action.payload.entities; 10 | 11 | return { 12 | ...answerOptions 13 | }; 14 | } 15 | 16 | case actionTypes.ANSWER_OPTION_ADD_NEW: 17 | case actionTypes.ANSWER_OPTION_CHANGE_TITLE: { 18 | const {answerOptionId, title} = action.payload; 19 | 20 | return update(state, { 21 | $merge: { 22 | [answerOptionId]: { 23 | _id: answerOptionId, 24 | title 25 | } 26 | } 27 | }) 28 | } 29 | 30 | case actionTypes.SURVEY_BIND_FORM_DATA: { 31 | const {answerOptions} = action.payload; 32 | 33 | return bindFormDataToState({ 34 | formFields: answerOptions, 35 | state 36 | }); 37 | } 38 | 39 | case actionTypes.SURVEY_RESET: { 40 | return {} 41 | } 42 | 43 | default: 44 | return state; 45 | } 46 | }; 47 | 48 | export default answerOptionsReducer; -------------------------------------------------------------------------------- /src/reducers/answersReducer.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../constants/actionTypes'; 2 | 3 | 4 | const answersReducer = (state = {}, action) => { 5 | switch (action.type) { 6 | case actionTypes.SURVEY_LOAD_SUCCESS: { 7 | const {answers} = action.payload.entities; 8 | 9 | return { 10 | ...answers 11 | }; 12 | } 13 | 14 | case actionTypes.SURVEY_RESET: { 15 | return {} 16 | } 17 | 18 | default: { 19 | return state; 20 | } 21 | } 22 | }; 23 | 24 | export default answersReducer; -------------------------------------------------------------------------------- /src/reducers/authReducer.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../constants/actionTypes'; 2 | 3 | const authReducer = (state = {}, action) => { 4 | switch (action.type) { 5 | case actionTypes.AUTH_SIGN_IN_SUCCESS: { 6 | return { 7 | ...state, 8 | authenticated: true, 9 | error: '' 10 | } 11 | } 12 | 13 | case actionTypes.AUTH_CLEAR_ERRORS: { 14 | return { 15 | ...state, 16 | error: '' 17 | } 18 | } 19 | 20 | case actionTypes.AUTH_SIGN_IN_ERROR: { 21 | return { 22 | ...state, 23 | error: action.payload 24 | } 25 | } 26 | 27 | case actionTypes.AUTH_SIGN_OUT_SUCCESS: { 28 | return { 29 | ...state, 30 | authenticated: false 31 | } 32 | } 33 | 34 | default: { 35 | return state; 36 | } 37 | } 38 | }; 39 | 40 | export default authReducer; -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import {routerReducer} from 'react-router-redux'; 3 | import {reducer as formReducer} from 'redux-form' 4 | 5 | import surveysReducer from './surveysReducer/surveysReducer'; 6 | import questionsReducer from './questionsReducer'; 7 | import answerOptionsReducer from './answerOptionsReducer'; 8 | import answersReducer from './answersReducer'; 9 | import authReducer from './authReducer'; 10 | 11 | export default combineReducers({ 12 | surveys: surveysReducer, 13 | questions: questionsReducer, 14 | answerOptions: answerOptionsReducer, 15 | answers: answersReducer, 16 | form: formReducer, 17 | auth: authReducer, 18 | routing: routerReducer 19 | }); -------------------------------------------------------------------------------- /src/reducers/questionsReducer.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../constants/actionTypes'; 2 | import update from 'immutability-helper'; 3 | 4 | import {bindFormDataToState} from '../utils/formDataUtils'; 5 | 6 | const questionsReducer = (state = {}, action) => { 7 | switch (action.type) { 8 | case actionTypes.SURVEY_LOAD_SUCCESS: { 9 | const {questions} = action.payload.entities; 10 | 11 | return { 12 | ...questions 13 | }; 14 | } 15 | 16 | case actionTypes.SURVEY_RESET: { 17 | return {} 18 | } 19 | 20 | case actionTypes.QUESTION_ADD_NEW: { 21 | const {question} = action.payload; 22 | 23 | return update(state, { 24 | $merge: { 25 | [question._id]: question 26 | } 27 | }); 28 | } 29 | 30 | case actionTypes.QUESTION_CHANGE_TYPE: { 31 | const { 32 | questionId, 33 | type 34 | } = action.payload; 35 | 36 | return update(state, { 37 | [questionId]: { 38 | $merge: { 39 | answerOptions: [], 40 | type 41 | } 42 | } 43 | }) 44 | } 45 | 46 | case actionTypes.ANSWER_OPTION_ADD_NEW: { 47 | const { 48 | answerOptionId, 49 | questionId 50 | } = action.payload; 51 | 52 | return update(state, { 53 | [questionId]: { 54 | answerOptions: { 55 | $push: [answerOptionId] 56 | } 57 | } 58 | }); 59 | } 60 | 61 | case actionTypes.SURVEY_BIND_FORM_DATA: { 62 | const {questions} = action.payload; 63 | 64 | return bindFormDataToState({ 65 | formFields: questions, 66 | state 67 | }); 68 | } 69 | 70 | default: 71 | return state; 72 | } 73 | }; 74 | 75 | export default questionsReducer; -------------------------------------------------------------------------------- /src/reducers/surveysReducer/surveysReducer.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import update from 'immutability-helper'; 3 | 4 | 5 | const surveysReducer = (state = {}, action) => { 6 | switch (action.type) { 7 | case actionTypes.SURVEY_LOAD_SUCCESS: { 8 | const {surveys} = action.payload.entities; 9 | 10 | return { 11 | ...surveys 12 | } 13 | } 14 | 15 | case actionTypes.SURVEY_INIT_NEW: { 16 | const {_id} = action.payload; 17 | 18 | return { 19 | [_id]: action.payload 20 | }; 21 | } 22 | 23 | case actionTypes.QUESTION_ADD_NEW: { 24 | const { 25 | question, 26 | surveyId 27 | } = action.payload; 28 | 29 | return update(state, { 30 | [surveyId]: { 31 | questions: { 32 | $push: [question._id] 33 | } 34 | } 35 | }); 36 | } 37 | 38 | case actionTypes.QUESTION_DELETE: { 39 | const { 40 | questionId, 41 | surveyId 42 | } = action.payload; 43 | 44 | const questionIndex = state[surveyId].questions.indexOf(questionId); 45 | 46 | return update(state, { 47 | [surveyId]: { 48 | questions: { 49 | $splice: [[questionIndex, 1]] 50 | } 51 | } 52 | }); 53 | } 54 | 55 | case actionTypes.SURVEY_RESET: { 56 | return {}; 57 | } 58 | 59 | case actionTypes.SURVEY_BIND_FORM_DATA: { 60 | const { 61 | surveyName, 62 | surveyDescription 63 | } = action.payload; 64 | 65 | const surveyId = Object.keys(state)[0]; 66 | 67 | return update(state, { 68 | [surveyId]: { 69 | $merge: { 70 | name: surveyName, 71 | description: surveyDescription 72 | } 73 | } 74 | }); 75 | } 76 | 77 | default: 78 | return state; 79 | } 80 | }; 81 | 82 | export default surveysReducer; 83 | -------------------------------------------------------------------------------- /src/reducers/surveysReducer/surveysReducer.test.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../../constants/actionTypes'; 2 | import surveysReducer from './surveysReducer'; 3 | 4 | 5 | describe('surveysReducer', () => { 6 | let initState; 7 | 8 | before(() => { 9 | initState = { 10 | Id_s1: { 11 | _id: 'Id_s1', 12 | name: 'Test 1', 13 | description: 'Test description 1', 14 | questions: [] 15 | } 16 | }; 17 | }); 18 | 19 | it('should add new loaded survey', () => { 20 | const action = { 21 | type: actionTypes.SURVEY_LOAD_SUCCESS, 22 | payload: { 23 | entities: { 24 | surveys: { 25 | Id_s2: { 26 | _id: 'Id_s2', 27 | name: 'Test 2', 28 | description: 'Test description 2', 29 | } 30 | } 31 | } 32 | } 33 | }; 34 | 35 | expect(surveysReducer(initState, action)).to.eql({ 36 | Id_s2: { 37 | _id: 'Id_s2', 38 | name: 'Test 2', 39 | description: 'Test description 2' 40 | } 41 | }); 42 | }); 43 | 44 | it('should init new survey', () => { 45 | const action = { 46 | type: actionTypes.SURVEY_INIT_NEW, 47 | payload: { 48 | _id: 'Id_s2', 49 | name: '', 50 | description: '', 51 | questions: [] 52 | } 53 | }; 54 | 55 | expect(surveysReducer(initState, action)).to.eql({ 56 | Id_s2: { 57 | _id: 'Id_s2', 58 | description: '', 59 | name: '', 60 | questions: [] 61 | } 62 | }); 63 | }); 64 | 65 | it('should add new question', () => { 66 | const action = { 67 | type: actionTypes.QUESTION_ADD_NEW, 68 | payload: { 69 | surveyId: 'Id_s1', 70 | question: { 71 | _id: 'Id_q1', 72 | title: '', 73 | type: 1, 74 | answerOptions: [] 75 | } 76 | } 77 | }; 78 | 79 | expect(surveysReducer(initState, action)).to.eql({ 80 | Id_s1: { 81 | _id: 'Id_s1', 82 | description: 'Test description 1', 83 | name: 'Test 1', 84 | questions: [ 85 | 'Id_q1' 86 | ] 87 | } 88 | }) 89 | }); 90 | 91 | it('should delete question', () => { 92 | const initStateWithQuestion = { 93 | Id_s1: { 94 | _id: 'Id_s1', 95 | name: 'Test 1', 96 | description: 'Test description 1', 97 | questions: ['Id_q1', 'Id_q2', 'Id_q3'] 98 | } 99 | }; 100 | 101 | const action = { 102 | type: actionTypes.QUESTION_DELETE, 103 | payload: { 104 | questionId: 'Id_q2', 105 | surveyId: 'Id_s1' 106 | } 107 | }; 108 | 109 | expect(surveysReducer(initStateWithQuestion, action)).to.eql({ 110 | Id_s1: { 111 | _id: 'Id_s1', 112 | description: 'Test description 1', 113 | name: 'Test 1', 114 | questions: ['Id_q1', 'Id_q3'] 115 | } 116 | }) 117 | }); 118 | 119 | it('should reset state', () => { 120 | const action = { 121 | type: actionTypes.SURVEY_RESET 122 | }; 123 | 124 | expect(surveysReducer(initState, action)).to.eql({}) 125 | }); 126 | 127 | it('should bind form data', () => { 128 | const action = { 129 | type: actionTypes.SURVEY_BIND_FORM_DATA, 130 | payload: { 131 | surveyName: 'Test 2', 132 | surveyDescription: 'Test description 2' 133 | } 134 | }; 135 | 136 | expect(surveysReducer(initState, action)).to.eql({ 137 | Id_s1: { 138 | _id: 'Id_s1', 139 | name: 'Test 2', 140 | description: 'Test description 2', 141 | questions: [] 142 | } 143 | }); 144 | }); 145 | 146 | it('should keep state immutable', () => { 147 | const action = { 148 | type: actionTypes.SURVEY_RESET 149 | }; 150 | 151 | surveysReducer(initState, action); 152 | 153 | expect(initState).to.eql({ 154 | Id_s1: { 155 | _id: 'Id_s1', 156 | name: 'Test 1', 157 | description: 'Test description 1', 158 | questions: [] 159 | } 160 | }); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/selectors/getAnswerOptions.js: -------------------------------------------------------------------------------- 1 | const getAnswerOptions = (state, questionId) => { 2 | return state.questions[questionId].answerOptions.map((answerOptionId) => 3 | state.answerOptions[answerOptionId] 4 | ); 5 | }; 6 | 7 | 8 | export default getAnswerOptions; -------------------------------------------------------------------------------- /src/selectors/getDenormalizedSurvey.js: -------------------------------------------------------------------------------- 1 | import {denormalize} from 'normalizr'; 2 | 3 | import survey from '../models/schema'; 4 | 5 | const getDenormalizedSurvey = (state) => { 6 | return denormalize(Object.keys(state.surveys)[0], survey, {...state}); 7 | }; 8 | 9 | export default getDenormalizedSurvey; 10 | -------------------------------------------------------------------------------- /src/selectors/getInitialFormBuilderValues.js: -------------------------------------------------------------------------------- 1 | const getInitialFormBuilderValues = (state) => { 2 | const surveyId = Object.keys(state.surveys)[0]; // Current implementation allows only one survey 3 | 4 | if (!surveyId) { 5 | return undefined; 6 | } 7 | 8 | const survey = state.surveys[surveyId]; 9 | 10 | return { 11 | surveyName: survey.name, 12 | surveyDescription: survey.description, 13 | questions: Object.keys(state.questions).reduce((result, nextKey) => { 14 | result[nextKey] = state.questions[nextKey].title; 15 | return result; 16 | }, {}), 17 | answerOptions: Object.keys(state.answerOptions).reduce((result, nextKey) => { 18 | result[nextKey] = state.answerOptions[nextKey].title; 19 | return result; 20 | }, {}) 21 | }; 22 | 23 | 24 | }; 25 | 26 | 27 | export default getInitialFormBuilderValues; 28 | 29 | -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import {configure} from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | import {JSDOM} from 'jsdom' 4 | import {expect} from 'chai'; 5 | import {use} from 'chai'; 6 | import chaiExclude from 'chai-exclude'; 7 | 8 | 9 | use(chaiExclude); 10 | 11 | configure({adapter: new Adapter()}); 12 | 13 | const dom = new JSDOM(''); 14 | 15 | global.window = dom.window; 16 | global.window.requestAnimationFrame = (callback) => setTimeout(callback, 0); // A shim for testing animations 17 | global.document = dom.window.document; 18 | global.history = dom.history; 19 | 20 | global.expect = expect; // Use chai assert library for assertions 21 | -------------------------------------------------------------------------------- /src/styles/core/reset.scss: -------------------------------------------------------------------------------- 1 | /* Positioning */ 2 | /* Box-model */ 3 | /* Typography */ 4 | /* Visual */ 5 | /* Misc */ 6 | 7 | * { 8 | outline: 0; 9 | padding: 0; 10 | } 11 | 12 | abbr[title] { 13 | text-decoration: none; 14 | } 15 | 16 | h1, h2, h3, h4, h5, h6 { 17 | margin: 0; 18 | } 19 | 20 | button { 21 | border-radius: 0; 22 | } -------------------------------------------------------------------------------- /src/styles/core/variables.scss: -------------------------------------------------------------------------------- 1 | $font-family: 'Roboto', sans-serif; 2 | 3 | $font-size-small: 13px; 4 | $font-size-regular: 16px; 5 | $font-size-large: 24px; 6 | $font-size-x-large: 36px; 7 | 8 | 9 | $font-weight-light: 200; 10 | $font-weight-regular: 300; 11 | 12 | $color-white: #ffffff; 13 | $color-grey: #b0b0b0; 14 | $color-grey-light: #eeeeee; 15 | $color-black: #2E2929; 16 | $color-blue: #4285f4; 17 | $color-blue-dark: #3F51B5; 18 | $color-blue-light: #e9eaf7; 19 | $color-blue-lightest: #f7f8fc; 20 | $color-red: #DC6D7F; 21 | 22 | 23 | $media-xs: 400px; 24 | $media-md: 600px; 25 | $media-xl: 950px; 26 | -------------------------------------------------------------------------------- /src/styles/layouts/layout.scss: -------------------------------------------------------------------------------- 1 | .container { 2 | margin: 124px auto 60px auto; 3 | padding: 0 15px; 4 | max-width: 600px; 5 | } 6 | -------------------------------------------------------------------------------- /src/styles/modules/buttons.scss: -------------------------------------------------------------------------------- 1 | @import "../core/variables.scss"; 2 | 3 | @mixin button-base { 4 | font-size: 14px; 5 | text-transform: uppercase; 6 | text-align: center; 7 | cursor: pointer; 8 | border: 0; 9 | } 10 | 11 | .button-raised { 12 | @include button-base; 13 | line-height: 36px; 14 | padding: 0 16px; 15 | color: #FFFFFF; 16 | background-color: $color-blue; 17 | border-radius: 2px; 18 | &:hover { 19 | transition: all 0.35s cubic-bezier(0.23, 1, 0.32, 1); 20 | box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.3); 21 | } 22 | 23 | &--disabled { 24 | @include button-base; 25 | line-height: 36px; 26 | padding: 0 16px; 27 | border-radius: 2px; 28 | background-color: $color-grey-light; 29 | color: $color-grey; 30 | cursor: default; 31 | } 32 | } 33 | 34 | .button-flat { 35 | @include button-base; 36 | color: $color-blue; 37 | background-color: transparent; 38 | 39 | &--active { 40 | display: inline-block; 41 | padding-bottom: 3px; 42 | border-bottom: 1px solid $color-blue; 43 | cursor: default; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/styles/modules/error-message.scss: -------------------------------------------------------------------------------- 1 | @import "../core/variables.scss"; 2 | 3 | .error-message { 4 | margin-bottom: 10px; 5 | padding: 5px; 6 | font-size: $font-size-small; 7 | background-color: $color-red; 8 | color: $color-white; 9 | } 10 | -------------------------------------------------------------------------------- /src/styles/modules/form.scss: -------------------------------------------------------------------------------- 1 | @import "../core/variables.scss"; 2 | 3 | .input { 4 | display: inline-block; 5 | box-sizing: border-box; 6 | width: 100%; 7 | font-size: $font-size-regular; 8 | font-weight: $font-weight-light; 9 | line-height: 36px; 10 | border-width: 0; 11 | border-bottom: 1px solid rgba(33, 33, 33, 0.22); 12 | 13 | &:focus { 14 | border-color: $color-blue; 15 | transition: all 0.45s cubic-bezier(0.23, 1, 0.32, 1); 16 | } 17 | 18 | &__has-error { 19 | border-color: $color-red 20 | } 21 | 22 | &__has-error:focus { 23 | border-color: $color-red 24 | } 25 | 26 | &__error-message { 27 | display: inline-block; 28 | margin-top: 7px; 29 | font-size: 13px; 30 | color: $color-red; 31 | } 32 | } 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/styles/modules/navigation.scss: -------------------------------------------------------------------------------- 1 | @import "../core/variables.scss"; 2 | 3 | .navigation { 4 | &__menu { 5 | margin: 0 15px; 6 | line-height: 64px; 7 | font-size: $font-size-x-large; 8 | cursor: pointer; 9 | color: $color-white; 10 | } 11 | 12 | &__list { 13 | margin: 0 15px; 14 | max-height: 0; 15 | overflow: hidden; 16 | line-height: 64px; 17 | font-size: 18px; 18 | list-style-type: none; 19 | transition: all 0.35s cubic-bezier(0.23, 1, 0.32, 1); 20 | 21 | &--visible { 22 | max-height: 500px; 23 | } 24 | } 25 | 26 | &__list-item { 27 | &:not(:last-child) { 28 | margin-right: 15px; 29 | } 30 | } 31 | 32 | &__link { 33 | text-decoration: none; 34 | font-weight: 300; 35 | color: #FFFFFF; 36 | 37 | &--active { 38 | padding-bottom: 3px; 39 | border-bottom: 1px solid #FFFFFF; 40 | } 41 | } 42 | } 43 | 44 | @media screen and (min-width: $media-md) { 45 | .navigation { 46 | &__menu { 47 | display: none; 48 | } 49 | 50 | &__list { 51 | display: flex; 52 | max-height: 500px; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/styles/modules/spinner.scss: -------------------------------------------------------------------------------- 1 | .spinner-holder { 2 | position: absolute; 3 | top: 50%; 4 | left: 50%; 5 | transform: translate(-50%, -50%); 6 | } -------------------------------------------------------------------------------- /src/styles/modules/typography.scss: -------------------------------------------------------------------------------- 1 | @import "../core/variables.scss"; 2 | 3 | html { 4 | color: $color-black; 5 | font-family: $font-family; 6 | font-size: $font-size-regular; 7 | font-weight: $font-weight-regular; 8 | text-rendering: optimizeLegibility; 9 | } 10 | 11 | 12 | /* ------------ HEADINGS --------------- */ 13 | 14 | h1, h2 { 15 | font-weight: $font-weight-regular; 16 | } 17 | 18 | h1 { 19 | font-size: $font-size-x-large; 20 | } 21 | 22 | h2 { 23 | font-size: $font-size-large; 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/styles/pages/App.scss: -------------------------------------------------------------------------------- 1 | @import '../core/variables.scss'; 2 | 3 | .app { 4 | &__header { 5 | position: fixed; 6 | top: 0; 7 | width: 100vw; 8 | background: $color-blue-dark; 9 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); 10 | } 11 | } 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/styles/pages/LoginForm.scss: -------------------------------------------------------------------------------- 1 | .login-form { 2 | &__field { 3 | margin-bottom: 10px; 4 | } 5 | 6 | &__submit { 7 | margin-top: 30px; 8 | } 9 | } -------------------------------------------------------------------------------- /src/styles/pages/SurveyBuilder.scss: -------------------------------------------------------------------------------- 1 | @import "../core/variables.scss"; 2 | 3 | .survey-builder { 4 | &__heading { 5 | margin-bottom: 60px; 6 | } 7 | 8 | &__title { 9 | display: block; 10 | width: 100%; 11 | font-size: $font-size-large; 12 | } 13 | 14 | &__description { 15 | display: block; 16 | width: 100%; 17 | font-size: $font-size-small; 18 | } 19 | 20 | &__question { 21 | margin-top: 60px; 22 | } 23 | 24 | &__radio-button-icon { 25 | margin-top: 5px; 26 | color: $color-grey; 27 | } 28 | 29 | &__answer-options { 30 | margin-bottom: 10px; 31 | list-style: none; 32 | 33 | li { 34 | display: flex; 35 | margin-top: 10px; 36 | align-items: flex-start; 37 | } 38 | } 39 | 40 | &__answer-option { 41 | margin-left: 10px; 42 | width: 100%; 43 | } 44 | 45 | &__add-option { 46 | display: flex; 47 | align-items: flex-start; 48 | 49 | button { 50 | margin-left: 10px; 51 | line-height: 36px 52 | } 53 | } 54 | 55 | &__add-question-button { 56 | margin: 30px 0 30px -5px; 57 | font-size: 35px; 58 | color: $color-blue; 59 | cursor: pointer; 60 | } 61 | 62 | &__question-control-group { 63 | display: flex; 64 | margin-top: 35px; 65 | align-items: center; 66 | 67 | ul { 68 | display: flex; 69 | list-style: none; 70 | 71 | li { 72 | margin-right: 15px; 73 | } 74 | } 75 | } 76 | 77 | &__remove-question-button { 78 | margin-left: auto; 79 | color: $color-blue; 80 | cursor: pointer; 81 | } 82 | 83 | @media screen and (min-width: $media-md) { 84 | &__answer-option { 85 | width: 50% 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/styles/pages/SurveyForm.scss: -------------------------------------------------------------------------------- 1 | .survey-form { 2 | margin-top: 35px; 3 | 4 | &__description { 5 | margin-top: 10px; 6 | } 7 | 8 | &__question { 9 | margin-top: 30px; 10 | } 11 | 12 | &__submit { 13 | margin-top: 50px; 14 | } 15 | 16 | &__radio-group-list { 17 | margin-top: 15px; 18 | list-style: none; 19 | } 20 | 21 | &__radio-group-list-item { 22 | display: flex; 23 | margin-top: 15px; 24 | align-items: baseline; 25 | 26 | label { 27 | margin-left: 10px; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /src/styles/pages/SurveyResponses.scss: -------------------------------------------------------------------------------- 1 | @import "../core/variables.scss"; 2 | 3 | .survey-responses { 4 | &__description { 5 | margin-top: 10px; 6 | } 7 | 8 | &__question { 9 | margin-top: 30px; 10 | } 11 | 12 | &__answer-list { 13 | list-style: none; 14 | } 15 | 16 | &__answer-list-item { 17 | padding: 7px 10px; 18 | font-size: $font-size-small; 19 | font-weight: normal; 20 | line-height: 1.5; 21 | } 22 | 23 | &__answer-list-item:nth-of-type(2n+1) { 24 | background-color: $color-blue-light; 25 | } 26 | 27 | &__answer-list-item:nth-of-type(2n) { 28 | background-color: $color-blue-lightest; 29 | } 30 | 31 | &__doughnut-chart { 32 | margin-top: 8px; 33 | } 34 | 35 | &__bar-chart { 36 | margin-top: 10px; 37 | } 38 | } -------------------------------------------------------------------------------- /src/utils/chartUtils.js: -------------------------------------------------------------------------------- 1 | /*** 2 | * Define label names of a chart 3 | * 4 | * @param answerOptions 5 | * @returns {Array} a list of answer option titles 6 | */ 7 | export const populateLabelValues = ({answerOptions}) => { 8 | return answerOptions.map(answerOption => answerOption.title); 9 | }; 10 | 11 | /*** 12 | * Populate how many instances of each answer option title exists in answers provided 13 | * 14 | * @param question object containing answerOptions and answers 15 | * @returns {Array} a list of how much each answer option occurred in the answers 16 | */ 17 | export const populateChartData = ({question}) => { 18 | return question.answerOptions.map(answerOption => { 19 | const title = answerOption.title; 20 | 21 | // Get how many instances of a given title exist 22 | return question.answers 23 | .filter(answer => answer.answerText === title) 24 | .length 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /src/utils/formDataUtils.js: -------------------------------------------------------------------------------- 1 | /*** 2 | * Bind reduxForm fields to appropriate reducer. 3 | * Ex: form answerOptions to answerOptionsReducer 4 | * 5 | * @param formFields form fields by pattern {id: title, id2: title} 6 | * @param state existing reducer state 7 | * @returns {*} new state instance 8 | */ 9 | 10 | export const bindFormDataToState = ({formFields, state}) => { 11 | return Object.keys(state).reduce((result, nextKey) => { 12 | if (formFields[nextKey]) { 13 | result[nextKey] = { 14 | ...state[nextKey], 15 | title: formFields[nextKey] 16 | } 17 | } else { 18 | result[nextKey] = state[nextKey]; 19 | } 20 | 21 | return result; 22 | }, {}); 23 | }; 24 | --------------------------------------------------------------------------------