├── .gitignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json └── src ├── index.html ├── index.js └── schema.gql /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .cache/ 3 | dist/ 4 | .env 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Room Sentiment 2 | 3 | A small app made with state machines to get a feel for the room. 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "room-sentiment", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "parcel build ./src/index.html", 8 | "start": "parcel ./src/index.html", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "Kyle Shevlin (https://kyleshevlin.com)", 12 | "license": "MIT", 13 | "dependencies": { 14 | "@apollo/react-hooks": "^3.1.3", 15 | "@emotion/core": "^10.0.22", 16 | "@xstate/react": "^0.8.0", 17 | "apollo-boost": "^0.4.4", 18 | "apollo-link": "^1.2.13", 19 | "graphql": "^14.5.8", 20 | "parcel-bundler": "^1.12.4", 21 | "react": "^16.11.0", 22 | "react-dom": "^16.11.0", 23 | "xstate": "^4.6.7" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Room Sentiment 8 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | 3 | import { jsx } from '@emotion/core' 4 | import React from 'react' 5 | import ReactDOM from 'react-dom' 6 | import { assign, Machine } from 'xstate' 7 | import { useMachine } from '@xstate/react' 8 | import ApolloClient, { gql } from 'apollo-boost' 9 | 10 | const client = new ApolloClient({ 11 | uri: 'https://graphql.fauna.com/graphql', 12 | headers: { 13 | Authorization: `Bearer ${process.env.FAUNA_SECRET}`, 14 | }, 15 | }) 16 | 17 | const inflect = (singular, plural, number) => (number === 1 ? singular : plural) 18 | 19 | const createSentiment = value => gql` 20 | mutation CreateSentiment { 21 | createSentiment(data: { 22 | value: ${value} 23 | }) { 24 | value 25 | } 26 | } 27 | ` 28 | 29 | const getAllSentiments = gql` 30 | query GetAllSentiments { 31 | allSentiments { 32 | data { 33 | value 34 | } 35 | } 36 | } 37 | ` 38 | 39 | const sentimentMachine = Machine( 40 | { 41 | id: 'sentiment', 42 | initial: 'idle', 43 | context: { 44 | level: null, 45 | }, 46 | states: { 47 | idle: { 48 | on: { 49 | RESET: { 50 | actions: ['resetLevel'], 51 | }, 52 | SELECT: { 53 | actions: ['updateLevel'], 54 | }, 55 | SUBMIT: { 56 | target: 'submitting', 57 | cond: 'levelSelected', 58 | }, 59 | }, 60 | }, 61 | submitting: { 62 | invoke: { 63 | id: 'submission', 64 | src: 'submission', 65 | onDone: 'success', 66 | onError: 'failure', 67 | }, 68 | }, 69 | success: {}, 70 | failure: { 71 | on: { 72 | RESET: { 73 | target: 'idle', 74 | actions: ['resetLevel'], 75 | }, 76 | RETRY: 'submitting', 77 | }, 78 | }, 79 | }, 80 | }, 81 | { 82 | actions: { 83 | resetLevel: assign({ 84 | level: null, 85 | }), 86 | updateLevel: assign({ 87 | level: (context, event) => event.level, 88 | }), 89 | }, 90 | guards: { 91 | levelSelected: context => context.level !== null, 92 | }, 93 | services: { 94 | submission: context => 95 | client.mutate({ 96 | mutation: createSentiment(context.level), 97 | }), 98 | }, 99 | } 100 | ) 101 | 102 | const Form = ({ current, send }) => { 103 | const { level } = current.context 104 | 105 | const handleSubmission = e => { 106 | e.preventDefault() 107 | send('SUBMIT') 108 | } 109 | 110 | return ( 111 |
112 |
On a scale from 0 to 10, how are you feeling?
113 |
119 | {Array(11) 120 | .fill() 121 | .map((_, index) => { 122 | const selected = level === index 123 | 124 | return ( 125 | 156 | ) 157 | })} 158 |
159 | 160 |
161 | 177 | 191 |
192 |
193 | ) 194 | } 195 | 196 | const Failure = ({ send }) => ( 197 |
198 |
Sorry, the form failed to submit.
199 |
200 | 216 | 232 |
233 |
234 | ) 235 | 236 | const calculateAverageSentiment = sentiments => { 237 | const totalSentiment = sentiments.reduce((acc, cur) => acc + cur.value, 0) 238 | return (totalSentiment / sentiments.length).toFixed(1) 239 | } 240 | 241 | const resultsMachine = Machine( 242 | { 243 | id: 'results', 244 | initial: 'idle', 245 | context: { 246 | sentiments: [], 247 | }, 248 | states: { 249 | idle: { 250 | on: { 251 | REQUEST: 'loading', 252 | }, 253 | }, 254 | loading: { 255 | invoke: { 256 | id: 'getAllSentiments', 257 | src: 'getAllSentiments', 258 | onDone: { 259 | target: 'success', 260 | actions: ['updateSentiments'], 261 | }, 262 | onError: 'failure', 263 | }, 264 | }, 265 | success: {}, 266 | failure: {}, 267 | }, 268 | }, 269 | { 270 | actions: { 271 | updateSentiments: assign({ 272 | sentiments: (context, event) => event.data.data.allSentiments.data, 273 | }), 274 | }, 275 | services: { 276 | getAllSentiments: () => 277 | client.query({ 278 | query: getAllSentiments, 279 | }), 280 | }, 281 | } 282 | ) 283 | 284 | const Results = () => { 285 | const [current, send] = useMachine(resultsMachine) 286 | const { sentiments } = current.context 287 | 288 | if (current.matches('loading')) { 289 | return
Tabulating results...
290 | } 291 | 292 | if (current.matches('failure')) { 293 | return ( 294 |
Sorry, there was an error calculating the results. Our bad.
295 | ) 296 | } 297 | 298 | if (current.matches('success')) { 299 | return ( 300 |
301 |
The average score is...
302 |
303 | {calculateAverageSentiment(sentiments)} 304 |
305 |
306 | ...out of {sentiments.length}{' '} 307 | {inflect('participant', 'participants', sentiments.length)}. Thanks 308 | for being one of them. 309 |
310 |
311 | ) 312 | } 313 | 314 | return ( 315 |
316 |
Would you like to see the results?
317 | 333 |
334 | ) 335 | } 336 | 337 | const Success = () => ( 338 |
339 |
Success! Thank you for participating.
340 | 341 |
342 | ) 343 | 344 | const App = () => { 345 | const [current, send] = useMachine(sentimentMachine) 346 | 347 | const renderState = () => { 348 | switch (true) { 349 | case current.matches('idle'): 350 | return
351 | 352 | case current.matches('submitting'): 353 | return
Submitting data...
354 | 355 | case current.matches('failure'): 356 | return 357 | 358 | case current.matches('success'): 359 | return 360 | 361 | default: 362 | return null 363 | } 364 | } 365 | 366 | return ( 367 |
373 |

Room Sentiment

374 | {renderState()} 375 |
376 | ) 377 | } 378 | 379 | ReactDOM.render(, document.getElementById('app')) 380 | -------------------------------------------------------------------------------- /src/schema.gql: -------------------------------------------------------------------------------- 1 | type Sentiment { 2 | value: Int! 3 | } 4 | 5 | type Query { 6 | allSentiments: [Sentiment!] 7 | } 8 | --------------------------------------------------------------------------------