├── .eslintignore ├── static └── images │ └── logo.png ├── gatsby-ssr.js ├── src ├── styles │ ├── index.css │ ├── constants.js │ └── typography.js ├── pages │ ├── 404.js │ ├── draft.js │ ├── results.js │ ├── index.js │ ├── thankyou.js │ └── survey.js ├── components │ ├── ui │ │ ├── Image.js │ │ ├── Heading.js │ │ ├── Tooltip.js │ │ ├── Card.js │ │ ├── Link.js │ │ ├── CodeBlock.js │ │ ├── Text.js │ │ ├── PageLayout.js │ │ ├── Blockquote.js │ │ ├── Progress.js │ │ ├── Container.js │ │ ├── Header.js │ │ ├── Markdown.js │ │ ├── MarkdownImage.js │ │ ├── List.js │ │ ├── Slider.js │ │ ├── TextArea.js │ │ ├── RatingStar.js │ │ ├── Button.js │ │ ├── ProgressItem.js │ │ ├── CheckboxRadioInput.js │ │ └── PageSpinner.js │ ├── survey │ │ ├── answer │ │ │ ├── AnswerLayout.js │ │ │ ├── RadioAnswer.js │ │ │ ├── SliderAnswer.js │ │ │ ├── CheckboxAnswer.js │ │ │ ├── RatingAnswer.js │ │ │ ├── ButtonAnswer.js │ │ │ └── SurveyAnswer.js │ │ ├── SurveyContent.js │ │ ├── SurveyPageLayout.js │ │ ├── SurveyQuestion.js │ │ └── SurveyProgress.js │ ├── draft │ │ └── DraftContent.js │ ├── results │ │ └── ResultsContent.js │ ├── SurveyHeader.js │ └── Root.js ├── store │ ├── survey │ │ ├── index.js │ │ ├── responses.js │ │ ├── questions.js │ │ └── session.js │ ├── results │ │ └── index.js │ ├── draft │ │ └── index.js │ ├── utils.js │ ├── configureStore.js │ └── surveySelectors.js ├── hooks │ └── useHotKeys.js └── enums.js ├── .gitignore ├── survey ├── responses.json ├── questions │ ├── 06_rating_surveyless_experience.md │ ├── 01_excited_about_surveyless.md │ ├── 08_single_choice_best_layout.md │ ├── 15_feedback.md │ ├── 13_TODO_ranking_survey_features.md │ ├── 11_additional_comments_overview.md │ ├── 07_likert_best_layout.md │ ├── 12_slider_fun.md │ ├── 09_multiple_choice_web_stack.md │ ├── 04_likert_overview.md │ ├── 10_multiple_choice_web_stack_checkbox.md │ ├── 14_TODO_matrix_feature_importance_rating.md │ ├── 02_ready_to_explore_markdown.md │ ├── 05_likert_radio_vs_buttons.md │ └── 03_ready_to_explore_question_types.md ├── thankyou.md └── theme.json ├── .editorconfig ├── gatsby-browser.js ├── .eslintrc ├── gatsby-config.js ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | # Third party 2 | **/node_modules 3 | 4 | # Build products 5 | public 6 | -------------------------------------------------------------------------------- /static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisrzhou/surveyless/HEAD/static/images/logo.png -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | // HACK: gatsby build 2 | 3 | import Root from 'components/Root'; 4 | export const wrapRootElement = Root; 5 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | html { 2 | box-sizing: border-box; 3 | } 4 | body { 5 | margin: 0; 6 | overflow-x: hidden; 7 | padding: 0; 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node 2 | node_modules 3 | 4 | # logs 5 | *.log* 6 | 7 | # files and IDE 8 | .DS_STORE 9 | *~ 10 | *.swp 11 | *.swo 12 | 13 | # project 14 | .cache 15 | public 16 | -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SurveyPageLayout from 'components/survey/SurveyPageLayout'; 3 | 4 | function NotFoundPage() { 5 | return ; 6 | } 7 | 8 | export default NotFoundPage; 9 | -------------------------------------------------------------------------------- /survey/responses.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "response_id": "a", 4 | "question_id": 1, 5 | "answer_id": 2 6 | }, 7 | { 8 | "response_id": "a", 9 | "question_id": 2, 10 | "answer_id": 3 11 | } 12 | ] 13 | -------------------------------------------------------------------------------- /src/components/ui/Image.js: -------------------------------------------------------------------------------- 1 | import {Box} from 'rebass'; 2 | import React from 'react'; 3 | 4 | function Image({src, width, ...otherProps}) { 5 | return ; 6 | } 7 | 8 | export default Image; 9 | -------------------------------------------------------------------------------- /src/store/survey/index.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | import questions from './questions'; 3 | import responses from './responses'; 4 | import session from './session'; 5 | 6 | export default combineReducers({ 7 | questions, 8 | responses, 9 | session, 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/ui/Heading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Text} from 'rebass'; 3 | 4 | function Heading({level, children, ...otherProps}) { 5 | return ( 6 | 7 | {children} 8 | 9 | ); 10 | } 11 | 12 | export default Heading; 13 | -------------------------------------------------------------------------------- /src/styles/constants.js: -------------------------------------------------------------------------------- 1 | export const SURVEYLESS_BRAND_COLOR = '#4b98e5'; 2 | export const SURVEYLESS_DARK_BRAND_COLOR = '#1d3b59'; 3 | export const SURVEYLESS_GRAY = '#a0afba'; 4 | export const SURVEYLESS_LIGHT_GRAY = '#F0F0F0'; 5 | export const DISABLED_OPACITY = 0.4; 6 | export const FOCUS_HOVER_OPACITY = 0.7; 7 | -------------------------------------------------------------------------------- /src/styles/typography.js: -------------------------------------------------------------------------------- 1 | import Typography from 'typography'; 2 | import theme from './../../survey/theme.json'; 3 | 4 | const typography = new Typography({ 5 | ...theme.typography, 6 | headerColor: theme.colors.headerText, 7 | bodyColor: theme.colors.primaryText, 8 | }); 9 | 10 | export default typography; 11 | -------------------------------------------------------------------------------- /src/components/ui/Tooltip.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactTooltip from 'react-tooltip-lite'; 3 | 4 | function Tooltip({children, content, otherProps}) { 5 | return ( 6 | 7 | {children} 8 | 9 | ); 10 | } 11 | 12 | export default Tooltip; 13 | -------------------------------------------------------------------------------- /survey/questions/06_rating_surveyless_experience.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 6 3 | text: How would you rate the experience of surveyless so far? 4 | questionType: LIKERT 5 | choiceType: RATING 6 | choices: [Very Bad, Bad, Neutral, Good, Very Good] 7 | additionalComments: false 8 | --- 9 | 10 | Rating scales are a concise and valid way to represent likert questions. 11 | -------------------------------------------------------------------------------- /survey/questions/01_excited_about_surveyless.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 1 3 | text: Hiya! How excited are you about surveyless? 4 | questionType: LIKERT 5 | choiceType: VERTICAL_RADIO 6 | choices: 7 | [ 8 | Not Excited at All, 9 | Somewhat Unexcited, 10 | Neutral, 11 | Somewhat Excited, 12 | Very Excited, 13 | ] 14 | additionalComments: false 15 | --- 16 | -------------------------------------------------------------------------------- /src/pages/draft.js: -------------------------------------------------------------------------------- 1 | import DraftContent from 'components/draft/DraftContent'; 2 | import PageLayout from 'components/ui/PageLayout'; 3 | import React from 'react'; 4 | import SurveyHeader from 'components/SurveyHeader'; 5 | 6 | function DraftPage() { 7 | return } content={} />; 8 | } 9 | 10 | export default DraftPage; 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | 17 | [COMMIT_EDITMSG] 18 | max_line_length = 0 19 | -------------------------------------------------------------------------------- /src/pages/results.js: -------------------------------------------------------------------------------- 1 | import PageLayout from 'components/ui/PageLayout'; 2 | import React from 'react'; 3 | import ResultsContent from 'components/results/ResultsContent'; 4 | import SurveyHeader from 'components/SurveyHeader'; 5 | 6 | function ResultsPage() { 7 | return } content={} />; 8 | } 9 | 10 | export default ResultsPage; 11 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | // HACK: gatsby build 2 | 3 | import Root from 'components/Root'; 4 | import typography from 'gatsby-plugin-typography/.cache/typography.js'; 5 | 6 | export const wrapRootElement = Root; 7 | 8 | // HACK: Hot reload typography in development. 9 | export function onClientEntry() { 10 | if (process.env.NODE_ENV !== `production`) { 11 | typography.injectStyles(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/components/survey/answer/AnswerLayout.js: -------------------------------------------------------------------------------- 1 | import List from 'components/ui/List'; 2 | import React from 'react'; 3 | 4 | function AnswerLayout({children, isVertical}) { 5 | return ( 6 | 10 | {children} 11 | 12 | ); 13 | } 14 | 15 | export default AnswerLayout; 16 | -------------------------------------------------------------------------------- /src/components/ui/Card.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Card as RebassCard} from 'rebass'; 3 | 4 | function Card({children}) { 5 | return ( 6 | 12 | {children} 13 | 14 | ); 15 | } 16 | 17 | export default Card; 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["fbjs", "prettier"], 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "relay/graphql-syntax": "ignore", 6 | "prettier/prettier": [ 7 | "error", 8 | { 9 | "bracketSpacing": false, 10 | "jsxBracketSameLine": true, 11 | "singleQuote": true, 12 | "trailingComma": "all", 13 | "useTabs": false 14 | } 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/store/results/index.js: -------------------------------------------------------------------------------- 1 | import {createAction, createActionTypes, createReducer} from 'store/utils'; 2 | 3 | export const actionTypes = createActionTypes('results', ['INITIALIZE']); 4 | 5 | export const actions = { 6 | initialize: createAction(actionTypes.INITIALIZE), 7 | }; 8 | 9 | export default createReducer( 10 | {}, 11 | { 12 | [actionTypes.INITIALIZE]: (state, _action) => ({...state}), 13 | }, 14 | ); 15 | -------------------------------------------------------------------------------- /survey/questions/08_single_choice_best_layout.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 8 3 | text: Which choice set layout do you like best for single-choice questions? 4 | questionType: SINGLE_CHOICE 5 | choiceType: HORIZONTAL_BUTTON 6 | choices: [Vertical Radio, Horizontal Radio, Vertical Button, Horizontal Button] 7 | additionalComments: false 8 | --- 9 | 10 | Similar to likert questions, `surveyless` provides various ways to render single-choice choice sets. 11 | -------------------------------------------------------------------------------- /src/components/survey/SurveyContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SurveyPageLayout from './SurveyPageLayout'; 3 | import SurveyProgress from './SurveyProgress'; 4 | import SurveyQuestion from './SurveyQuestion'; 5 | 6 | function SurveyContent() { 7 | return ( 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | 15 | export default SurveyContent; 16 | -------------------------------------------------------------------------------- /src/components/ui/Link.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Link as RebassLink} from 'rebass'; 3 | 4 | function Link({children, href, isExternal}) { 5 | return ( 6 | 10 | {children} 11 | 12 | ); 13 | } 14 | 15 | Link.defaultProps = { 16 | isExternal: true, 17 | }; 18 | 19 | export default Link; 20 | -------------------------------------------------------------------------------- /src/components/ui/CodeBlock.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter'; 3 | import {ghcolors} from 'react-syntax-highlighter/dist/styles/prism'; 4 | 5 | export default function({value, language}) { 6 | return ( 7 | 11 | {value} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /src/hooks/useHotKeys.js: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react'; 2 | 3 | function useHotKeys(handlers) { 4 | function handleKeyDown(e) { 5 | if (e.keyCode in handlers) { 6 | handlers[e.keyCode](e); 7 | } 8 | } 9 | useEffect(() => { 10 | document.body.addEventListener('keydown', handleKeyDown); 11 | return () => { 12 | document.body.removeEventListener('keydown', handleKeyDown); 13 | }; 14 | }); 15 | } 16 | 17 | export default useHotKeys; 18 | -------------------------------------------------------------------------------- /survey/questions/15_feedback.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 15 3 | text: Please provide any additional feedback on the surveyless project! 4 | questionType: COMMENT 5 | choiceType: null 6 | choices: [] 7 | additionalComments: false 8 | --- 9 | 10 | ## Reaching the end! 11 | 12 | **Good job** on reaching the last question of this live survey demo! 13 | 14 | We hope that you are able to better understand the features of `surveyless`. Let's wrap up the survey demo with the final **Comments** question type: 15 | -------------------------------------------------------------------------------- /src/store/draft/index.js: -------------------------------------------------------------------------------- 1 | import {createAction, createActionTypes, createReducer} from 'store/utils'; 2 | 3 | const actionTypes = createActionTypes('draft', ['SET_PREVIEW_MODE']); 4 | 5 | const initialState = { 6 | isPreviewMode: false, 7 | }; 8 | 9 | export const actions = { 10 | setPreviewMode: createAction(actionTypes.SET_PREVIEW_MODE), 11 | }; 12 | 13 | export default createReducer(initialState, { 14 | [actionTypes.SET_PREVIEW_MODE]: (state, {payload}) => ({ 15 | ...state, 16 | isPreviewMode: payload, 17 | }), 18 | }); 19 | -------------------------------------------------------------------------------- /src/components/draft/DraftContent.js: -------------------------------------------------------------------------------- 1 | import Button from 'components/ui/Button'; 2 | import Card from 'components/ui/Card'; 3 | import Container from 'components/ui/Container'; 4 | import {Link} from 'gatsby'; 5 | import React from 'react'; 6 | import {routes} from 'enums'; 7 | 8 | function DraftContent() { 9 | return ( 10 | 11 | TODO (DRAFT) 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default DraftContent; 20 | -------------------------------------------------------------------------------- /src/components/results/ResultsContent.js: -------------------------------------------------------------------------------- 1 | import Button from 'components/ui/Button'; 2 | import Card from 'components/ui/Card'; 3 | import Container from 'components/ui/Container'; 4 | import {Link} from 'gatsby'; 5 | import React from 'react'; 6 | import {routes} from 'enums'; 7 | 8 | function ResultsContent() { 9 | return ( 10 | 11 | TODO (RESULTS) 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default ResultsContent; 20 | -------------------------------------------------------------------------------- /src/components/ui/Text.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Text as RebassText} from 'rebass'; 3 | 4 | function Text({children, isMono, ...otherProps}) { 5 | return ( 6 | 15 | {children} 16 | 17 | ); 18 | } 19 | 20 | Text.defaultProps = { 21 | isMono: false, 22 | }; 23 | 24 | export default Text; 25 | -------------------------------------------------------------------------------- /src/components/ui/PageLayout.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex} from 'rebass'; 2 | 3 | import React from 'react'; 4 | 5 | function PageLayout({header, children}) { 6 | return ( 7 | 8 | 19 | {header} 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | export default PageLayout; 26 | -------------------------------------------------------------------------------- /survey/questions/13_TODO_ranking_survey_features.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 13 3 | text: When working with surveys, which features are most important to you? 4 | questionType: SINGLE_CHOICE 5 | choiceType: HORIZONTAL_RADIO 6 | choices: 7 | [Good UI, Easy to setup, Fast to setup, Easy to clone/duplicate, Customizable] 8 | additionalComments: false 9 | --- 10 | 11 | ## Ranking Question Overview 12 | 13 | Ranking questions are extended forms of multi-choice questions that allow users to select and rank multiple answer choices in an order. `surveyless` provides a drag-and-drop UI to answer ranking questions. 14 | -------------------------------------------------------------------------------- /survey/thankyou.md: -------------------------------------------------------------------------------- 1 | # Thank you for checking out `surveyless`! ❤️ 2 | 3 | We hope that this live survey demo has been helpful. Here are some recommended next steps on what to check out! 4 | 5 | - 📖 Read the [/docs](https://github.com/chrisrzhou/surveyless/docs) 6 | - 💻 [Clone the repo](https://github.com/chrisrzhou/surveyless) or [deploy an instance](https://app.netlify.com/start/deploy?repository=https://github.com/chrisrzhou/surveyless) on Netlify 7 | - 😸 Watch some cat videos 8 | 9 | 14 | 15 | -------------------------------------------------------------------------------- /src/store/survey/responses.js: -------------------------------------------------------------------------------- 1 | import {createAction, createActionTypes, createReducer} from 'store/utils'; 2 | 3 | export const actionTypes = createActionTypes('survey/responses', [ 4 | 'SET_RESPONSE', 5 | ]); 6 | 7 | export const actions = { 8 | setResponse: createAction(actionTypes.SET_RESPONSE), 9 | }; 10 | 11 | const initialState = {}; 12 | 13 | export default createReducer(initialState, { 14 | [actionTypes.SET_RESPONSE]: (state, {payload}) => { 15 | return { 16 | ...state, 17 | [payload.questionId]: { 18 | ...state[payload.questionId], 19 | ...payload, 20 | }, 21 | }; 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/components/ui/Blockquote.js: -------------------------------------------------------------------------------- 1 | import {Box} from 'rebass'; 2 | import React from 'react'; 3 | 4 | function Blockquote({children}) { 5 | return ( 6 | 23 | {children} 24 | 25 | ); 26 | } 27 | 28 | export default Blockquote; 29 | -------------------------------------------------------------------------------- /src/store/utils.js: -------------------------------------------------------------------------------- 1 | export const createActionTypes = (namespace, actionNames) => { 2 | let actionTypes = {}; 3 | actionNames.forEach(actionName => { 4 | actionTypes[actionName] = `${namespace}/${actionName}`; 5 | }); 6 | return actionTypes; 7 | }; 8 | 9 | export const createAction = type => payload => ({ 10 | type, 11 | payload, 12 | }); 13 | 14 | export const createReducer = (initialState, handlers) => { 15 | return (state = initialState, action) => { 16 | if (!action) { 17 | return state; 18 | } 19 | const handler = handlers[action.type]; 20 | return handler ? handler(state, action) : state; 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /survey/questions/11_additional_comments_overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 11 3 | text: Do you like the additional comments feature in surveyless? 4 | questionType: SINGLE_CHOICE 5 | choiceType: HORIZONTAL_BUTTON 6 | choices: [Yes, No] 7 | additionalComments: true 8 | --- 9 | 10 | All questions in `surveyless` can be supplemented with an `additionalComments` section. Simply turn it on in the frontmatter of the markdown, and you can collect additional open-ended comments for the question. 11 | 12 | This is a useful feature if you want to collect additional data to learn more about the question. It is also common to include additional comments in single-choice or multi-choice questions as a catch-all for answer choices that might have been left out. 13 | -------------------------------------------------------------------------------- /src/components/ui/Progress.js: -------------------------------------------------------------------------------- 1 | import List from './List'; 2 | import ProgressItem from './ProgressItem'; 3 | import React from 'react'; 4 | 5 | function Progress({currentIndex, items, onItemClick}) { 6 | return ( 7 | 8 | {items.map(({disabled, id, isCompleted, tooltip}, index) => { 9 | return ( 10 | onItemClick(index)} 17 | tooltip={tooltip} 18 | /> 19 | ); 20 | })} 21 | 22 | ); 23 | } 24 | 25 | export default Progress; 26 | -------------------------------------------------------------------------------- /survey/questions/07_likert_best_layout.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 7 3 | text: Which choice set layout do you like best for likert questions? 4 | questionType: SINGLE_CHOICE 5 | choiceType: VERTICAL_RADIO 6 | choices: [Radio, Buttons, Star Rating] 7 | additionalComments: false 8 | --- 9 | 10 | ## Single-choice Question Overview 11 | 12 | Single-choice questions, as the name suggests, only accept a single answer choice to the question. They are different from likert questions because the answer choices cannot be scored or scaled. 13 | 14 | In general, all likert questions are single-choice questions, but not all single-choice questions are likert questions. 15 | 16 | As an example, let's ask a single-choice question about the choice layout options for likert questions: 17 | -------------------------------------------------------------------------------- /src/store/survey/questions.js: -------------------------------------------------------------------------------- 1 | import {createAction, createActionTypes, createReducer} from 'store/utils'; 2 | 3 | import {combineReducers} from 'redux'; 4 | 5 | export const actionTypes = createActionTypes('survey/questions', [ 6 | 'INITIALIZE', 7 | ]); 8 | 9 | export const actions = { 10 | initialize: createAction(actionTypes.INITIALIZE), 11 | }; 12 | 13 | const byId = createReducer( 14 | {}, 15 | { 16 | [actionTypes.INITIALIZE]: (state, {payload}) => { 17 | return payload.byId; 18 | }, 19 | }, 20 | ); 21 | 22 | const allIds = createReducer([], { 23 | [actionTypes.INITIALIZE]: (state, {payload}) => { 24 | return payload.allIds; 25 | }, 26 | }); 27 | 28 | export default combineReducers({ 29 | allIds, 30 | byId, 31 | }); 32 | -------------------------------------------------------------------------------- /src/components/ui/Container.js: -------------------------------------------------------------------------------- 1 | import {Spring, animated, config} from 'react-spring'; 2 | 3 | import {Flex} from 'rebass'; 4 | import React from 'react'; 5 | 6 | function Container({children}) { 7 | return ( 8 | 9 | {style => ( 10 | 11 | 20 | {children} 21 | 22 | 23 | )} 24 | 25 | ); 26 | } 27 | 28 | export default Container; 29 | -------------------------------------------------------------------------------- /src/components/ui/Header.js: -------------------------------------------------------------------------------- 1 | import Container from './Container'; 2 | import {Flex} from 'rebass'; 3 | import Heading from './Heading'; 4 | import Image from './Image'; 5 | import {Link} from 'gatsby'; 6 | import React from 'react'; 7 | import Text from './Text'; 8 | 9 | function Header({src, subtitle, title}) { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | {title} 17 | 18 | 19 | 20 | {subtitle} 21 | 22 | ); 23 | } 24 | 25 | export default Header; 26 | -------------------------------------------------------------------------------- /src/components/ui/Markdown.js: -------------------------------------------------------------------------------- 1 | import Blockquote from './Blockquote'; 2 | import CodeBlock from './CodeBlock'; 3 | import Heading from './Heading'; 4 | import Link from './Link'; 5 | import MarkdownImage from './MarkdownImage'; 6 | import React from 'react'; 7 | import ReactMarkdown from 'react-markdown'; 8 | import Text from './Text'; 9 | 10 | function Markdown({source}) { 11 | return ( 12 | , 21 | link: Link, 22 | }} 23 | /> 24 | ); 25 | } 26 | 27 | export default Markdown; 28 | -------------------------------------------------------------------------------- /survey/questions/12_slider_fun.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 12 3 | text: How much fun are you having with the slider input? 4 | questionType: SLIDER 5 | choiceType: null 6 | choices: [This is lame, It's okay, Pretty fun, Woohoo!] 7 | sliderMinValue: 0 8 | sliderMaxValue: 100 9 | sliderStepValue: 5 10 | additionalComments: true 11 | --- 12 | 13 | ## Slider Question Overview 14 | 15 | A slider question provides a more open format for collecting numerical inputs. 16 | Here are some example slider questions: 17 | 18 | > What percentage of time do you spend coding? (percentage based, values: 0% to 100%) 19 | 20 | > How many hours do you spend exercising in a week? (numeric based, values 0 to LIMIT) 21 | 22 | It is also possible to label intervals of slider values, as demonstrated by the following question we will ask you next: 23 | -------------------------------------------------------------------------------- /src/components/survey/answer/RadioAnswer.js: -------------------------------------------------------------------------------- 1 | import CheckboxRadioInput from 'components/ui/CheckboxRadioInput'; 2 | import AnswerLayout from './AnswerLayout'; 3 | import React from 'react'; 4 | 5 | function RadioAnswer({choices, disabled, isVertical, onChange, value}) { 6 | return ( 7 | 8 | {choices.map((choice, index) => { 9 | return ( 10 | { 16 | onChange(index); 17 | }} 18 | role="radio" 19 | /> 20 | ); 21 | })} 22 | 23 | ); 24 | } 25 | 26 | export default RadioAnswer; 27 | -------------------------------------------------------------------------------- /src/components/ui/MarkdownImage.js: -------------------------------------------------------------------------------- 1 | import {Spring, config} from 'react-spring'; 2 | 3 | import Image from './Image'; 4 | import React from 'react'; 5 | 6 | function MarkdownImage({src, ...otherProps}) { 7 | // HACK strip '/static' from src 8 | return ( 9 | 13 | {style => ( 14 | 25 | )} 26 | 27 | ); 28 | } 29 | 30 | export default MarkdownImage; 31 | -------------------------------------------------------------------------------- /survey/questions/09_multiple_choice_web_stack.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 9 3 | text: Which of the following web frameworks are you actively using? 4 | questionType: MULTI_CHOICE 5 | choiceType: HORIZONTAL_CHECKBOX 6 | choices: 7 | [ 8 | React, 9 | Redux, 10 | Gatsby, 11 | Vue, 12 | Angular, 13 | Angular 2, 14 | Graphql, 15 | Bootstrap, 16 | Relay, 17 | Apollo, 18 | ] 19 | additionalComments: false 20 | --- 21 | 22 | ## Multi-choice Question Overview 23 | 24 | [Multi-choice questions](https://en.wikipedia.org/wiki/Multiple_choice) provides a question format for people to select multiple answers for a given question. A common way to analyze multiple choice questions is to visualize all the distribution of selected answers in a bar chart. 25 | 26 | Let's ask a relevant multi-choice question about web frameworks! 27 | -------------------------------------------------------------------------------- /src/store/survey/session.js: -------------------------------------------------------------------------------- 1 | import {createAction, createActionTypes, createReducer} from 'store/utils'; 2 | 3 | export const actionTypes = createActionTypes('survey/session', [ 4 | 'SET_CURRENT_QUESTION_ID', 5 | 'SET_IS_COMPLETED', 6 | ]); 7 | 8 | export const actions = { 9 | setCurrentQuestionIndex: createAction(actionTypes.SET_CURRENT_QUESTION_ID), 10 | setIsCompleted: createAction(actionTypes.SET_IS_COMPLETED), 11 | }; 12 | 13 | const initialState = { 14 | currentQuestionIndex: 0, 15 | isCompleted: false, 16 | }; 17 | 18 | export default createReducer(initialState, { 19 | [actionTypes.SET_CURRENT_QUESTION_ID]: (state, {payload}) => ({ 20 | ...state, 21 | currentQuestionIndex: payload, 22 | }), 23 | [actionTypes.SET_IS_COMPLETED]: (state, {payload}) => ({ 24 | ...state, 25 | isCompleted: true, 26 | }), 27 | }); 28 | -------------------------------------------------------------------------------- /survey/questions/04_likert_overview.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 4 3 | text: Do you agree that the above was a useful overview of Likert questions 4 | questionType: LIKERT 5 | choiceType: VERTICAL_RADIO 6 | choices: 7 | [ 8 | Strongly Disagree, 9 | Disagree, 10 | Neither agree nor disagree, 11 | Agree, 12 | Strongly Agree, 13 | ] 14 | additionalComments: false 15 | --- 16 | 17 | ## Likert Question Overview 18 | 19 | **[Likert questions](https://en.wikipedia.org/wiki/Likert_scale)** are the most widely used questions to study positive and negative scores in surveys. 20 | 21 | It usually uses a 5-point or 7-point scale with a neutral option, and is organized from negative to positive (left to right). The most common way of structuring a likert question is to ask a question of the form: 22 | 23 | > How **NEGATIVE** or **POSITIVE** are you with **TOPIC**? 24 | -------------------------------------------------------------------------------- /src/store/configureStore.js: -------------------------------------------------------------------------------- 1 | import {applyMiddleware, combineReducers, createStore} from 'redux'; 2 | import {persistReducer, persistStore} from 'redux-persist'; 3 | 4 | import {composeWithDevTools} from 'redux-devtools-extension'; 5 | import draft from './draft'; 6 | import results from './results'; 7 | import storage from 'redux-persist/lib/storage'; 8 | import survey from './survey'; 9 | 10 | const rootReducer = combineReducers({ 11 | draft, 12 | results, 13 | survey, 14 | }); 15 | 16 | const persistConfig = { 17 | key: 'root', 18 | storage, 19 | }; 20 | 21 | const persistedReducer = persistReducer(persistConfig, rootReducer); 22 | 23 | export default () => { 24 | let store = createStore( 25 | persistedReducer, 26 | composeWithDevTools(applyMiddleware()), 27 | ); 28 | let persistor = persistStore(store); 29 | return {store, persistor}; 30 | }; 31 | -------------------------------------------------------------------------------- /survey/questions/10_multiple_choice_web_stack_checkbox.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 10 3 | text: Which of the following web frameworks are you actively using (vertical button layout)? 4 | questionType: MULTI_CHOICE 5 | choiceType: VERTICAL_BUTTON 6 | choices: 7 | [ 8 | React, 9 | Redux, 10 | Gatsby, 11 | Vue, 12 | Angular, 13 | Angular 2, 14 | Graphql, 15 | Bootstrap, 16 | Relay, 17 | Apollo, 18 | ] 19 | additionalComments: false 20 | --- 21 | 22 | `surveyless` provides the button and checkbox layout for rendering choice sets for multi-choice questions. Both horizontal and vertical layouts are supported. This question presents the layout with vertical buttons, and is less ideal than the horizontal checkbox presentation in the previous question. When there are a lot of answer choices, it is usually more concise to place them in a horizontal layout. 23 | -------------------------------------------------------------------------------- /survey/questions/14_TODO_matrix_feature_importance_rating.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 14 3 | text: For each of the following survey features, please rate how important they are to you. 4 | questionType: MATRIX 5 | choiceType: RATING 6 | choices: 7 | [Not important at all, Not important, Neutral, Important, Very Important] 8 | additionalComments: false 9 | --- 10 | 11 | ## TODO Matrix Question Overview 12 | 13 | Matrix questions allows multiple questions to be grouped together, allowing users to answer multiple sub-questions that share the same context and answer choice set. 14 | 15 | Matrix questions can be a collection of either likert, single-choice, or multi-choice questions (not a mix of question types). As a result, they can use many choice sets and layouts. 16 | 17 | In this example, we will ask a matrix question about the importance of survey features, using the rating choice set. 18 | -------------------------------------------------------------------------------- /src/components/survey/SurveyPageLayout.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | 3 | import {Box} from 'rebass'; 4 | import PageLayout from 'components/ui/PageLayout'; 5 | import SurveyHeader from 'components/SurveyHeader'; 6 | import {connect} from 'react-redux'; 7 | import {getSession} from 'store/surveySelectors'; 8 | import {navigate} from 'gatsby'; 9 | import {routes} from 'enums'; 10 | 11 | function SurveyPageLayout({children, isCompleted}) { 12 | useEffect( 13 | () => { 14 | if (window.location.pathname !== '/') { 15 | if (!isCompleted) { 16 | navigate(routes.SURVEY); 17 | } 18 | } 19 | }, 20 | [isCompleted], 21 | ); 22 | return ( 23 | }> 24 | {children} 25 | 26 | ); 27 | } 28 | 29 | export default connect(state => ({isCompleted: getSession(state).isCompleted}))( 30 | SurveyPageLayout, 31 | ); 32 | -------------------------------------------------------------------------------- /src/enums.js: -------------------------------------------------------------------------------- 1 | import keymirror from 'keymirror'; 2 | 3 | export const routes = { 4 | HOME: '/', 5 | DRAFT: '/draft', 6 | SURVEY: '/survey', 7 | RESULTS: '/results', 8 | THANKYOU: '/thankyou', 9 | }; 10 | 11 | export const surveyModes = keymirror({ 12 | DRAFT: null, 13 | OPEN: null, 14 | CLOSED: null, 15 | }); 16 | 17 | export const keyCodes = { 18 | ENTER: 13, 19 | SPACE: 32, 20 | LEFT: 37, 21 | UP: 38, 22 | RIGHT: 39, 23 | DOWN: 40, 24 | }; 25 | 26 | export const questionTypes = keymirror({ 27 | LIKERT: null, 28 | SINGLE_CHOICE: null, 29 | MULTI_CHOICE: null, 30 | COMMENT: null, 31 | SLIDER: null, 32 | RANKING: null, 33 | MATRIX: null, 34 | }); 35 | 36 | export const choiceTypes = keymirror({ 37 | HORIZONTAL_BUTTON: null, 38 | HORIZONTAL_CHECKBOX: null, 39 | HORIZONTAL_RADIO: null, 40 | RATING: null, 41 | VERTICAL_BUTTON: null, 42 | VERTICAL_CHECKBOX: null, 43 | VERTICAL_RADIO: null, 44 | }); 45 | -------------------------------------------------------------------------------- /survey/theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors": { 3 | "brand": "#4b98e5", 4 | "headerText": "#1d3b59", 5 | "primaryText": "#4F565D", 6 | "secondaryText": "#a3b8cc", 7 | "disabled": "#A2A9AF", 8 | "wash": "#F0F0F0", 9 | "cardBackground": "#FFFFFF", 10 | "background": "#FFFFFF", 11 | "dark": "#3a4856" 12 | }, 13 | "typography": { 14 | "baseFontSize": "16px", 15 | "baseLineHeight": 1.5, 16 | "scaleRatio": 2, 17 | "headerFontFamily": [ 18 | "Work Sans", 19 | "system-ui", 20 | "Helvetica Neue", 21 | "Segoe UI", 22 | "Helvetica", 23 | "Arial", 24 | "sans-serif" 25 | ], 26 | "bodyFontFamily": ["Quattrocento Sans", "system-ui", "sans-serif"], 27 | "googleFonts": [ 28 | { 29 | "name": "Work Sans", 30 | "styles": ["700"] 31 | }, 32 | { 33 | "name": "Quattrocento Sans", 34 | "styles": ["400", "400i", "700", "700i"] 35 | } 36 | ] 37 | }, 38 | "space": [2, 4, 8, 16, 32, 64, 128, 256] 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ui/List.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex} from 'rebass'; 2 | import {Trail, animated} from 'react-spring'; 3 | 4 | import React from 'react'; 5 | 6 | function List({children, isCentered, isVertical, spacing}) { 7 | return ( 8 | 15 | child)} 18 | keys={item => item.key} 19 | native 20 | to={{opacity: 1}}> 21 | {item => style => ( 22 | 23 | {item} 24 | 25 | )} 26 | 27 | 28 | ); 29 | } 30 | 31 | List.defaultProps = { 32 | isCentered: false, 33 | isMobileVertical: false, 34 | isVertical: false, 35 | spacing: 1, 36 | }; 37 | 38 | export default List; 39 | -------------------------------------------------------------------------------- /src/components/survey/answer/SliderAnswer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Slider from 'components/ui/Slider'; 3 | 4 | function SliderAnswer({auxiliaryData, choices, disabled, value, onChange}) { 5 | const {sliderMaxValue, sliderMinValue, sliderStepValue} = auxiliaryData; 6 | // if auxiliaryData is not provided, coerce slider prop values 7 | const markLabels = choices || []; 8 | const max = sliderMaxValue || 100; 9 | const min = sliderMinValue || 0; 10 | const step = sliderStepValue || 1; 11 | 12 | // create marks 13 | const marks = {}; 14 | const markInterval = 15 | markLabels.length > 1 ? (max + min) / (markLabels.length - 1) : 0; 16 | markLabels.forEach((choice, index) => { 17 | marks[markInterval * index] = choice; 18 | }); 19 | return ( 20 | 29 | ); 30 | } 31 | 32 | export default SliderAnswer; 33 | -------------------------------------------------------------------------------- /src/components/survey/answer/CheckboxAnswer.js: -------------------------------------------------------------------------------- 1 | import CheckboxRadioInput from 'components/ui/CheckboxRadioInput'; 2 | import AnswerLayout from './AnswerLayout'; 3 | import React from 'react'; 4 | 5 | function CheckboxAnswer({choices, disabled, isVertical, onChange, value}) { 6 | const values = value == null ? [] : value; 7 | return ( 8 | 9 | {choices.map((choice, index) => { 10 | return ( 11 | { 17 | if (checked && !values.includes(index)) { 18 | onChange([...values, index]); 19 | } else { 20 | onChange(values.filter(a => a !== index)); 21 | } 22 | }} 23 | role="checkbox" 24 | /> 25 | ); 26 | })} 27 | 28 | ); 29 | } 30 | 31 | export default CheckboxAnswer; 32 | -------------------------------------------------------------------------------- /src/components/ui/Slider.js: -------------------------------------------------------------------------------- 1 | import 'rc-slider/assets/index.css'; 2 | 3 | import {Box} from 'rebass'; 4 | import RCSlider from 'rc-slider'; 5 | import React from 'react'; 6 | import Tooltip from 'rc-tooltip'; 7 | 8 | const Handle = RCSlider.Handle; 9 | 10 | const handle = props => { 11 | const {value, dragging, index, ...restProps} = props; 12 | return ( 13 | 19 | 20 | 21 | ); 22 | }; 23 | 24 | // TODO implement a better slider 25 | function Slider({disabled, marks, max, min, onChange, step, value}) { 26 | return ( 27 | 28 | 38 | 39 | ); 40 | } 41 | 42 | export default Slider; 43 | -------------------------------------------------------------------------------- /gatsby-config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | 'gatsby-plugin-resolve-src', 4 | 'gatsby-plugin-react-helmet', 5 | { 6 | resolve: 'gatsby-plugin-manifest', 7 | options: { 8 | name: 'surveyless', 9 | short_name: 'surveyless', 10 | start_url: '/', 11 | background_color: '#FFFFFF', 12 | theme_color: '#4b98e5', 13 | display: 'minimal-ui', 14 | icon: 'static/images/logo.png', 15 | }, 16 | }, 17 | { 18 | resolve: 'gatsby-plugin-typography', 19 | options: { 20 | pathToConfigModule: 'src/styles/typography', 21 | }, 22 | }, 23 | 'gatsby-plugin-styled-components', 24 | 'gatsby-transformer-remark', 25 | // source files 26 | { 27 | resolve: 'gatsby-source-filesystem', 28 | options: { 29 | path: 'survey', 30 | name: 'survey', 31 | }, 32 | }, 33 | { 34 | resolve: 'gatsby-source-filesystem', 35 | options: { 36 | path: 'README.md', 37 | name: 'surveyInfo', 38 | }, 39 | }, 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/ui/TextArea.js: -------------------------------------------------------------------------------- 1 | import {DISABLED_OPACITY, SURVEYLESS_LIGHT_GRAY} from 'styles/constants'; 2 | 3 | import {Card} from 'rebass'; 4 | import React from 'react'; 5 | 6 | function TextArea({disabled, height, onChange, placeholder, value}) { 7 | return ( 8 | onChange(e.target.value)} 30 | p={1} 31 | value={value} 32 | width="100%" 33 | /> 34 | ); 35 | } 36 | 37 | TextArea.defaultProps = { 38 | height: 50, 39 | }; 40 | 41 | export default TextArea; 42 | -------------------------------------------------------------------------------- /src/components/SurveyHeader.js: -------------------------------------------------------------------------------- 1 | import {StaticQuery, graphql, withPrefix} from 'gatsby'; 2 | 3 | import Header from 'components/ui/Header'; 4 | import React from 'react'; 5 | 6 | function SurveyHeader() { 7 | return ( 8 | { 27 | const {frontmatter} = data.allMarkdownRemark.edges[0].node; 28 | const {subtitle, title} = frontmatter; 29 | return ( 30 | 35 | ); 36 | }} 37 | /> 38 | ); 39 | } 40 | 41 | export default SurveyHeader; 42 | -------------------------------------------------------------------------------- /survey/questions/02_ready_to_explore_markdown.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 2 3 | text: Ready to explore markdown support in surveyless? 4 | questionType: SINGLE_CHOICE 5 | choiceType: HORIZONTAL_BUTTON 6 | choices: [Yes, No] 7 | additionalComments: false 8 | --- 9 | 10 | Welcome to `surveyless`! This is a guided survey demo that will showcase important features in `surveyless` Before we proceed with more questions, let's take a quick tour on navigational features when taking the survey: 11 | 12 | - Click on the logo/header to head back to the survey home page. 13 | - Use the arrow keys ⬅️➡️ to navigate through survey questions. You can only go to the next question if you have answered the current question. 14 | - You can also navigate and track the progress of answered and unanswered questions using the progress bar component (located at the bottom of the screen). 15 | 16 | `surveyless` saves your progress locally, so you can continue where you left off. 17 | 18 | In case you were wondering, this section was written in markdown. In the next question, we will ask for your feedback on the common markdown features that are supported in `surveyless`! 19 | -------------------------------------------------------------------------------- /src/components/ui/RatingStar.js: -------------------------------------------------------------------------------- 1 | import {Box} from 'rebass'; 2 | import {DISABLED_OPACITY} from 'styles/constants'; 3 | import React from 'react'; 4 | 5 | const ACTIVE_COLOR = '#FAC917'; 6 | const INACTIVE_COLOR = '#fff8e5'; 7 | 8 | function RatingStar({disabled, isActive, isHovered, onClick}) { 9 | return ( 10 | 24 | 25 | 29 | 30 | 31 | ); 32 | } 33 | 34 | export default RatingStar; 35 | -------------------------------------------------------------------------------- /src/components/ui/Button.js: -------------------------------------------------------------------------------- 1 | import {DISABLED_OPACITY, FOCUS_HOVER_OPACITY} from 'styles/constants'; 2 | 3 | import {Card} from 'rebass'; 4 | import React from 'react'; 5 | import Text from './Text'; 6 | 7 | function Button({disabled, label, onClick, variant, ...otherProps}) { 8 | const isPrimary = variant === 'primary'; 9 | return ( 10 | 33 | {label} 34 | 35 | ); 36 | } 37 | 38 | Button.defaultProps = { 39 | variant: 'primary', 40 | }; 41 | 42 | export default Button; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Chris Zhou 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/pages/index.js: -------------------------------------------------------------------------------- 1 | import {graphql, navigate} from 'gatsby'; 2 | 3 | import Button from 'components/ui/Button'; 4 | import Card from 'components/ui/Card'; 5 | import Container from 'components/ui/Container'; 6 | import {Flex} from 'rebass'; 7 | import Markdown from 'components/ui/Markdown'; 8 | import React from 'react'; 9 | import SurveyPageLayout from 'components/survey/SurveyPageLayout'; 10 | import {routes} from 'enums'; 11 | 12 | function HomePage({data}) { 13 | const {rawMarkdownBody} = data.allMarkdownRemark.edges[0].node; 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | navigate(routes.SURVEY)} /> 22 | 23 | 24 | 25 | ); 26 | } 27 | 28 | export default HomePage; 29 | 30 | export const pageQuery = graphql` 31 | { 32 | allMarkdownRemark(filter: {fileAbsolutePath: {regex: "/README.md/"}}) { 33 | edges { 34 | node { 35 | rawMarkdownBody 36 | } 37 | } 38 | } 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /src/components/survey/answer/RatingAnswer.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | 3 | import {Box} from 'rebass'; 4 | import List from 'components/ui/List'; 5 | import RatingStar from 'components/ui/RatingStar'; 6 | import Tooltip from 'components/ui/Tooltip'; 7 | 8 | function RatingAnswer({choices, disabled, onChange, value}) { 9 | const [activeIndex, setActiveIndex] = useState(-1); 10 | return ( 11 | 12 | {choices.map((choice, index) => { 13 | return ( 14 | 15 | !disabled && setActiveIndex(index)} 17 | onMouseLeave={() => !disabled && setActiveIndex(-1)} 18 | p={1}> 19 | onChange(index)} 26 | /> 27 | 28 | 29 | ); 30 | })} 31 | 32 | ); 33 | } 34 | 35 | export default RatingAnswer; 36 | -------------------------------------------------------------------------------- /src/components/survey/answer/ButtonAnswer.js: -------------------------------------------------------------------------------- 1 | import Button from 'components/ui/Button'; 2 | import AnswerLayout from './AnswerLayout'; 3 | import React from 'react'; 4 | 5 | function ButtonAnswer({ 6 | choices, 7 | disabled, 8 | isMulti, 9 | isVertical, 10 | onChange, 11 | value, 12 | }) { 13 | const values = value == null ? [] : value; 14 | return ( 15 | 16 | {choices.map((choice, index) => { 17 | const isActive = isMulti ? values.includes(index) : value === index; 18 | return ( 19 | { 24 | if (isMulti) { 25 | if (values.includes(index)) { 26 | onChange(values.filter(a => a !== index)); 27 | } else { 28 | onChange([...values, index]); 29 | } 30 | } else { 31 | onChange(index); 32 | } 33 | }} 34 | variant={isActive ? 'primary' : 'outline'} 35 | /> 36 | ); 37 | })} 38 | 39 | ); 40 | } 41 | 42 | export default ButtonAnswer; 43 | -------------------------------------------------------------------------------- /src/pages/thankyou.js: -------------------------------------------------------------------------------- 1 | import {graphql, navigate} from 'gatsby'; 2 | 3 | import Button from 'components/ui/Button'; 4 | import Card from 'components/ui/Card'; 5 | import Container from 'components/ui/Container'; 6 | import {Flex} from 'rebass'; 7 | import Markdown from 'components/ui/Markdown'; 8 | import React from 'react'; 9 | import SurveyPageLayout from 'components/survey/SurveyPageLayout'; 10 | import {routes} from 'enums'; 11 | 12 | function ThankyouPage({data}) { 13 | const {rawMarkdownBody} = data.allMarkdownRemark.edges[0].node; 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | navigate(routes.SURVEY)} 25 | /> 26 | 27 | 28 | ); 29 | } 30 | 31 | export default ThankyouPage; 32 | 33 | export const pageQuery = graphql` 34 | { 35 | allMarkdownRemark(filter: {fileAbsolutePath: {regex: "/thankyou.md/"}}) { 36 | edges { 37 | node { 38 | rawMarkdownBody 39 | } 40 | } 41 | } 42 | } 43 | `; 44 | -------------------------------------------------------------------------------- /src/store/surveySelectors.js: -------------------------------------------------------------------------------- 1 | export function getIsQuestionCompleted(state, questionId) { 2 | return questionId in state.survey.responses; 3 | } 4 | 5 | export function getAnswer(state, questionId) { 6 | return state.survey.responses[questionId] || {}; 7 | } 8 | 9 | export function getQuestion(state, questionId) { 10 | return state.survey.questions.byId[questionId] || {}; 11 | } 12 | 13 | export function getSession(state) { 14 | return state.survey.session; 15 | } 16 | 17 | export function getCurrentQuestionId(state) { 18 | const {questions, session} = state.survey; 19 | return questions.allIds[ 20 | Math.min(session.currentQuestionIndex, questions.allIds.length - 1) 21 | ]; 22 | } 23 | 24 | export function getProgressItems(state) { 25 | const {questions, responses} = state.survey; 26 | const {allIds, byId} = questions; 27 | return allIds.map((id, index) => { 28 | const question = byId[id]; 29 | const isCompleted = responses[id] && responses[id].answerValue != null; 30 | const maxRespondedQuestionCount = Object.values(responses).filter( 31 | response => response.answerValue != null, 32 | ).length; 33 | return { 34 | disabled: !(index <= maxRespondedQuestionCount || isCompleted), 35 | id, 36 | isCompleted, 37 | tooltip: question.text, 38 | }; 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /survey/questions/05_likert_radio_vs_buttons.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 5 3 | text: Do you agree that the previous radio inputs look better than these buttons? 4 | questionType: LIKERT 5 | choiceType: VERTICAL_BUTTON 6 | choices: 7 | [ 8 | Strongly Disagree, 9 | Disagree, 10 | Neither agree nor disagree, 11 | Agree, 12 | Strongly Agree, 13 | ] 14 | additionalComments: false 15 | --- 16 | 17 | ## There are many ways to render choice sets. 18 | 19 | `surveyless` provides multiple ways to render valid question types and choice sets: 20 | 21 | - Likert Questions 22 | - Horizontal/Vertical Radio Input 23 | - Horizontal/Vertical Buttons 24 | - Rating scale 25 | - Single-choice Questions 26 | - Horizontal/Vertical Radio Input 27 | - Horizontal/Vertical Buttons 28 | - Multi-choice Questions 29 | - Horizontal/Vertical Checkbox 30 | - Horizontal/Vertical Buttons 31 | - Matrix Questions (can be either Single-choice or Multi-choice) 32 | - Horizontal/Vertical Radio Input 33 | - Horizontal/Vertical Buttons 34 | - Horizontal/Vertical Checkbox 35 | - Rating scale 36 | - Slider Questions 37 | - Slider input 38 | - Comments 39 | - None 40 | 41 | You should pick out the best valid combination for your surveys. In this case, vertical radio inputs should look better than vertical buttons when rendering likert questions. 42 | -------------------------------------------------------------------------------- /src/components/ui/ProgressItem.js: -------------------------------------------------------------------------------- 1 | import { 2 | DISABLED_OPACITY, 3 | FOCUS_HOVER_OPACITY, 4 | SURVEYLESS_GRAY, 5 | } from 'styles/constants'; 6 | 7 | import {Box} from 'rebass'; 8 | import React from 'react'; 9 | import Tooltip from './Tooltip'; 10 | 11 | function ProgressItem({ 12 | disabled, 13 | index, 14 | isActive, 15 | isCompleted, 16 | onClick, 17 | tooltip, 18 | }) { 19 | let color; 20 | if (isCompleted) { 21 | color = 'brand'; 22 | } else { 23 | color = 'disabled'; 24 | } 25 | const item = ( 26 | 48 | ); 49 | return disabled ? ( 50 | item 51 | ) : ( 52 | 53 | {item} 54 | 55 | ); 56 | } 57 | 58 | export default ProgressItem; 59 | -------------------------------------------------------------------------------- /src/components/ui/CheckboxRadioInput.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex} from 'rebass'; 2 | import {DISABLED_OPACITY, SURVEYLESS_LIGHT_GRAY} from 'styles/constants'; 3 | 4 | import React from 'react'; 5 | import {keyCodes} from 'enums'; 6 | 7 | function CheckboxRadioInput({checked, disabled, labelValue, role, onChange}) { 8 | const hoverBackground = checked ? undefined : SURVEYLESS_LIGHT_GRAY; 9 | function handleCheck() { 10 | !disabled && onChange(!checked); 11 | } 12 | return ( 13 | 20 | { 38 | if ([keyCodes.SPACE, keyCodes.ENTER].includes(e.keyCode)) { 39 | handleCheck(); 40 | } 41 | }} 42 | mr={1} 43 | role={role} 44 | tabIndex="0" 45 | /> 46 | {labelValue} 47 | 48 | ); 49 | } 50 | 51 | export default CheckboxRadioInput; 52 | -------------------------------------------------------------------------------- /src/components/Root.js: -------------------------------------------------------------------------------- 1 | import 'styles/index.css'; 2 | 3 | import Helmet from 'react-helmet'; 4 | import PageSpinner from 'components/ui/PageSpinner'; 5 | import {PersistGate} from 'redux-persist/integration/react'; 6 | import React from 'react'; 7 | import {Provider as StoreProvider} from 'react-redux'; 8 | import {ThemeProvider} from 'styled-components'; 9 | import configureStore from 'store/configureStore'; 10 | import {setConfig} from 'react-hot-loader'; 11 | import theme from './../../survey/theme.json'; 12 | 13 | // HACK: react-hooks bug with react-hot-loader: https://github.com/gatsbyjs/gatsby/issues/9489 14 | setConfig({pureSFC: true}); 15 | 16 | const {store, persistor} = configureStore(); 17 | 18 | export default function Root({element}) { 19 | return ( 20 | 21 | } 23 | persistor={persistor}> 24 | 25 | 26 | 39 | 40 | 41 | {element} 42 | 43 | 44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/pages/survey.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react'; 2 | 3 | import SurveyContent from 'components/survey/SurveyContent'; 4 | import {actions} from 'store/survey/questions'; 5 | import {connect} from 'react-redux'; 6 | import {graphql} from 'gatsby'; 7 | 8 | function SurveyPage({data, isCompleted, onInitialize}) { 9 | useEffect(() => { 10 | const byId = {}; 11 | data.allMarkdownRemark.edges.forEach(({node}) => { 12 | const {frontmatter, rawMarkdownBody} = node; 13 | const { 14 | id, 15 | text, 16 | questionType, 17 | choices, 18 | choiceType, 19 | additionalComments, 20 | ...auxiliaryData // capture all other data in question into an object 21 | } = frontmatter; 22 | byId[id] = { 23 | id, 24 | text, 25 | questionType, 26 | choices, 27 | choiceType, 28 | description: rawMarkdownBody, 29 | additionalComments, 30 | auxiliaryData, 31 | }; 32 | }); 33 | onInitialize({byId, allIds: Object.keys(byId)}); 34 | }, []); 35 | return ; 36 | } 37 | 38 | export default connect( 39 | null, 40 | { 41 | onInitialize: actions.initialize, 42 | }, 43 | )(SurveyPage); 44 | 45 | export const pageQuery = graphql` 46 | { 47 | allMarkdownRemark( 48 | filter: {fileAbsolutePath: {regex: "//questions/.*.md/"}} 49 | sort: {order: ASC, fields: [frontmatter___id]} 50 | ) { 51 | edges { 52 | node { 53 | frontmatter { 54 | id 55 | text 56 | questionType 57 | choices 58 | choiceType 59 | additionalComments 60 | # auxiliary data fields for special question types 61 | sliderMaxValue 62 | sliderMinValue 63 | sliderStepValue 64 | } 65 | rawMarkdownBody 66 | } 67 | } 68 | } 69 | } 70 | `; 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "surveyless", 3 | "version": "0.1.0", 4 | "description": "Build, run and analyze simple serverless surveys", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/chrisrzhou/surveyless" 8 | }, 9 | "author": "Chris Zhou ", 10 | "license": "MIT", 11 | "keywords": [ 12 | "survey", 13 | "serverless", 14 | "jamstack", 15 | "gatsby", 16 | "netlify", 17 | "react", 18 | "redux", 19 | "markdown", 20 | "visualization", 21 | "data" 22 | ], 23 | "scripts": { 24 | "clean": "rm -rf public .cache", 25 | "lint": "eslint --fix src", 26 | "build": "gatsby build", 27 | "dev": "gatsby develop" 28 | }, 29 | "dependencies": { 30 | "gatsby": "^2.0.19", 31 | "gatsby-plugin-manifest": "^2.0.5", 32 | "gatsby-plugin-react-helmet": "^3.0.0", 33 | "gatsby-plugin-resolve-src": "^2.0.0-beta.1", 34 | "gatsby-plugin-styled-components": "^3.0.0-rc.5", 35 | "gatsby-plugin-typography": "^2.2.1", 36 | "gatsby-source-filesystem": "^2.0.4", 37 | "gatsby-transformer-remark": "^2.1.12", 38 | "keymirror": "^0.1.1", 39 | "prop-types": "^15.6.2", 40 | "rc-slider": "^8.6.3", 41 | "react": "^16.7.0-alpha", 42 | "react-dom": "^16.7.0-alpha", 43 | "react-helmet": "^5.2.0", 44 | "react-markdown": "^4.0.3", 45 | "react-redux": "^5.1.0", 46 | "react-spring": "^6.1.8", 47 | "react-syntax-highlighter": "^10.0.1", 48 | "react-tooltip-lite": "^1.7.1", 49 | "react-typography": "^0.16.13", 50 | "rebass": "^3.0.0-9", 51 | "redux": "^4.0.1", 52 | "redux-persist": "^5.10.0", 53 | "styled-components": "^4.0.3", 54 | "typography": "^0.16.17" 55 | }, 56 | "devDependencies": { 57 | "babel-eslint": "^9.0.0", 58 | "babel-plugin-styled-components": "^1.8.0", 59 | "eslint": "^5.1.0", 60 | "eslint-config-fbjs": "^2.1.0", 61 | "eslint-config-prettier": "^3.1.0", 62 | "eslint-plugin-babel": "^5.1.0", 63 | "eslint-plugin-flowtype": "^2.50.0", 64 | "eslint-plugin-jsx-a11y": "^6.1.2", 65 | "eslint-plugin-prettier": "^3.0.0", 66 | "eslint-plugin-react": "^7.10.0", 67 | "eslint-plugin-relay": "^0.0.28", 68 | "prettier": "^1.14.2", 69 | "redux-devtools-extension": "^2.13.5" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/ui/PageSpinner.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex} from 'rebass'; 2 | import {Keyframes, animated} from 'react-spring'; 3 | 4 | import Heading from './Heading'; 5 | import React from 'react'; 6 | import {SURVEYLESS_BRAND_COLOR} from 'styles/constants'; 7 | 8 | const IconsContainer = Keyframes.Spring(async next => { 9 | while (true) { 10 | await next({ 11 | from: {radians: 0}, 12 | to: {radians: 2 * Math.PI}, 13 | }); 14 | } 15 | }); 16 | 17 | const ICON_SIZE = 30; 18 | const loadingIcons = [0, 1, 2]; 19 | 20 | function LoadingIcons() { 21 | return ( 22 | 23 | 28 | {({radians}) => 29 | loadingIcons.map(i => ( 30 | 31 | 36 | `translate3d(0, ${(ICON_SIZE / 2) * 37 | Math.sin(r + (i * 2 * Math.PI) / 5)}px, 0)`, 38 | ), 39 | }} 40 | viewBox="0 0 400 400"> 41 | 42 | 43 | 44 | 45 | 46 | )) 47 | } 48 | 49 | 50 | ); 51 | } 52 | 53 | function PageSpinner({title}) { 54 | return ( 55 | 68 | 69 | {title} 70 | 71 | 72 | 73 | ); 74 | } 75 | 76 | export default PageSpinner; 77 | -------------------------------------------------------------------------------- /src/components/survey/SurveyQuestion.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex} from 'rebass'; 2 | import { 3 | getAnswer, 4 | getCurrentQuestionId, 5 | getQuestion, 6 | getSession, 7 | } from 'store/surveySelectors'; 8 | 9 | import Card from 'components/ui/Card'; 10 | import Container from 'components/ui/Container'; 11 | import Heading from 'components/ui/Heading'; 12 | import Markdown from 'components/ui/Markdown'; 13 | import React from 'react'; 14 | import SurveyAnswer from './answer/SurveyAnswer'; 15 | import Text from 'components/ui/Text'; 16 | import TextArea from 'components/ui/TextArea'; 17 | import {actions} from 'store/survey/responses'; 18 | import {connect} from 'react-redux'; 19 | 20 | function SurveyQuestion({answer, onSetResponse, question, isCompleted}) { 21 | const { 22 | id, 23 | text, 24 | questionType, 25 | choiceType, 26 | choices, 27 | description, 28 | additionalComments, 29 | auxiliaryData, 30 | } = question; 31 | if (id == null) { 32 | return null; 33 | } 34 | return ( 35 | 36 | 37 | 38 | 39 | 40 | {text} 41 | { 50 | onSetResponse({questionId: id, answerValue}); 51 | }} 52 | /> 53 | {additionalComments && ( 54 | 55 | Additional Comments 56 | { 59 | onSetResponse({ 60 | questionId: id, 61 | additionalComments: value, 62 | }); 63 | }} 64 | value={answer.additionalComments} 65 | /> 66 | 67 | )} 68 | 69 | 70 | ); 71 | } 72 | 73 | export default connect( 74 | state => { 75 | const questionId = getCurrentQuestionId(state); 76 | return { 77 | isCompleted: getSession(state).isCompleted, 78 | answer: getAnswer(state, questionId), 79 | question: getQuestion(state, questionId), 80 | }; 81 | }, 82 | { 83 | onSetResponse: actions.setResponse, 84 | }, 85 | )(SurveyQuestion); 86 | -------------------------------------------------------------------------------- /src/components/survey/answer/SurveyAnswer.js: -------------------------------------------------------------------------------- 1 | import {choiceTypes, questionTypes} from 'enums'; 2 | 3 | import ButtonAnswer from './ButtonAnswer'; 4 | import CheckboxAnswer from './CheckboxAnswer'; 5 | import RadioAnswer from './RadioAnswer'; 6 | import RatingAnswer from './RatingAnswer'; 7 | import React from 'react'; 8 | import SliderAnswer from './SliderAnswer'; 9 | import TextArea from 'components/ui/TextArea'; 10 | 11 | // This component controls the valid combinations of question types and choices 12 | function SurveyAnswer({ 13 | answerValue, 14 | auxiliaryData, 15 | choiceType, 16 | choices, 17 | disabled, 18 | onAnswerChange, 19 | questionType, 20 | }) { 21 | const sharedAnswerProps = { 22 | value: answerValue, 23 | disabled, 24 | choices, 25 | onChange: onAnswerChange, 26 | }; 27 | switch (questionType) { 28 | case questionTypes.LIKERT: 29 | case questionTypes.SINGLE_CHOICE: 30 | case questionTypes.MATRIX: 31 | switch (choiceType) { 32 | case choiceTypes.RATING: 33 | return ; 34 | case choiceTypes.HORIZONTAL_BUTTON: 35 | case choiceTypes.VERTICAL_BUTTON: 36 | return ( 37 | 42 | ); 43 | case choiceTypes.HORIZONTAL_RADIO: 44 | case choiceTypes.VERTICAL_RADIO: 45 | default: 46 | return ( 47 | 52 | ); 53 | } 54 | case questionTypes.MULTI_CHOICE: { 55 | switch (choiceType) { 56 | case choiceTypes.HORIZONTAL_BUTTON: 57 | case choiceTypes.VERTICAL_BUTTON: 58 | return ( 59 | 64 | ); 65 | case choiceTypes.HORIZONTAL_CHECKBOX: 66 | case choiceTypes.VERTICAL_CHECKBOX: 67 | default: 68 | return ( 69 | 73 | ); 74 | } 75 | } 76 | case questionTypes.COMMENT: 77 | return ; 78 | case questionTypes.SLIDER: 79 | return ( 80 | 81 | ); 82 | case questionTypes.RANKING: 83 | default: 84 | return null; 85 | } 86 | } 87 | 88 | export default SurveyAnswer; 89 | -------------------------------------------------------------------------------- /survey/questions/03_ready_to_explore_question_types.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 3 3 | text: Are you ready to learn more about supported question types and choice sets in surveyless! 4 | questionType: SINGLE_CHOICE 5 | choiceType: HORIZONTAL_BUTTON 6 | choices: [Do I have a choice?, Hell yes!] 7 | additionalComments: false 8 | --- 9 | 10 | `surveyless` supports the following basic markdown use cases: 11 | 12 | ## Headings 13 | 14 | # h1 Heading 15 | 16 | ## h2 Heading 17 | 18 | ### h3 Heading 19 | 20 | #### h4 Heading 21 | 22 | ##### h5 Heading 23 | 24 | ###### h6 Heading 25 | 26 | ## Newlines 27 | 28 | --- 29 | 30 | ## Bold, Italics, Strikethrough, Emojis 31 | 32 | This is **bold**, _italics_, ~~strikethrough~~. 33 | 34 | Emojis supported! 👨💻 / 🐈 / 🏞️🍜🍣 35 | 36 | ## Blockquotes 37 | 38 | Regular blockquote: 39 | 40 | > Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer posuere erat a ante. 41 | 42 | Nested blockquote: 43 | 44 | > Donec massa lacus, ultricies a ullamcorper in, fermentum sed augue. 45 | > Nunc augue augue, aliquam non hendrerit ac, commodo vel nisi. 46 | > 47 | > > Sed adipiscing elit vitae augue consectetur a gravida nunc vehicula. Donec auctor 48 | > > odio non est accumsan facilisis. Aliquam id turpis in dolor tincidunt mollis ac eu diam. 49 | > > 50 | > > > Donec massa lacus, ultricies a ullamcorper in, fermentum sed augue. 51 | > > > Nunc augue augue, aliquam non hendrerit ac, commodo vel nisi. 52 | 53 | ## Lists 54 | 55 | Unordered list: 56 | 57 | - Lorem ipsum dolor sit amet 58 | 59 | * Consectetur adipiscing elit 60 | 61 | - Integer molestie lorem at massa 62 | 63 | Ordered list: 64 | 65 | 1. Lorem ipsum dolor sit amet 66 | 2. Consectetur adipiscing elit 67 | 3. Integer molestie lorem at massa 68 | 69 | ## Code and Syntax Highlighting 70 | 71 | Inline `code` and syntax highlighting for various languages are supported: 72 | 73 | ```javascript 74 | // javascript 75 | const greet = person => { 76 | return `Hello ${person}!`; 77 | }; 78 | ``` 79 | 80 | ```python 81 | // python 82 | def greet(person): 83 | return 'Hello ' + person + '!' 84 | ``` 85 | 86 | ## Links 87 | 88 | Autolink to https://github.com/chrisrzhou/surveyless works! 89 | 90 | Inline links to [surveyless](https://github.com/chrisrzhou/surveyless) works! 91 | 92 | > Note that all rendered markdown links in `surveyless` will open in new windows. This is by design. 93 | 94 | ## Images 95 | 96 | External image resources works! 97 |  98 | 99 | Local image resources works! 100 |  101 | 102 | > Note that all images are responsively centered to the document. Please also specify local image resources using absolute filepaths beginning with "/static/". This is by design. 103 | 104 | ### Todo List 105 | 106 | - [x] Checked item 107 | - [ ] [Links supported](#qux) 108 | - [ ] Unchecked item 109 | 110 | ## Tables 111 | 112 | | Key | Description | 113 | | ------- | ---------------------- | 114 | | id | survey id | 115 | | title | title of survey | 116 | | choices | array of choice values | 117 | 118 | ## Iframes 119 | 120 | Not usually a good idea, but you **can** embed youtube cat videos 😸. Make sure to set the iframe `src` to use `https` since `surveyless` instances are served over `https`. 121 | 122 | 127 | 128 | -------------------------------------------------------------------------------- /src/components/survey/SurveyProgress.js: -------------------------------------------------------------------------------- 1 | import {Box, Flex} from 'rebass'; 2 | import React, {useEffect, useState} from 'react'; 3 | import {getProgressItems, getSession} from 'store/surveySelectors'; 4 | import {keyCodes, routes} from 'enums'; 5 | 6 | import Button from 'components/ui/Button'; 7 | import PageSpinner from 'components/ui/PageSpinner'; 8 | import Progress from 'components/ui/Progress'; 9 | import {actions} from 'store/survey/session'; 10 | import {connect} from 'react-redux'; 11 | import {navigate} from 'gatsby'; 12 | import useHotKeys from 'hooks/useHotKeys'; 13 | 14 | function SurveyContent({ 15 | progressItems, 16 | session, 17 | onSetCurrentQuestionIndex, 18 | onSetIsCompleted, 19 | }) { 20 | // TODO hookup to real API 21 | const [isLoading, setIsLoading] = useState(false); 22 | const [mockSubmitTimeout, setMockSubmitTimeout] = useState(); 23 | 24 | useEffect(() => { 25 | return () => { 26 | if (mockSubmitTimeout) { 27 | clearTimeout(mockSubmitTimeout); 28 | } 29 | }; 30 | }, []); 31 | 32 | useHotKeys({ 33 | [keyCodes.UP]: nextQuestion, 34 | [keyCodes.RIGHT]: nextQuestion, 35 | [keyCodes.DOWN]: previousQuestion, 36 | [keyCodes.LEFT]: previousQuestion, 37 | }); 38 | 39 | const {currentQuestionIndex, isCompleted} = session; 40 | 41 | const totalQuestionsCount = progressItems.length; 42 | const lastCompletedQuestionIndex = progressItems.filter( 43 | item => item.isCompleted, 44 | ).length; 45 | 46 | function goToQuestionIndex(questionIndex) { 47 | onSetCurrentQuestionIndex(questionIndex); 48 | window.scrollTo(0, 0); 49 | } 50 | 51 | function nextQuestion() { 52 | if (currentQuestionIndex < totalQuestionsCount - 1) { 53 | const nextQuestionIndex = currentQuestionIndex + 1; 54 | if (!progressItems[nextQuestionIndex].disabled) { 55 | goToQuestionIndex(nextQuestionIndex); 56 | } 57 | } 58 | } 59 | 60 | function previousQuestion() { 61 | goToQuestionIndex(Math.max(0, currentQuestionIndex - 1)); 62 | } 63 | 64 | function continueSurvey() { 65 | goToQuestionIndex(lastCompletedQuestionIndex); 66 | } 67 | 68 | function submit() { 69 | setIsLoading(true); 70 | setMockSubmitTimeout( 71 | setTimeout(() => { 72 | setIsLoading(false); 73 | onSetIsCompleted(true); 74 | navigate(routes.THANKYOU); 75 | }, 2000), 76 | ); 77 | } 78 | 79 | let button; 80 | if (isCompleted) { 81 | button = ( 82 | navigate(routes.THANKYOU)} 85 | /> 86 | ); 87 | } else { 88 | if (lastCompletedQuestionIndex === totalQuestionsCount) { 89 | button = ; 90 | } else if (currentQuestionIndex < lastCompletedQuestionIndex) { 91 | button = ; 92 | } 93 | } 94 | return ( 95 | 108 | {button} 109 | 114 | {isLoading && } 115 | 116 | ); 117 | } 118 | 119 | export default connect( 120 | state => ({ 121 | progressItems: getProgressItems(state), 122 | session: getSession(state), 123 | }), 124 | { 125 | onSetCurrentQuestionIndex: actions.setCurrentQuestionIndex, 126 | onSetIsCompleted: actions.setIsCompleted, 127 | }, 128 | )(SurveyContent); 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: surveyless 3 | subtitle: Build, run, and analyze simple serverless surveys 4 | mode: OPEN 5 | --- 6 | 7 | # 📝 [WORK IN PROGRESS] surveyless 8 | 9 | Build, run, and analyze simple serverless surveys. 10 | 11 | Less servers, databases, keys, signups, accounts, ads, complexity, and headaches 🤦 12 | 13 |  14 | 15 | [](https://app.netlify.com/start/deploy?repository=https://github.com/chrisrzhou/surveyless) 16 | 17 | ## Features 18 | 19 | - ⚛️ JAMStack + modern web tools power a fully automated survey platform. 20 | - 👌 Zero-config and simple to get started. 21 | - 📝 Manage expressive survey content and questions in markdown. 22 | - 💅 Easy UI customizations using a `theme.json` config file. 23 | - 👯 Clone and easily recreate new surveys with consistent setup. 24 | - 📊 Visualize survey results instantly after responses are collected. 25 | - 🔐 Simple security model. 26 | 27 | ## Why JAMStack + Serverless? 28 | 29 | ### Third-party solutions 30 | 31 | Surveys are an important way to analyze sentiment about brands or events. Existing survey platforms (such as Survey Monkey, Qualtrics) provide various survey tools and solutions. However, these offerings come at a cost, and are usually bloated with superfluous features. They are difficult to customize, and do not provide expressive ways to create survey content. Running surveys on these third-party servers can also be unreliable and expensive. 32 | 33 | ### Blazing fast deployment on CDN 34 | 35 | `surveyless` is a [JAMStack](https://jamstack.org/) application that runs serverless surveys on CDNs. Survey responses are managed by automated pull requests to the `/responses` branch of the Git repo. Closing a survey involves merging the `/responses` into the `/master` branch. This triggers a rebuild of the site, and results can then be analyzed immediately. 36 | 37 | ### Delightful developer experience 38 | 39 | Markdown is a natural medium for creating rich content (this page is created with simple markdown!). Develop and preview content and code changes with hot-reloading. When code changes are committed, it will trigger a rebuild of the site and deploy changes to CDNs. The `theme.json` file provides ample ways to customize your survey for your brand. You can easily duplicate future surveys by cloning a copy of a well-maintained `surveyless` repo, which preserves the content and customizations of the original survey setup. 40 | 41 | ### Simple security model 42 | 43 | `surveyless` has no hackable databases/servers/keys components. We do not store any user-sensitive information in survey responses. For open surveys, simply run your surveys with confidence. If you would like to keep the survey content and responses private, you can achieve this by simply making your Git repo private. 44 | 45 | ## Survey Setup 46 | 47 | For a detailed guide, please refer to the [/docs](./docs). 48 | 49 | 1. Click the `Deploy to Netlify` button to deploy an instance of `surveyless` on Netlify and Github. 50 | 2. To develop, 51 | 52 | ```bash 53 | git clone git@github.com:{USERNAME}/{SURVEYLESS_INSTANCE_NAME}.git # clone repo 54 | cd {SURVEYLESS_INSTANCE_NAME} 55 | yarn # install dependencies 56 | yarn dev # starts gatsby development survey 57 | ``` 58 | 59 | 3. Edit `README.md` file to update the landing page survey content. 60 | 4. Edit relevant content in `/survey` folder (e.g. `thankyou.md` and `/survey/questions` markdown file) 61 | 5. Test the survey changes in `localhost`. 62 | 6. `git commit` and `git push` to apply changes to production. 63 | 64 | ## Question Types and Choice Sets 65 | 66 | For a detailed guide, please refer to the [/docs](./docs). 67 | 68 | The following question types and respective choice sets are supported: 69 | 70 | - Likert: 71 | - Rating 72 | - Radio 73 | - Button 74 | - Single-choice 75 | - Radio 76 | - Button 77 | - Multi-choice 78 | - Button 79 | - Checkbox 80 | - Ranking 81 | - Button 82 | - Matrix (can be collection of single-choice or multi-choice question types) 83 | - Rating 84 | - Radio 85 | - Button 86 | - Checkbox 87 | - Slider 88 | - No choice type 89 | - Comment 90 | - No choice type 91 | 92 | All questions can be accompanied by an optional `additionalComments` section where open-ended comments can be captured for the question, in addition to the choice sets provided. 93 | 94 | ## Customizations 95 | 96 | Change your brand logo by attaching a new PNG logo in `/static/logo.png`. 97 | 98 | Use the `/survey/theme.json` file to customize the UI appearance of your survey. Avoid deleting any entries in the `theme.json` file. 99 | 100 | - Edit the `colors` field to customize unified UI colors. 101 | - Edit the `typography` customize font properties. Google fonts are supported (restarting the devserver is required for this). 102 | - Edit the `space` field to customize spacing properties in the UI. 103 | 104 | ## Markdown Assets 105 | 106 | To include and use assets in your markdown, upload images and other assets into the `/static` folder, and refer to them with the following path syntax `/static/images/logo.png`. It is **important** to refer to markdown assets using an absolute path instead of a relative path. 107 | 108 | ## Survey Lifecycle 109 | 110 | TODO: will be updated 111 | 112 | ### Launch 113 | 114 | ### Close 115 | 116 | ### Analyze 117 | 118 | ## Inspirations 119 | 120 | `surveyless` is **heavily inspired** by [`mdx-deck`](https://github.com/jxnblk/mdx-deck), which is a simple but powerful library to build presentation slides in markdown. 121 | 122 | In addition, the `surveyless` mechanics would be non-functional without the great developer tools and experiences built by [Gatsby](https://gatsbyjs.org/) and [Netlify](https://www.netlify.com/). 123 | 124 | ## TODO 125 | 126 | - work on comment, slider, ranking and matrix questions 127 | - hotkey for continue + add hotkey helper icon/modal 128 | - go over questions content to make sure it reads/flows well 129 | - build out /docs folder showcasing examples and detailed guide on question types and other APIs 130 | - netlify-functions to submit response (figure out generating uuids and simple auth with JWT) 131 | - theme editor 132 | - results page 133 | - responses should work off the `/responses` github branch 134 | - STRETCH: tooling to detect malformed markdown/frontmatter, missing/duplicated question Ids. If the content of `/survey` folder has changed, reset the localstorage (or create a button that hard resets localstorage in DRAFT mode) 135 | --------------------------------------------------------------------------------