├── .github └── workflows │ └── build-node.js.yml ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json ├── mockServiceWorker.js └── robots.txt ├── src ├── App.css ├── App.test.tsx ├── App.tsx ├── components │ ├── Editor.tsx │ ├── Surveys.css │ ├── Surveys.tsx │ └── Viewer.tsx ├── index.css ├── index.tsx ├── logo.svg ├── mocks │ ├── browser.ts │ ├── handlers.ts │ └── server.ts ├── models │ ├── in-memory-storage.ts │ └── survey.ts ├── pages │ ├── About.tsx │ ├── Edit.tsx │ ├── Home.tsx │ ├── Results.tsx │ └── Run.tsx ├── react-app-env.d.ts ├── redux │ ├── index.ts │ ├── results.ts │ ├── root-reducer.ts │ └── surveys.ts ├── reportWebVitals.ts ├── routes │ └── index.tsx └── setupTests.ts └── tsconfig.json /.github/workflows/build-node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [18.x, 20.x, 22.x] 20 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Use Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: 'npm' 29 | - run: npm ci 30 | - run: npm run build --if-present 31 | - run: npm test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Node.js CI](https://github.com/surveyjs/surveyjs-react-client/actions/workflows/build-node.js.yml/badge.svg)](https://github.com/surveyjs/surveyjs-react-client/actions/workflows/build-node.js.yml) 2 | 3 | # SurveyJS React Application 4 | 5 | This project is a client-side React application that uses [SurveyJS](https://surveyjs.io/) components. The application displays a list of surveys with the following buttons that perform actions on the surveys: 6 | 7 | - **Run** - Uses the [SurveyJS Form Library](https://surveyjs.io/form-library/documentation/overview) component to run the survey. 8 | - **Edit** - Uses the [Survey Creator](https://surveyjs.io/survey-creator/documentation/overview) component to configure the survey. 9 | - **Results** - Uses the [SurveyJS Dashboard](https://surveyjs.io/dashboard/documentation/overview) component to display survey results as a table. 10 | - **Remove** - Deletes the survey. 11 | 12 | ![My Surveys App](https://user-images.githubusercontent.com/18551316/183420903-7fbcc043-5833-46fe-9910-5aa451045119.png) 13 | 14 | You can integrate this project with a backend of your choice to create a full-cycle survey management service as shown in the following repos: 15 | 16 | - [surveyjs-aspnet-mvc](https://github.com/surveyjs/surveyjs-aspnet-mvc) 17 | - [surveyjs-nodejs](https://github.com/surveyjs/surveyjs-nodejs) 18 | - [surveyjs-php](https://github.com/surveyjs/surveyjs-php) 19 | 20 | ## Run the Application 21 | 22 | ```bash 23 | git clone https://github.com/surveyjs/surveyjs-react-client.git 24 | cd surveyjs-react-client 25 | npm i 26 | npm start 27 | ``` 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "surveyjs-react-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.8.1", 7 | "@testing-library/jest-dom": "^5.16.4", 8 | "@testing-library/react": "^13.0.0", 9 | "@testing-library/user-event": "^13.5.0", 10 | "@types/jest": "^27.4.1", 11 | "@types/node": "^16.11.26", 12 | "@types/react": "^18", 13 | "@types/react-dom": "^18", 14 | "@types/react-redux": "^7.1.23", 15 | "@types/react-router": "^5.1.18", 16 | "@types/react-router-dom": "^5.3.3", 17 | "axios": "^0.26.1", 18 | "connected-react-router": "6.9.3", 19 | "history": "5.3.0", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0", 22 | "react-redux": "^7.2.8", 23 | "react-router": "6.3.0", 24 | "react-router-dom": "6.3.0", 25 | "react-scripts": "5.0.0", 26 | "survey-analytics": "latest", 27 | "survey-core": "latest", 28 | "survey-creator-core": "latest", 29 | "survey-creator-react": "latest", 30 | "survey-react-ui": "latest", 31 | "typescript": "^4.6.3", 32 | "web-vitals": "^2.1.4" 33 | }, 34 | "scripts": { 35 | "start": "react-scripts start", 36 | "build": "react-scripts build", 37 | "test": "react-scripts test", 38 | "eject": "react-scripts eject" 39 | }, 40 | "eslintConfig": { 41 | "extends": [ 42 | "react-app", 43 | "react-app/jest" 44 | ] 45 | }, 46 | "browserslist": { 47 | "production": [ 48 | ">0.2%", 49 | "not dead", 50 | "not op_mini all" 51 | ], 52 | "development": [ 53 | "last 1 chrome version", 54 | "last 1 firefox version", 55 | "last 1 safari version" 56 | ] 57 | }, 58 | "msw": { 59 | "workerDirectory": "public" 60 | }, 61 | "devDependencies": { 62 | "msw": "^0.39.2" 63 | }, 64 | "overrides": { 65 | "connected-react-router": { 66 | "react": "^18.0.0", 67 | "react-router": "6.3.0", 68 | "history": "5.3.0" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surveyjs/surveyjs-react-client/27c011ba37bd23664dc69ae1dc1d4b3c9de5a2eb/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 25 | SurveyJS Service 26 | 27 | 28 | 29 | 30 |
31 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surveyjs/surveyjs-react-client/27c011ba37bd23664dc69ae1dc1d4b3c9de5a2eb/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/surveyjs/surveyjs-react-client/27c011ba37bd23664dc69ae1dc1d4b3c9de5a2eb/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "SurveyJS Service", 3 | "name": "SurveyJS React Service Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/mockServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* tslint:disable */ 3 | 4 | /** 5 | * Mock Service Worker (0.39.2). 6 | * @see https://github.com/mswjs/msw 7 | * - Please do NOT modify this file. 8 | * - Please do NOT serve this file on production. 9 | */ 10 | 11 | const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929' 12 | const bypassHeaderName = 'x-msw-bypass' 13 | const activeClientIds = new Set() 14 | 15 | self.addEventListener('install', function () { 16 | return self.skipWaiting() 17 | }) 18 | 19 | self.addEventListener('activate', async function (event) { 20 | return self.clients.claim() 21 | }) 22 | 23 | self.addEventListener('message', async function (event) { 24 | const clientId = event.source.id 25 | 26 | if (!clientId || !self.clients) { 27 | return 28 | } 29 | 30 | const client = await self.clients.get(clientId) 31 | 32 | if (!client) { 33 | return 34 | } 35 | 36 | const allClients = await self.clients.matchAll() 37 | 38 | switch (event.data) { 39 | case 'KEEPALIVE_REQUEST': { 40 | sendToClient(client, { 41 | type: 'KEEPALIVE_RESPONSE', 42 | }) 43 | break 44 | } 45 | 46 | case 'INTEGRITY_CHECK_REQUEST': { 47 | sendToClient(client, { 48 | type: 'INTEGRITY_CHECK_RESPONSE', 49 | payload: INTEGRITY_CHECKSUM, 50 | }) 51 | break 52 | } 53 | 54 | case 'MOCK_ACTIVATE': { 55 | activeClientIds.add(clientId) 56 | 57 | sendToClient(client, { 58 | type: 'MOCKING_ENABLED', 59 | payload: true, 60 | }) 61 | break 62 | } 63 | 64 | case 'MOCK_DEACTIVATE': { 65 | activeClientIds.delete(clientId) 66 | break 67 | } 68 | 69 | case 'CLIENT_CLOSED': { 70 | activeClientIds.delete(clientId) 71 | 72 | const remainingClients = allClients.filter((client) => { 73 | return client.id !== clientId 74 | }) 75 | 76 | // Unregister itself when there are no more clients 77 | if (remainingClients.length === 0) { 78 | self.registration.unregister() 79 | } 80 | 81 | break 82 | } 83 | } 84 | }) 85 | 86 | // Resolve the "main" client for the given event. 87 | // Client that issues a request doesn't necessarily equal the client 88 | // that registered the worker. It's with the latter the worker should 89 | // communicate with during the response resolving phase. 90 | async function resolveMainClient(event) { 91 | const client = await self.clients.get(event.clientId) 92 | 93 | if (client.frameType === 'top-level') { 94 | return client 95 | } 96 | 97 | const allClients = await self.clients.matchAll() 98 | 99 | return allClients 100 | .filter((client) => { 101 | // Get only those clients that are currently visible. 102 | return client.visibilityState === 'visible' 103 | }) 104 | .find((client) => { 105 | // Find the client ID that's recorded in the 106 | // set of clients that have registered the worker. 107 | return activeClientIds.has(client.id) 108 | }) 109 | } 110 | 111 | async function handleRequest(event, requestId) { 112 | const client = await resolveMainClient(event) 113 | const response = await getResponse(event, client, requestId) 114 | 115 | // Send back the response clone for the "response:*" life-cycle events. 116 | // Ensure MSW is active and ready to handle the message, otherwise 117 | // this message will pend indefinitely. 118 | if (client && activeClientIds.has(client.id)) { 119 | ;(async function () { 120 | const clonedResponse = response.clone() 121 | sendToClient(client, { 122 | type: 'RESPONSE', 123 | payload: { 124 | requestId, 125 | type: clonedResponse.type, 126 | ok: clonedResponse.ok, 127 | status: clonedResponse.status, 128 | statusText: clonedResponse.statusText, 129 | body: 130 | clonedResponse.body === null ? null : await clonedResponse.text(), 131 | headers: serializeHeaders(clonedResponse.headers), 132 | redirected: clonedResponse.redirected, 133 | }, 134 | }) 135 | })() 136 | } 137 | 138 | return response 139 | } 140 | 141 | async function getResponse(event, client, requestId) { 142 | const { request } = event 143 | const requestClone = request.clone() 144 | const getOriginalResponse = () => fetch(requestClone) 145 | 146 | // Bypass mocking when the request client is not active. 147 | if (!client) { 148 | return getOriginalResponse() 149 | } 150 | 151 | // Bypass initial page load requests (i.e. static assets). 152 | // The absence of the immediate/parent client in the map of the active clients 153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet 154 | // and is not ready to handle requests. 155 | if (!activeClientIds.has(client.id)) { 156 | return await getOriginalResponse() 157 | } 158 | 159 | // Bypass requests with the explicit bypass header 160 | if (requestClone.headers.get(bypassHeaderName) === 'true') { 161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers) 162 | 163 | // Remove the bypass header to comply with the CORS preflight check. 164 | delete cleanRequestHeaders[bypassHeaderName] 165 | 166 | const originalRequest = new Request(requestClone, { 167 | headers: new Headers(cleanRequestHeaders), 168 | }) 169 | 170 | return fetch(originalRequest) 171 | } 172 | 173 | // Send the request to the client-side MSW. 174 | const reqHeaders = serializeHeaders(request.headers) 175 | const body = await request.text() 176 | 177 | const clientMessage = await sendToClient(client, { 178 | type: 'REQUEST', 179 | payload: { 180 | id: requestId, 181 | url: request.url, 182 | method: request.method, 183 | headers: reqHeaders, 184 | cache: request.cache, 185 | mode: request.mode, 186 | credentials: request.credentials, 187 | destination: request.destination, 188 | integrity: request.integrity, 189 | redirect: request.redirect, 190 | referrer: request.referrer, 191 | referrerPolicy: request.referrerPolicy, 192 | body, 193 | bodyUsed: request.bodyUsed, 194 | keepalive: request.keepalive, 195 | }, 196 | }) 197 | 198 | switch (clientMessage.type) { 199 | case 'MOCK_SUCCESS': { 200 | return delayPromise( 201 | () => respondWithMock(clientMessage), 202 | clientMessage.payload.delay, 203 | ) 204 | } 205 | 206 | case 'MOCK_NOT_FOUND': { 207 | return getOriginalResponse() 208 | } 209 | 210 | case 'NETWORK_ERROR': { 211 | const { name, message } = clientMessage.payload 212 | const networkError = new Error(message) 213 | networkError.name = name 214 | 215 | // Rejecting a request Promise emulates a network error. 216 | throw networkError 217 | } 218 | 219 | case 'INTERNAL_ERROR': { 220 | const parsedBody = JSON.parse(clientMessage.payload.body) 221 | 222 | console.error( 223 | `\ 224 | [MSW] Uncaught exception in the request handler for "%s %s": 225 | 226 | ${parsedBody.location} 227 | 228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\ 229 | `, 230 | request.method, 231 | request.url, 232 | ) 233 | 234 | return respondWithMock(clientMessage) 235 | } 236 | } 237 | 238 | return getOriginalResponse() 239 | } 240 | 241 | self.addEventListener('fetch', function (event) { 242 | const { request } = event 243 | const accept = request.headers.get('accept') || '' 244 | 245 | // Bypass server-sent events. 246 | if (accept.includes('text/event-stream')) { 247 | return 248 | } 249 | 250 | // Bypass navigation requests. 251 | if (request.mode === 'navigate') { 252 | return 253 | } 254 | 255 | // Opening the DevTools triggers the "only-if-cached" request 256 | // that cannot be handled by the worker. Bypass such requests. 257 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { 258 | return 259 | } 260 | 261 | // Bypass all requests when there are no active clients. 262 | // Prevents the self-unregistered worked from handling requests 263 | // after it's been deleted (still remains active until the next reload). 264 | if (activeClientIds.size === 0) { 265 | return 266 | } 267 | 268 | const requestId = uuidv4() 269 | 270 | return event.respondWith( 271 | handleRequest(event, requestId).catch((error) => { 272 | if (error.name === 'NetworkError') { 273 | console.warn( 274 | '[MSW] Successfully emulated a network error for the "%s %s" request.', 275 | request.method, 276 | request.url, 277 | ) 278 | return 279 | } 280 | 281 | // At this point, any exception indicates an issue with the original request/response. 282 | console.error( 283 | `\ 284 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, 285 | request.method, 286 | request.url, 287 | `${error.name}: ${error.message}`, 288 | ) 289 | }), 290 | ) 291 | }) 292 | 293 | function serializeHeaders(headers) { 294 | const reqHeaders = {} 295 | headers.forEach((value, name) => { 296 | reqHeaders[name] = reqHeaders[name] 297 | ? [].concat(reqHeaders[name]).concat(value) 298 | : value 299 | }) 300 | return reqHeaders 301 | } 302 | 303 | function sendToClient(client, message) { 304 | return new Promise((resolve, reject) => { 305 | const channel = new MessageChannel() 306 | 307 | channel.port1.onmessage = (event) => { 308 | if (event.data && event.data.error) { 309 | return reject(event.data.error) 310 | } 311 | 312 | resolve(event.data) 313 | } 314 | 315 | client.postMessage(JSON.stringify(message), [channel.port2]) 316 | }) 317 | } 318 | 319 | function delayPromise(cb, duration) { 320 | return new Promise((resolve) => { 321 | setTimeout(() => resolve(cb()), duration) 322 | }) 323 | } 324 | 325 | function respondWithMock(clientMessage) { 326 | return new Response(clientMessage.payload.body, { 327 | ...clientMessage.payload, 328 | headers: clientMessage.payload.headers, 329 | }) 330 | } 331 | 332 | function uuidv4() { 333 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 334 | const r = (Math.random() * 16) | 0 335 | const v = c == 'x' ? r : (r & 0x3) | 0x8 336 | return v.toString(16) 337 | }) 338 | } 339 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .sjs-client-app { 2 | color: #404040; 3 | font-family: "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | .sjs-client-app__logo { 7 | vertical-align: middle; 8 | } 9 | 10 | .sjs-client-app__header { 11 | background-color: var(--primary, #19b394); 12 | padding: 10px; 13 | padding-bottom: 0; 14 | } 15 | 16 | .sjs-nav-button { 17 | display: inline-block; 18 | margin-left: 24px; 19 | font-weight: bold; 20 | font-size: 22px; 21 | text-decoration: none; 22 | line-height: 40px; 23 | color: white; 24 | } 25 | 26 | .sjs-nav-button:hover, 27 | .sjs-nav-button.active { 28 | text-decoration: underline; 29 | } 30 | 31 | .sjs-client-app__content { 32 | position: fixed; 33 | top: 64px; 34 | width: 100%; 35 | left: 0; 36 | bottom: 0; 37 | overflow: auto; 38 | user-select: none; 39 | } 40 | 41 | h1 { 42 | padding-left: 24px; 43 | } 44 | 45 | .sjs-client-app__content--about h1, 46 | .sjs-client-app__content--surveys-list h1 { 47 | padding: 0; 48 | } 49 | 50 | .sjs-client-app__content--surveys-list, 51 | .sjs-client-app__content--about { 52 | max-width: 800px; 53 | margin: 0 auto; 54 | } 55 | 56 | .sjs-client-app__footer {} 57 | 58 | .sjs-editor-container { 59 | height: calc(100% - 2px); 60 | } 61 | 62 | .sjs-results-container { 63 | height: calc(100% - 88px); 64 | } 65 | 66 | .sjs-results-content { 67 | height: 100%; 68 | } 69 | 70 | .sjs-results-placeholder { 71 | line-height: 200px; 72 | text-align: center; 73 | border: 1px dotted lightgray; 74 | } -------------------------------------------------------------------------------- /src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | import { load } from './redux/surveys' 5 | import store from './redux'; 6 | 7 | test('renders home page header', () => { 8 | render(); 9 | const linkElement = screen.getByText(/My Surveys/i, { selector: 'span' }); 10 | expect(linkElement).toBeInTheDocument(); 11 | const headerElement = screen.getByText(/My Surveys/i, { selector: 'h1' }); 12 | expect(headerElement).toBeInTheDocument(); 13 | }); 14 | 15 | test('get surveys list', async () => { 16 | const loadResult = await store.dispatch(load()) 17 | const surveys = loadResult.payload 18 | expect(surveys.length).toBe(2) 19 | expect(surveys[0].name).toBe('Product Feedback Survey') 20 | expect(surveys[1].name).toBe('Customer and their partner income survey') 21 | }); -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { BrowserRouter as Router } from "react-router-dom"; 4 | import Content, { NavBar } from './routes' 5 | import store from './redux'; 6 | import './App.css'; 7 | import logo from './logo.svg'; 8 | 9 | function App() { 10 | return ( 11 | 12 | 13 |
14 |
15 | logo 16 | 17 |
18 |
19 | 20 |
21 |
22 |
23 |
24 |
25 |
26 | ); 27 | } 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /src/components/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo } from 'react' 2 | import { useReduxDispatch } from '../redux' 3 | import { get, update } from '../redux/surveys' 4 | import { SurveyCreator, SurveyCreatorComponent } from 'survey-creator-react' 5 | import 'survey-creator-core/survey-creator-core.css' 6 | 7 | const Editor = (params: { id: string }): React.ReactElement => { 8 | const dispatch = useReduxDispatch() 9 | const creator = useMemo(() => { 10 | const options = { 11 | showLogicTab: true, 12 | showThemeTab: true, 13 | showTranslationTab: true 14 | }; 15 | return new SurveyCreator(options); 16 | }, []); 17 | creator.isAutoSave = true; 18 | creator.saveSurveyFunc = (saveNo: number, callback: (no: number, success: boolean) => void) => { 19 | dispatch(update({ id: params.id, json: creator.JSON, text: creator.text })) 20 | callback(saveNo, true); 21 | } 22 | 23 | useEffect(() => { 24 | (async () => { 25 | const surveyAction = await dispatch(get(params.id)) 26 | if(typeof surveyAction.payload.json === 'object') { 27 | creator.JSON = surveyAction.payload.json 28 | } else { 29 | creator.text = surveyAction.payload.json 30 | } 31 | })() 32 | }, [dispatch, creator, params.id]) 33 | 34 | return (<> 35 | 36 | ) 37 | } 38 | 39 | export default Editor -------------------------------------------------------------------------------- /src/components/Surveys.css: -------------------------------------------------------------------------------- 1 | .sjs-surveys-list { 2 | font-size: 16px; 3 | line-height: 40px; 4 | width: 100%; 5 | table-layout: fixed; 6 | } 7 | 8 | .sjs-surveys-list__footer { 9 | margin: 48px auto; 10 | /* max-width: 500px; */ 11 | } 12 | 13 | .sjs-button { 14 | appearance: none; 15 | -webkit-appearance: none; 16 | text-decoration: none; 17 | outline: none; 18 | border-radius: 100px; 19 | background: transparent; 20 | padding: 8px 24px; 21 | border: none; 22 | color: var(--primary, #19b394); 23 | cursor: pointer; 24 | font-weight: 600; 25 | text-align: center; 26 | } 27 | 28 | .sjs-button:hover { 29 | /* background-color: var(--primary, #19b394); */ 30 | background-color: var(--primary-light, rgba(25, 179, 148, 0.1)); 31 | } 32 | 33 | .sjs-add-btn { 34 | display: inline-block; 35 | padding: 16px 48px; 36 | background: white; 37 | box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.15); 38 | border-radius: 4px; 39 | text-align: center; 40 | border: 2px solid transparent; 41 | width: 100%; 42 | box-sizing: border-box; 43 | } 44 | 45 | .sjs-add-btn:hover, 46 | .sjs-add-btn:focus { 47 | border-color: var(--primary, #19b394); 48 | background-color: white; 49 | } 50 | 51 | .sjs-remove-btn { 52 | color: var(--danger, #e60a3e); 53 | } 54 | 55 | .sjs-remove-btn:hover, 56 | .sjs-remove-btn:focus { 57 | /* background-color: var(--danger, #e60a3e); */ 58 | background-color: var(--danger-light, rgba(230, 10, 62, 0.1)); 59 | } -------------------------------------------------------------------------------- /src/components/Surveys.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { create, load, remove } from '../redux/surveys' 3 | import { useReduxDispatch, useReduxSelector } from '../redux' 4 | import { Link } from 'react-router-dom' 5 | import './Surveys.css' 6 | 7 | const Surveys = (): React.ReactElement => { 8 | const surveys = useReduxSelector(state => state.surveys.surveys) 9 | const dispatch = useReduxDispatch() 10 | 11 | const postStatus = useReduxSelector(state => state.surveys.status) 12 | 13 | useEffect(() => { 14 | if (postStatus === 'idle') { 15 | dispatch(load()) 16 | } 17 | }, [postStatus, dispatch]) 18 | 19 | return (<> 20 | 21 | {surveys.map(survey => 22 | 23 | 24 | 30 | 31 | )} 32 |
{survey.name} 25 | Run 26 | Edit 27 | Results 28 | dispatch(remove(survey.id))}>Remove 29 |
33 |
34 | dispatch(create())}>Add Survey 35 |
36 | ) 37 | } 38 | 39 | export default Surveys -------------------------------------------------------------------------------- /src/components/Viewer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef } from 'react' 2 | import { useReduxDispatch } from '../redux' 3 | import { load } from '../redux/results' 4 | import { get } from '../redux/surveys' 5 | import { Model } from 'survey-core' 6 | import 'tabulator-tables/dist/css/tabulator.css' 7 | import 'survey-analytics/survey.analytics.tabulator.css' 8 | const SurveyAnalyticsTabulator = require('survey-analytics/survey.analytics.tabulator') 9 | 10 | const Viewer = (params: { id: string }): React.ReactElement => { 11 | const visContainerRef = useRef(null); 12 | const dispatch = useReduxDispatch() 13 | 14 | useEffect(() => { 15 | (async () => { 16 | const surveyAction = await dispatch(get(params.id)) 17 | const survey = surveyAction.payload 18 | const resultsAction = await dispatch(load(params.id)) 19 | const data = resultsAction.payload.data 20 | if (data.length > 0 && visContainerRef.current) { 21 | var model = new Model(survey.json); 22 | visContainerRef.current.innerHTML = ''; 23 | var surveyAnalyticsTabulator = new SurveyAnalyticsTabulator.Tabulator( 24 | model, 25 | data.map((item: any) => typeof item === 'string' ? JSON.parse(item) : item) 26 | ); 27 | surveyAnalyticsTabulator.render(visContainerRef.current); 28 | } 29 | })() 30 | }, [dispatch, params.id]) 31 | 32 | return (<> 33 |
34 |
35 | This survey doesn't have any answers yet 36 |
37 |
38 | ) 39 | } 40 | 41 | export default Viewer -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | if (process.env.NODE_ENV === 'development') { 8 | const { worker } = require('./mocks/browser') 9 | worker.start() 10 | } 11 | 12 | ReactDOM.render( 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | 19 | // If you want to start measuring performance in your app, pass a function 20 | // to log results (for example: reportWebVitals(console.log)) 21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 22 | reportWebVitals(); 23 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 9 | 10 | 11 | 12 | 24 | 25 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/mocks/browser.ts: -------------------------------------------------------------------------------- 1 | import { setupWorker } from 'msw' 2 | import { handlers } from './handlers' 3 | 4 | // This configures a Service Worker with the given request handlers. 5 | export const worker = setupWorker(...handlers) -------------------------------------------------------------------------------- /src/mocks/handlers.ts: -------------------------------------------------------------------------------- 1 | import { rest } from 'msw' 2 | import { createSurvey, getResults, getSurvey, getSurveys, postResult, removeSurvey, updateSurvey } from '../models/in-memory-storage' 3 | import { apiBaseAddress } from '../models/survey' 4 | 5 | export const handlers = [ 6 | rest.get(apiBaseAddress + '/surveys', (req, res, ctx) => { 7 | // const { userId } = req.params 8 | // return res( 9 | // ctx.json({ 10 | // id: userId, 11 | // firstName: 'John', 12 | // lastName: 'Maverick', 13 | // }), 14 | // ) 15 | return res( 16 | ctx.json(getSurveys()), 17 | ) 18 | }), 19 | rest.get(apiBaseAddress + '/getActive', (req, res, ctx) => { 20 | return res( 21 | ctx.json(getSurveys()), 22 | ) 23 | }), 24 | rest.get(apiBaseAddress + '/create', (req, res, ctx) => { 25 | return res( 26 | ctx.json(createSurvey()), 27 | ) 28 | }), 29 | rest.get(apiBaseAddress + '/delete', (req, res, ctx) => { 30 | const id = req.url.searchParams.get('id') 31 | removeSurvey(id as string); 32 | return res( 33 | ctx.json({ id }), 34 | ) 35 | }), 36 | rest.get(apiBaseAddress + '/getSurvey', (req, res, ctx) => { 37 | const surveyId = req.url.searchParams.get('surveyId') 38 | return res( 39 | ctx.json(getSurvey(surveyId as string)), 40 | ) 41 | }), 42 | rest.post(apiBaseAddress + '/changeJson', (req, res, ctx) => { 43 | const { id, json } = req.body as Record 44 | updateSurvey(id as string, json) 45 | return res( 46 | ctx.json({ id, json }), 47 | ) 48 | }), 49 | rest.post(apiBaseAddress + '/post', (req, res, ctx) => { 50 | const { postId, surveyResult } = req.body as Record 51 | postResult(postId as string, surveyResult) 52 | return res( 53 | ctx.json({}), 54 | ) 55 | }), 56 | rest.get(apiBaseAddress + '/results', (req, res, ctx) => { 57 | const postId = req.url.searchParams.get('postId') 58 | return res( 59 | ctx.json({ id: postId, data: getResults(postId as string) }), 60 | ) 61 | }) 62 | ] -------------------------------------------------------------------------------- /src/mocks/server.ts: -------------------------------------------------------------------------------- 1 | import { setupServer } from 'msw/node' 2 | import { handlers } from './handlers' 3 | 4 | export const server = setupServer(...handlers) -------------------------------------------------------------------------------- /src/models/in-memory-storage.ts: -------------------------------------------------------------------------------- 1 | import { defaultJSON, ISurveyDefinition, survey1Json, survey1Results, survey2Json, survey2Results } from "./survey"; 2 | 3 | const surveys: Array = [ survey1Json, survey2Json ]; 4 | 5 | const results: { [index: string]: Array } = { 6 | '1': survey1Results, 7 | '2': survey2Results 8 | }; 9 | 10 | let nextId = 3; 11 | 12 | export function getSurveys() { 13 | return ([] as Array).concat(surveys); 14 | } 15 | 16 | export function createSurvey() { 17 | let newSurvey = JSON.parse(JSON.stringify(defaultJSON)); 18 | newSurvey.id = '' + nextId++ 19 | newSurvey.name += ' ' + newSurvey.id; 20 | surveys.push(newSurvey); 21 | return newSurvey; 22 | } 23 | 24 | export function getSurvey(id: string) { 25 | return surveys.filter(s => s.id === id)[0]; 26 | } 27 | 28 | export function removeSurvey(id: string) { 29 | const survey = surveys.filter(s => s.id === id)[0]; 30 | const index = surveys.indexOf(survey); 31 | if(index >= 0) { 32 | surveys.splice(index, 1); 33 | } 34 | } 35 | 36 | export function updateSurvey(id: string, json: any) { 37 | const survey = surveys.filter(s => s.id === id)[0]; 38 | survey.json = json; 39 | } 40 | 41 | export function postResult(id: string, json: any) { 42 | if(!Array.isArray(results[id])) { 43 | results[id] = []; 44 | } 45 | results[id].push(json); 46 | } 47 | 48 | export function getResults(id: string) { 49 | return results[id] || []; 50 | } 51 | -------------------------------------------------------------------------------- /src/models/survey.ts: -------------------------------------------------------------------------------- 1 | export interface ISurveyDefinition { 2 | id: string, 3 | name: string, 4 | json: any 5 | } 6 | 7 | export const defaultJSON = { 8 | id: '', 9 | name: 'New Survey', 10 | json: { 11 | elements: [ 12 | { type: 'radiogroup', name: 'question1', choices: [ '1', '2', '3' ] } 13 | ] 14 | } 15 | } 16 | 17 | export const survey1Json = { 18 | id: "1", 19 | name: "Product Feedback Survey", 20 | json: { 21 | pages: [{ 22 | elements: [{ 23 | type: "matrix", 24 | name: "Quality", 25 | title: "Please indicate if you agree or disagree with the following statements", 26 | columns: [{ 27 | value: 1, 28 | text: "Strongly disagree" 29 | }, { 30 | value: 2, 31 | text: "Disagree" 32 | }, { 33 | value: 3, 34 | text: "Neutral" 35 | }, { 36 | value: 4, 37 | text: "Agree" 38 | }, { 39 | value: 5, 40 | text: "Strongly agree" 41 | }], 42 | rows: [{ 43 | value: "affordable", 44 | text: "Product is affordable" 45 | }, { 46 | value: "does what it claims", 47 | text: "Product does what it claims" 48 | }, { 49 | value: "better then others", 50 | text: "Product is better than other products on the market" 51 | }, { 52 | value: "easy to use", 53 | text: "Product is easy to use" 54 | }] 55 | }, { 56 | type: "rating", 57 | name: "satisfaction", 58 | title: "How satisfied are you with the product?", 59 | mininumRateDescription: "Not satisfied", 60 | maximumRateDescription: "Completely satisfied" 61 | }, { 62 | type: "rating", 63 | name: "recommend friends", 64 | visibleIf: "{satisfaction} > 3", 65 | title: "How likely are you to recommend the product to a friend or colleague?", 66 | mininumRateDescription: "Won't recommend", 67 | maximumRateDescription: "Will recommend" 68 | }, { 69 | type: "comment", 70 | name: "suggestions", 71 | title: "What would make you more satisfied with the product?" 72 | }] 73 | }, { 74 | elements: [{ 75 | type: "radiogroup", 76 | name: "price to competitors", 77 | title: "Compared to our competitors, do you feel the product is", 78 | choices: [ 79 | "Less expensive", 80 | "Priced about the same", 81 | "More expensive", 82 | "Not sure" 83 | ] 84 | }, { 85 | type: "radiogroup", 86 | name: "price", 87 | title: "Do you feel our current price is merited by our product?", 88 | choices: [ 89 | "correct|Yes, the price is about right", 90 | "low|No, the price is too low", 91 | "high|No, the price is too high" 92 | ] 93 | }, { 94 | type: "multipletext", 95 | name: "pricelimit", 96 | title: "What is the... ", 97 | items: [{ 98 | name: "mostamount", 99 | title: "Most amount you would pay for a product like ours" 100 | }, { 101 | name: "leastamount", 102 | title: "The least amount you would feel comfortable paying" 103 | }] 104 | }] 105 | }, { 106 | elements: [{ 107 | type: "text", 108 | name: "email", 109 | title: 'Thank you for taking our survey. Please enter your email address and press the "Submit" button.' 110 | }] 111 | }] 112 | } 113 | }; 114 | 115 | export const survey1Results = [ 116 | {'Quality':{'affordable':'5','better then others':'5','does what it claims':'5','easy to use':'5'},'satisfaction':5,'recommend friends':5,'suggestions':'I am happy!','price to competitors':'Not sure','price':'low','pricelimit':{'mostamount':'100','leastamount':'100'}}, 117 | {'Quality':{'affordable':'3','does what it claims':'2','better then others':'2','easy to use':'3'},'satisfaction':3,'suggestions':'better support','price to competitors':'Not sure','price':'high','pricelimit':{'mostamount':'60','leastamount':'10'}} 118 | ]; 119 | 120 | export const survey2Json = { 121 | id: "2", 122 | name: "Customer and their partner income survey", 123 | json: { 124 | completeText: "Finish", 125 | pageNextText: "Continue", 126 | pagePrevText: "Previous", 127 | pages: [{ 128 | elements: [{ 129 | type: "panel", 130 | elements: [{ 131 | type: "html", 132 | name: "income_intro", 133 | html: 134 | "Income. In this section, you will be asked about your current employment status and other ways you and your partner receive income. It will be handy to have the following in front of you: payslip (for employment details), latest statement from any payments (from Centrelink or other authority), a current Centrelink Schedule for any account-based pension from super, annuities, or other income stream products that you may own. If you don't have a current one, you can get these schedules by contacting your income stream provider." 135 | }], 136 | name: "panel1" 137 | }], 138 | name: "page0" 139 | }, { 140 | elements: [{ 141 | type: "panel", 142 | elements: [{ 143 | type: "radiogroup", 144 | choices: [ 145 | "Married", 146 | "In a registered relationship", 147 | "Living with my partner", 148 | "Widowed", 149 | "Single" 150 | ], 151 | name: "maritalstatus_c", 152 | title: " " 153 | }], 154 | name: "panel13", 155 | title: "What is your marital status?" 156 | }], 157 | name: "page1" 158 | }, { 159 | elements: [{ 160 | type: "panel", 161 | elements: [{ 162 | type: "panel", 163 | elements: [{ 164 | type: "radiogroup", 165 | choices: [{ 166 | value: "1", 167 | text: "Yes" 168 | }, { 169 | value: "0", 170 | text: "No" 171 | }], 172 | colCount: 2, 173 | isRequired: true, 174 | name: "member_receives_income_from_employment", 175 | title: " " 176 | }, { 177 | type: "checkbox", 178 | name: "member_type_of_employment", 179 | visible: false, 180 | visibleIf: "{member_receives_income_from_employment} =1", 181 | title: " ", 182 | isRequired: true, 183 | choices: [ 184 | "Self-employed", 185 | "Other types of employment" 186 | ] 187 | }], 188 | name: "panel2", 189 | title: "You" 190 | }, { 191 | type: "panel", 192 | elements: [{ 193 | type: "radiogroup", 194 | choices: [{ 195 | value: "1", 196 | text: "Yes" 197 | }, { 198 | value: "0", 199 | text: "No" 200 | }], 201 | colCount: 2, 202 | isRequired: true, 203 | name: "partner_receives_income_from_employment", 204 | title: " " 205 | }, { 206 | type: "checkbox", 207 | name: "partner_type_of_employment", 208 | visible: false, 209 | visibleIf: "{partner_receives_income_from_employment} =1", 210 | title: " ", 211 | isRequired: true, 212 | choices: [ 213 | "Self-employed", 214 | "Other types of employment" 215 | ] 216 | }], 217 | name: "panel1", 218 | startWithNewLine: false, 219 | title: "Your Partner", 220 | visibleIf: 221 | "{maritalstatus_c} = 'Married' or {maritalstatus_c} = 'In a registered relationship' or {maritalstatus_c} = 'Living with my partner'" 222 | }], 223 | name: "panel5", 224 | title: "Do you and/or your partner currently receive income from employment?" 225 | }], 226 | name: "page2" 227 | }, { 228 | elements: [{ 229 | type: "panel", 230 | elements: [{ 231 | type: "panel", 232 | elements: [{ 233 | type: "paneldynamic", 234 | minPanelCount: 1, 235 | name: "member_array_employer_names", 236 | valueName: "member_array_employer", 237 | title: "Enter information about your employers", 238 | panelAddText: "Add another employer", 239 | panelCount: 1, 240 | templateElements: [{ 241 | type: "text", 242 | name: "member_employer_name", 243 | valueName: "name", 244 | title: "Employer name" 245 | }] 246 | }], 247 | name: "panel2", 248 | title: "You", 249 | visible: false, 250 | visibleIf: "{member_type_of_employment} contains 'Other types of employment'" 251 | }, { 252 | type: "panel", 253 | elements: [{ 254 | type: "paneldynamic", 255 | minPanelCount: 1, 256 | name: "partner_array_employer_names", 257 | valueName: "partner_array_employer", 258 | title: "Enter information about employers of your partner", 259 | panelAddText: "Add another employer", 260 | panelCount: 1, 261 | templateElements: [{ 262 | type: "text", 263 | name: "partner_employer_name", 264 | valueName: "name", 265 | title: "Employer name" 266 | }] 267 | }], 268 | name: "panel8", 269 | startWithNewLine: false, 270 | title: "Your Partner", 271 | visible: false, 272 | visibleIf: 273 | "{partner_type_of_employment} contains 'Other types of employment'" 274 | }], 275 | name: "panel6", 276 | title: "Employers" 277 | }], 278 | name: "page3.1", 279 | visible: false, 280 | visibleIf: 281 | "{member_type_of_employment} contains 'Other types of employment' or {partner_type_of_employment} contains 'Other types of employment'" 282 | }, { 283 | elements: [{ 284 | type: "panel", 285 | elements: [{ 286 | type: "panel", 287 | elements: [{ 288 | type: "paneldynamic", 289 | renderMode: "progressTop", 290 | allowAddPanel: false, 291 | allowRemovePanel: false, 292 | name: "member_array_employer_info", 293 | title: "Your employers", 294 | valueName: "member_array_employer", 295 | panelCount: 1, 296 | templateElements: [{ 297 | type: "panel", 298 | name: "panel_member_employer_address", 299 | title: "Contacts", 300 | elements: [{ 301 | type: "text", 302 | name: "member_employer_address", 303 | valueName: "address", 304 | title: "Address:" 305 | }, { 306 | type: "text", 307 | name: "member_employer_phone", 308 | valueName: "phone", 309 | title: "Phone number:" 310 | }, { 311 | type: "text", 312 | name: "member_employer_abn", 313 | valueName: "abn", 314 | title: "ABN:" 315 | }] 316 | }, { 317 | type: "panel", 318 | name: "panel_member_employer_role", 319 | title: "Are you a full time worker?", 320 | elements: [{ 321 | type: "radiogroup", 322 | choices: [ 323 | "Full-time", 324 | "Part-time", 325 | "Casual", 326 | "Seasonal" 327 | ], 328 | name: "member_employer_role", 329 | title: " ", 330 | valueName: "role" 331 | }] 332 | }, { 333 | type: "panel", 334 | name: "panel_member_employer_hours_work", 335 | title: "How many hours do you work?", 336 | elements: [{ 337 | type: "text", 338 | inputType: "number", 339 | name: "member_employer_hours_worked", 340 | valueName: "hours_worked", 341 | title: "Hours:" 342 | }, { 343 | type: "dropdown", 344 | name: "member_employer_hours_worked_frequency", 345 | title: "Work frequency:", 346 | valueName: "hours_worked_frequency", 347 | startWithNewLine: false, 348 | defaultValue: "Day", 349 | choices: [ 350 | "Day", 351 | "Week", 352 | "Fortnight", 353 | "Month", 354 | "Year" 355 | ] 356 | }] 357 | }, { 358 | type: "panel", 359 | name: "panel_member_employer_income", 360 | title: "What is your income?", 361 | elements: [{ 362 | type: "text", 363 | inputType: "number", 364 | name: "member_employer_income", 365 | valueName: "income", 366 | title: "Income:" 367 | }, { 368 | type: "dropdown", 369 | name: "member_employer_income_frequency", 370 | title: "Income frequency:", 371 | valueName: "income_frequency", 372 | startWithNewLine: false, 373 | defaultValue: "Month", 374 | choices: [ 375 | "Day", 376 | "Week", 377 | "Fortnight", 378 | "Month", 379 | "Year" 380 | ] 381 | }] 382 | }], 383 | templateTitle: "Employer name: {panel.name}" 384 | }], 385 | name: "panel17", 386 | title: "You", 387 | visibleIf: "{member_type_of_employment} contains 'Other types of employment'" 388 | }, { 389 | type: "panel", 390 | elements: [{ 391 | type: "paneldynamic", 392 | renderMode: "progressTop", 393 | allowAddPanel: false, 394 | allowRemovePanel: false, 395 | name: "partner_array_employer_info", 396 | title: "Employers", 397 | valueName: "partner_array_employer", 398 | panelCount: 1, 399 | templateElements: [{ 400 | type: "panel", 401 | name: "panel_partner_employer_address", 402 | title: "Contacts", 403 | elements: [{ 404 | type: "text", 405 | name: "partner_employer_address", 406 | valueName: "address", 407 | title: "Address:" 408 | }, { 409 | type: "text", 410 | name: "partner_employer_phone", 411 | valueName: "phone", 412 | title: "Phone number:" 413 | }, { 414 | type: "text", 415 | name: "partner_employer_abn", 416 | valueName: "abn", 417 | title: "ABN:" 418 | }] 419 | }, { 420 | type: "panel", 421 | name: "panel_partner_employer_role", 422 | title: "Are you a full time worker?", 423 | elements: [{ 424 | type: "radiogroup", 425 | choices: [ 426 | "Full-time", 427 | "Part-time", 428 | "Casual", 429 | "Seasonal" 430 | ], 431 | name: "partner_employer_role", 432 | title: "Your role", 433 | valueName: "role" 434 | }] 435 | }, { 436 | type: "panel", 437 | name: "panel_partner_employer_hours_work", 438 | title: "How many hours do you work?", 439 | elements: [{ 440 | type: "text", 441 | inputType: "number", 442 | name: "partner_employer_hours_worked", 443 | valueName: "hours_worked", 444 | title: "Hours:" 445 | }, { 446 | type: "dropdown", 447 | name: "partner_employer_hours_worked_frequency", 448 | valueName: "hours_worked_frequency", 449 | title: "Work frequency:", 450 | startWithNewLine: false, 451 | defaultValue: "Day", 452 | choices: [ 453 | "Day", 454 | "Week", 455 | "Fortnight", 456 | "Month", 457 | "Year" 458 | ] 459 | }] 460 | }, { 461 | type: "panel", 462 | name: "panel_partner_employer_income", 463 | title: "What is your income?", 464 | elements: [{ 465 | type: "text", 466 | inputType: "number", 467 | name: "partner_employer_income", 468 | valueName: "income", 469 | title: "Income:" 470 | }, { 471 | type: "dropdown", 472 | name: "partner_employer_income_frequency", 473 | valueName: "income_frequency", 474 | title: "Income frequency:", 475 | startWithNewLine: false, 476 | defaultValue: "Month", 477 | choices: [ 478 | "Day", 479 | "Week", 480 | "Fortnight", 481 | "Month", 482 | "Year" 483 | ] 484 | }] 485 | }], 486 | templateTitle: "Employer name: {panel.name}" 487 | }], 488 | name: "panel18", 489 | startWithNewLine: false, 490 | title: "You partner", 491 | visibleIf: "{partner_type_of_employment} contains 'Other types of employment'" 492 | }], 493 | name: "panel16", 494 | title: "Enter information about your employers" 495 | }], 496 | name: "page3.2", 497 | visibleIf: 498 | "{member_type_of_employment} contains 'Other types of employment' or {partner_type_of_employment} contains 'Other types of employment'" 499 | }, { 500 | elements: [{ 501 | type: "panel", 502 | elements: [{ 503 | type: "panel", 504 | elements: [{ 505 | type: "radiogroup", 506 | choices: [{ 507 | value: "1", 508 | text: "Yes" 509 | }, { 510 | value: "0", 511 | text: "No" 512 | }], 513 | colCount: 2, 514 | isRequired: true, 515 | name: "member_receive_fringe_benefits", 516 | title: " " 517 | }, { 518 | type: "panel", 519 | elements: [{ 520 | type: "text", 521 | name: "member_fringe_benefits_type" 522 | }, { 523 | type: "text", 524 | name: "member_fringe_benefits_value" 525 | }, { 526 | type: "radiogroup", 527 | choices: ["Grossed up", "Not grossed up"], 528 | name: "member_fringe_benefits_grossing" 529 | }], 530 | name: "panel11", 531 | visible: false, 532 | visibleIf: "{member_receive_fringe_benefits} = 1" 533 | }], 534 | name: "panel2", 535 | title: "You", 536 | visible: false, 537 | visibleIf: "{member_type_of_employment} contains 'Other types of employment'" 538 | }, { 539 | type: "panel", 540 | elements: [{ 541 | type: "radiogroup", 542 | choices: [{ 543 | value: "1", 544 | text: "Yes" 545 | }, { 546 | value: "0", 547 | text: "No" 548 | }], 549 | colCount: 2, 550 | isRequired: true, 551 | name: "partner_receive_fringe_benefits", 552 | title: " " 553 | }, { 554 | type: "panel", 555 | elements: [{ 556 | type: "text", 557 | name: "partner_fringe_benefits_type" 558 | }, { 559 | type: "text", 560 | name: "partner_fringe_benefits_value" 561 | }, { 562 | type: "radiogroup", 563 | choices: ["Grossed up", "Not grossed up"], 564 | name: "partner_fringe_benefits_grossing" 565 | }], 566 | name: "panel12", 567 | visible: false, 568 | visibleIf: "{partner_receive_fringe_benefits} = 1" 569 | }], 570 | name: "panel1", 571 | startWithNewLine: false, 572 | title: "Your Partner", 573 | visible: false, 574 | visibleIf: "{partner_type_of_employment} contains 'Other types of employment'" 575 | }], 576 | name: "panel9", 577 | title: "Do any of your employers provide you with fringe benefits?" 578 | }], 579 | name: "page4", 580 | visible: false, 581 | visibleIf: 582 | "{member_type_of_employment} contains 'Other types of employment' or {partner_type_of_employment} contains 'Other types of employment'" 583 | }, { 584 | elements: [{ 585 | type: "panel", 586 | elements: [{ 587 | type: "panel", 588 | elements: [{ 589 | type: "radiogroup", 590 | choices: [{ 591 | value: "1", 592 | text: "Yes" 593 | }, { 594 | value: "0", 595 | text: "No" 596 | }], 597 | colCount: 2, 598 | isRequired: true, 599 | name: "member_seasonal_intermittent_or_contract_work", 600 | title: " " 601 | }], 602 | name: "panel2", 603 | title: "You", 604 | visible: false, 605 | visibleIf: "{member_receives_income_from_employment} = 1" 606 | }, { 607 | type: "panel", 608 | elements: [{ 609 | type: "radiogroup", 610 | choices: [{ 611 | value: "1", 612 | text: "Yes" 613 | }, { 614 | value: "0", 615 | text: "No" 616 | }], 617 | colCount: 2, 618 | isRequired: true, 619 | name: "partner_seasonal_intermittent_or_contract_work", 620 | title: " " 621 | }], 622 | name: "panel1", 623 | startWithNewLine: false, 624 | title: "Your Partner", 625 | visible: false, 626 | visibleIf: "{partner_receives_income_from_employment} =1 " 627 | }], 628 | name: "panel10", 629 | title: "In the last 6 months, have you done any seasonal, intermittent or contract work?" 630 | }], 631 | name: "page5", 632 | visible: false, 633 | visibleIf: "{member_receives_income_from_employment} = 1 or {partner_receives_income_from_employment} =1 " 634 | }], 635 | requiredText: "", 636 | showQuestionNumbers: "off", 637 | storeOthersAsComment: false 638 | } 639 | }; 640 | 641 | export const survey2Results = [ 642 | {'member_array_employer':[{}],'partner_array_employer':[{}],'maritalstatus_c':'Married','member_receives_income_from_employment':'0','partner_receives_income_from_employment':'0'}, 643 | {'member_array_employer':[{}],'partner_array_employer':[{}],'maritalstatus_c':'Single','member_receives_income_from_employment':'1','member_type_of_employment':['Self employment'],'member_seasonal_intermittent_or_contract_work':'0'} 644 | ]; 645 | 646 | export var apiBaseAddress = '/api'; -------------------------------------------------------------------------------- /src/pages/About.tsx: -------------------------------------------------------------------------------- 1 | const About = () => (
2 |

About

3 |

4 | This React application demonstrates how to implement a basic SurveyJS service client. You can use it with any backend that supports REST API. 5 |

6 |
) 7 | 8 | export default About; -------------------------------------------------------------------------------- /src/pages/Edit.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'react-router' 2 | import Editor from '../components/Editor' 3 | 4 | const Edit = () => { 5 | const { id } = useParams(); 6 | return (<> 7 |
8 | 9 |
10 | ); 11 | } 12 | 13 | export default Edit; -------------------------------------------------------------------------------- /src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import Surveys from '../components/Surveys' 2 | 3 | const Home = () => ( 4 |
5 |

My Surveys

6 | 7 |
8 | ) 9 | 10 | export default Home; -------------------------------------------------------------------------------- /src/pages/Results.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'react-router' 2 | import { useReduxSelector } from '../redux'; 3 | import Viewer from '../components/Viewer' 4 | 5 | const Results = () => { 6 | const { id } = useParams(); 7 | const surveys = useReduxSelector(state => state.surveys.surveys) 8 | const survey = surveys.filter(s => s.id === id)[0] 9 | return (<> 10 |

{'\'' + survey.name + '\' results'}

11 |
12 | 13 |
14 | ); 15 | } 16 | 17 | export default Results; -------------------------------------------------------------------------------- /src/pages/Run.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from 'react-router' 2 | import { useReduxDispatch, useReduxSelector } from '../redux' 3 | import { post } from '../redux/results' 4 | import { Model } from 'survey-core' 5 | import { Survey } from 'survey-react-ui' 6 | import 'survey-core/survey-core.css' 7 | 8 | const Run = () => { 9 | const { id } = useParams(); 10 | const dispatch = useReduxDispatch() 11 | const surveys = useReduxSelector(state => state.surveys.surveys) 12 | const survey = surveys.filter(s => s.id === id)[0] 13 | const model = new Model(survey.json) 14 | 15 | model 16 | .onComplete 17 | .add((sender: Model) => { 18 | dispatch(post({postId: id as string, surveyResult: sender.data, surveyResultText: JSON.stringify(sender.data)})) 19 | }); 20 | 21 | return (<> 22 |

{survey.name}

23 | 24 | ); 25 | } 26 | 27 | export default Run; -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/redux/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit' 2 | import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux' 3 | import { createBrowserHistory } from 'history' 4 | import rootReducer from './root-reducer' 5 | 6 | export const history = createBrowserHistory() 7 | 8 | const store = configureStore({ 9 | reducer: rootReducer(history), 10 | // middleware: getDefaultMiddleware => getDefaultMiddleware(), // .prepend(middleware) 11 | }) 12 | 13 | export type RootState = ReturnType 14 | 15 | export type AppDispatch = typeof store.dispatch 16 | export const useReduxDispatch = (): AppDispatch => useDispatch() 17 | export const useReduxSelector: TypedUseSelectorHook = useSelector 18 | export default store -------------------------------------------------------------------------------- /src/redux/results.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit' 2 | import axios from 'axios' 3 | import { apiBaseAddress } from '../models/survey' 4 | 5 | export const load = createAsyncThunk('results/load', async (id: string) => { 6 | const response = await axios.get(apiBaseAddress + '/results?postId=' + id) 7 | return response.data 8 | }) 9 | 10 | export const post = createAsyncThunk('results/post', async (data: {postId: string, surveyResult: any, surveyResultText: string}) => { 11 | const response = await axios.post(apiBaseAddress + '/post', data); 12 | return response.data 13 | }) 14 | -------------------------------------------------------------------------------- /src/redux/root-reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from '@reduxjs/toolkit' 2 | import { connectRouter } from 'connected-react-router' 3 | import { History } from 'history' 4 | import surveysReducer from './surveys' 5 | 6 | const rootReducer = (history: History) => 7 | combineReducers({ 8 | surveys: surveysReducer, 9 | router: connectRouter(history), 10 | }) 11 | 12 | export default rootReducer -------------------------------------------------------------------------------- /src/redux/surveys.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice } from '@reduxjs/toolkit' 2 | import axios from 'axios' 3 | import { apiBaseAddress, ISurveyDefinition } from '../models/survey' 4 | 5 | const initialState: { surveys: Array, status: string, error: any } = { 6 | surveys: [], 7 | status: 'idle', 8 | error: null 9 | } 10 | 11 | const surveysSlice = createSlice({ 12 | name: 'surveys', 13 | initialState, 14 | reducers: { 15 | // add: (state, action: PayloadAction) => { 16 | // state.surveys.push(getDefaultJSON()); 17 | // }, 18 | // remove: (state, action: PayloadAction) => { 19 | // const survey = state.surveys.filter(s => s.id === action.payload)[0]; 20 | // const index = state.surveys.indexOf(survey); 21 | // if(index >= 0) { 22 | // state.surveys.splice(index, 1); 23 | // } 24 | // }, 25 | // update: (state, action: PayloadAction<{id: string, json: any}>) => { 26 | // const survey = state.surveys.filter(s => s.id === action.payload.id)[0]; 27 | // survey.json = action.payload.json; 28 | // }, 29 | }, 30 | extraReducers(builder) { 31 | builder 32 | .addCase(load.pending, (state, action) => { 33 | state.status = 'loading' 34 | }) 35 | .addCase(load.fulfilled, (state, action) => { 36 | state.status = 'succeeded' 37 | // Add any fetched surveys to the array 38 | state.surveys = state.surveys.concat(action.payload) 39 | }) 40 | .addCase(load.rejected, (state, action) => { 41 | state.status = 'failed' 42 | state.error = action.error.message 43 | }) 44 | .addCase(create.fulfilled, (state, action) => { 45 | state.status = 'succeeded' 46 | // Add new survey to the array 47 | state.surveys.push(action.payload) 48 | }) 49 | .addCase(remove.fulfilled, (state, action) => { 50 | state.status = 'succeeded' 51 | // Remove survey from the array 52 | const survey = state.surveys.filter(s => s.id === action.payload.id)[0]; 53 | const index = state.surveys.indexOf(survey); 54 | if(index >= 0) { 55 | state.surveys.splice(index, 1); 56 | } 57 | }) 58 | .addCase(update.fulfilled, (state, action) => { 59 | state.status = 'succeeded' 60 | // Update survey in the array 61 | const survey = state.surveys.filter(s => s.id === action.payload.id)[0]; 62 | survey.json = action.payload.json; 63 | }) 64 | } 65 | }) 66 | 67 | export const load = createAsyncThunk('surveys/load', async () => { 68 | const response = await axios.get(apiBaseAddress + '/getActive') 69 | return response.data 70 | }) 71 | 72 | export const get = createAsyncThunk('surveys/get', async (id: string) => { 73 | const response = await axios.get(apiBaseAddress + '/getSurvey?surveyId=' + id) 74 | return response.data 75 | }) 76 | 77 | export const create = createAsyncThunk('surveys/create', async () => { 78 | const response = await axios.get(apiBaseAddress + '/create') 79 | return response.data 80 | }) 81 | 82 | export const remove = createAsyncThunk('surveys/delete', async (id: string) => { 83 | const response = await axios.get(apiBaseAddress + '/delete?id=' + id) 84 | return response.data 85 | }) 86 | 87 | export const update = createAsyncThunk('surveys/update', async (data: {id: string, json: any, text: string}) => { 88 | const response = await axios.post(apiBaseAddress + '/changeJson', data) 89 | return response.data 90 | }) 91 | 92 | // export const { add, remove, update } = surveysSlice.actions 93 | export default surveysSlice.reducer -------------------------------------------------------------------------------- /src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /src/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, NavLink, Routes } from 'react-router-dom' 3 | import Home from "../pages/Home" 4 | import About from "../pages/About" 5 | import Run from "../pages/Run" 6 | import Edit from "../pages/Edit" 7 | import Results from "../pages/Results" 8 | 9 | export const NavBar = () => ( 10 | <> 11 | My Surveys 12 | About 13 | 14 | ) 15 | 16 | const NoMatch = () => (<>

404

) 17 | 18 | const Content = (): React.ReactElement => ( 19 | <> 20 | 21 | }> 22 | }> 23 | }> 24 | }> 25 | }> 26 | }> 27 | 28 | 29 | ) 30 | 31 | export default Content -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | 7 | import { server } from './mocks/server' 8 | // Establish API mocking before all tests. 9 | beforeAll(() => server.listen()) 10 | // Reset any request handlers that we may add during the tests, 11 | // so they don't affect other tests. 12 | afterEach(() => server.resetHandlers()) 13 | // Clean up after the tests are finished. 14 | afterAll(() => server.close()) -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | --------------------------------------------------------------------------------