├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------