├── .gitignore
├── README.md
├── functions
├── todos-create.js
├── todos-delete-batch.js
├── todos-delete.js
├── todos-read-all.js
├── todos-read.js
├── todos-update.js
└── utils
│ └── getId.js
├── netlify.toml
├── package-lock.json
├── package.json
├── public
├── favicon.ico
├── index.html
└── manifest.json
├── scripts
└── bootstrap-fauna-database.js
└── src
├── App.css
├── App.js
├── App.test.js
├── assets
├── deploy-to-netlify.svg
├── github.svg
└── logo.svg
├── components
├── AppHeader
│ ├── AppHeader.css
│ └── index.js
├── ContentEditable
│ ├── ContentEditable.css
│ ├── Editable.js
│ └── index.js
├── SettingsIcon
│ ├── SettingIcon.css
│ └── index.js
└── SettingsMenu
│ ├── SettingsMenu.css
│ └── index.js
├── index.css
├── index.js
└── utils
├── analytics.js
├── api.js
├── isLocalHost.js
└── sortByDate.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 | /functions-build
12 |
13 | # netlify
14 | .netlify
15 |
16 | # misc
17 | .DS_Store
18 | .env.local
19 | .env.development.local
20 | .env.test.local
21 | .env.production.local
22 |
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Netlify + FaunaDB
2 |
3 | Example of using [FaunaDB](https://fauna.com/) with [Netlify functions](https://www.netlify.com/docs/functions/)
4 |
5 |
6 |
7 | Expand Table of Contents
8 |
9 | - [About this application](#about-this-application)
10 | - [Setup & Run Locally](#setup--run-locally)
11 | - [TLDR; Quick Deploy](#tldr-quick-deploy)
12 | - [Tutorial](#tutorial)
13 | * [Background](#background)
14 | * [1. Create React app](#1-create-react-app)
15 | * [2. Set up FaunaDB](#2-set-up-faunadb)
16 | * [3. Create a function](#3-create-a-function)
17 | + [Anatomy of a Lambda function](#anatomy-of-a-lambda-function)
18 | + [Setting up functions for local development](#setting-up-functions-for-local-development)
19 | * [4. Connect the function to the frontend app](#4-connect-the-function-to-the-frontend-app)
20 | * [5. Finishing the backend Functions](#5-finishing-the-backend-functions)
21 | * [Wrapping Up](#wrapping-up)
22 |
23 |
24 |
25 |
26 | ## About this application
27 |
28 | This application is using [React](https://reactjs.org/) for the frontend, [Netlify Functions](https://www.netlify.com/docs/functions/) for API calls, and [FaunaDB](https://fauna.com/) as the backing database.
29 |
30 | 
31 |
32 | ## Deploy with one click
33 |
34 | Click the [Deploy to Netlify Button](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/fauna-one-click&stack=fauna)
35 |
36 | [](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/netlify-faunadb-example&stack=fauna)
37 |
38 | ## Setup & Run Locally
39 |
40 | 1. Clone down the repository
41 |
42 | ```bash
43 | git clone https://github.com/netlify/netlify-faunadb-example.git
44 | ```
45 |
46 | 2. Enter the repo directory
47 |
48 | ```bash
49 | cd netlify-faunadb-example
50 | ```
51 |
52 | 3. Install the dependencies
53 |
54 | ```bash
55 | npm install
56 | ```
57 |
58 | 4. Sign up for a FaunaDB account
59 |
60 | https://dashboard.fauna.com/accounts/register
61 |
62 | 5. Create a database
63 |
64 | In the Fauna Cloud Console:
65 | - Click “New Database”
66 | - Enter “Netlify” as the “Database Name”
67 | - Click “Save”
68 |
69 | 6. Create a database access key
70 |
71 | In the Fauna Cloud Console:
72 | - Click “Security” in the left navigation
73 | - Click “New Key”
74 | - Make sure that the “Database” field is set to “Netlify”
75 | - Make sure that the “Role” field is set to “Admin”
76 | - Enter “Netlify” as the “Key Name”
77 | - Click “Save”
78 |
79 | 7. Copy the database access key’s secret
80 |
81 | Save the secret somewhere safe; you won’t get a second chance to see it.
82 |
83 | 8. Set your database access secret in your terminal environment
84 |
85 | In your terminal, run the following command:
86 |
87 | ```bash
88 | export FAUNADB_SERVER_SECRET=YourFaunaDBSecretHere
89 | ```
90 |
91 | Replace `YourFaunaDBSecretHere` with the value of the secret that you copied in the previous step.
92 |
93 | 9. Bootstrap your FaunaDB collection and indexes
94 |
95 | ```bash
96 | npm run bootstrap
97 | ```
98 |
99 | 10. Run project locally
100 |
101 | ```bash
102 | npm start
103 | ```
104 |
105 | ## TLDR; Quick Deploy
106 |
107 | 1. Click the [Deploy to Netlify button](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/netlify-faunadb-example)
108 |
109 | [](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/netlify-faunadb-example)
110 |
111 | 2. Click “Connect to GitHub”. Authorize Netlify, when asked.
112 |
113 | 3. Paste your FaunaDB database access secret into the “Your FaunaDB Server Secret” field.
114 |
115 | 4. Click “Save & Deploy”. Netlify clones your repo, then builds and deploys your app. All done!
116 |
117 | 
118 |
119 | ## Tutorial
120 |
121 | ### Background
122 |
123 | This application is using [React](https://reactjs.org/) for the frontend, [Netlify Functions](https://www.netlify.com/docs/functions/) for API calls, and [FaunaDB](https://fauna.com/) as the backing database.
124 |
125 | We are going to explore how to get up and running with Netlify Functions and how to deploy your own serverless backend.
126 |
127 | ### 1. Create React app
128 |
129 | We are using React for this demo app, but you can use whatever you want to manage the frontend.
130 |
131 | Into VueJS? Awesome use that.
132 |
133 | Miss the days of jQuery? Righto, jQuery away!
134 |
135 | Fan of VanillaJS? By all means, have at it!
136 |
137 | 1. Install create react app
138 |
139 | ```bash
140 | npm install create-react-app -g
141 | ```
142 | 2. Create the react app!
143 |
144 | ```bash
145 | create-react-app my-app
146 | ```
147 |
148 | 3. The react app is now setup!
149 |
150 | ```bash
151 | # change directories into my-app
152 | cd my-app
153 | ```
154 |
155 | ### 2. Set up FaunaDB
156 |
157 | We are using FaunaDB to hold and store all of our todo data.
158 |
159 | To setup a FaunaDB account and get the API key we'll use to scaffold out our todos database, head over to [https://dashboard.fauna.com/accounts/register](https://dashboard.fauna.com/accounts/register) and create a free Fauna Cloud account.
160 |
161 | 1. **Sign up**
162 |
163 | 
164 |
165 | 2. **Create a key**
166 |
167 | 
168 |
169 | 3. **Name your key and create**
170 |
171 | 
172 |
173 | 4. **Copy this API key for later use, or use the [Deploy to Netlify Button](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/netlify-faunadb-example) and plugin this API key.**
174 |
175 | 
176 |
177 | 5. **Create your FaunaDB database**
178 |
179 | Set the FaunaDB API key locally in your terminal
180 |
181 | ```bash
182 | # on mac
183 | export FAUNADB_SERVER_SECRET=YourFaunaDBKeyHere
184 | # on windows
185 | set FAUNADB_SERVER_SECRET=YourFaunaDBKeyHere
186 | ```
187 |
188 | Replace `YourFaunaDBSecretHere` with the value of the secret that you copied in the previous step.
189 |
190 | Add the [/scripts/bootstrap-fauna-database.js](https://github.com/netlify/netlify-faunadb-example/blob/f965df497f0de507c2dfdb1a8a32a81bbd939314/scripts/bootstrap-fauna-database.js) to the root directory of the project. This is an idempotent script that you can run one million times and have the same result (one todos database)
191 |
192 | Next up, add the bootstrap command to npm scripts in your `package.json` file
193 |
194 | ```json
195 | {
196 | "scripts": {
197 | "bootstrap": "node ./scripts/bootstrap-fauna-database.js"
198 | }
199 | }
200 | ```
201 |
202 | Now we can run the `bootstrap` command to setup our Fauna database in our FaunaDB account.
203 |
204 | ```bash
205 | npm run bootstrap
206 | ```
207 |
208 | If you log in to the [FaunaDB dashboard](https://dashboard.fauna.com/) you will see your todo database.
209 |
210 | ### 3. Create a function
211 |
212 | Now, let’s create a function for our app and wire that up to run locally.
213 |
214 | The functions in our project are going to live in a `/functions` folder. You can set this to whatever you'd like but we like the `/functions` convention.
215 |
216 | #### Anatomy of a Lambda function
217 |
218 | All AWS Lambda functions have the following signature:
219 |
220 | ```js
221 | exports.handler = (event, context, callback) => {
222 | // "event" has information about the path, body, headers, etc. of the request
223 | console.log('event', event)
224 | // "context" has information about the lambda environment and user details
225 | console.log('context', context)
226 | // The "callback" ends the execution of the function and returns a response back to the caller
227 | return callback(null, {
228 | statusCode: 200,
229 | body: JSON.stringify({
230 | data: '⊂◉‿◉つ'
231 | })
232 | })
233 | }
234 | ```
235 |
236 | We are going to use the `faunadb` npm package to connect to our Fauna Database and create an item.
237 |
238 | #### Setting up functions for local development
239 |
240 | Let's rock and roll.
241 |
242 | 1. **Create a `./functions` directory**
243 |
244 | ```bash
245 | # make functions directory
246 | mdkir functions
247 | ```
248 |
249 | 2. **Install `netlify-lambda`**
250 |
251 | [Netlify lambda](https://github.com/netlify/netlify-lambda) is a tool for locally emulating the serverless function for development and for bundling our serverless function with third party npm modules (if we are using those)
252 |
253 | ```
254 | npm i netlify-lambda --save-dev
255 | ```
256 |
257 | To simulate our function endpoints locally, we need to setup a [proxy](https://github.com/netlify/create-react-app-lambda/blob/master/package.json#L19-L26) for webpack to use.
258 |
259 | In `package.json` add:
260 |
261 | ```json
262 | {
263 | "name": "react-lambda",
264 | ...
265 | "proxy": {
266 | "/.netlify/functions": {
267 | "target": "http://localhost:9000",
268 | "pathRewrite": {
269 | "^/\\.netlify/functions": ""
270 | }
271 | }
272 | }
273 | }
274 | ```
275 |
276 | This will proxy requests we make to `/.netlify/functions` to our locally-running function server at port 9000.
277 |
278 | 3. **Add our `start` & `build` commands**
279 |
280 | Let's go ahead and add our `start` & `build` command to npm scripts in `package.json`. These will let us run things locally and give a command for Netlify to build our app and functions when we are ready to deploy.
281 |
282 | We are going to be using the `npm-run-all` npm module to run our frontend and backend in parallel in the same terminal window.
283 |
284 | So install it!
285 |
286 | ```
287 | npm install npm-run-all --save-dev
288 | ```
289 |
290 | **About `npm start`**
291 |
292 | The `start:app` command will run `react-scripts start` to run our react app
293 |
294 | The `start:server` command will run `netlify-lambda serve functions -c ./webpack.config.js` to run our function code locally. The `-c webpack-config` flag lets us set a custom webpack config to [fix a module issue](https://medium.com/@danbruder/typeerror-require-is-not-a-function-webpack-faunadb-6e785858d23b) with FaunaDB module.
295 |
296 | Running `npm start` in our terminal will run `npm-run-all --parallel start:app start:server` to fire them both up at once.
297 |
298 | **About `npm build`**
299 |
300 | The `build:app` command will run `react-scripts build` to run our React app.
301 |
302 | The `build:server` command will run `netlify-lambda build functions -c ./webpack.config.js` to run our function code locally.
303 |
304 | Running `npm run build` in our terminal will run `npm-run-all --parallel build:**` to fire them both up at once.
305 |
306 |
307 | **Your `package.json` should look like**
308 |
309 | ```json
310 | {
311 | "name": "netlify-fauna",
312 | "scripts": {
313 | "👇 ABOUT-bootstrap-command": "💡 scaffold and setup FaunaDB #",
314 | "bootstrap": "node ./scripts/bootstrap-fauna-database.js",
315 | "👇 ABOUT-start-command": "💡 start the app and server #",
316 | "start": "npm-run-all --parallel start:app start:server",
317 | "start:app": "react-scripts start",
318 | "start:server": "netlify-lambda serve functions -c ./webpack.config.js",
319 | "👇 ABOUT-prebuild-command": "💡 before 'build' runs, run the 'bootstrap' command #",
320 | "prebuild": "echo 'setup faunaDB' && npm run bootstrap",
321 | "👇 ABOUT-build-command": "💡 build the react app and the serverless functions #",
322 | "build": "npm-run-all --parallel build:**",
323 | "build:app": "react-scripts build",
324 | "build:functions": "netlify-lambda build functions -c ./webpack.config.js",
325 | },
326 | "dependencies": {
327 | "faunadb": "^0.2.2",
328 | "react": "^16.4.0",
329 | "react-dom": "^16.4.0",
330 | "react-scripts": "1.1.4"
331 | },
332 | "devDependencies": {
333 | "netlify-lambda": "^0.4.0",
334 | "npm-run-all": "^4.1.3"
335 | },
336 | "proxy": {
337 | "/.netlify/functions": {
338 | "target": "http://localhost:9000",
339 | "pathRewrite": {
340 | "^/\\.netlify/functions": ""
341 | }
342 | }
343 | }
344 | }
345 |
346 | ```
347 |
348 | 4. **Install FaunaDB and write the create function**
349 |
350 | We are going to be using the `faunadb` npm module to call into our todos index in FaunaDB.
351 |
352 | So install it in the project.
353 |
354 | ```bash
355 | npm i faunadb --save
356 | ```
357 |
358 | Then create a new function file in `/functions` called `todos-create.js`
359 |
360 | ```js
361 | /* code from functions/todos-create.js */
362 | import faunadb from 'faunadb' /* Import faunaDB sdk */
363 |
364 | /* configure faunaDB Client with our secret */
365 | const q = faunadb.query
366 | const client = new faunadb.Client({
367 | secret: process.env.FAUNADB_SECRET
368 | })
369 |
370 | /* export our lambda function as named "handler" export */
371 | exports.handler = (event, context, callback) => {
372 | /* parse the string body into a useable JS object */
373 | const data = JSON.parse(event.body)
374 | console.log("Function `todo-create` invoked", data)
375 | const todoItem = {
376 | data: data
377 | }
378 | /* construct the fauna query */
379 | return client.query(q.Create(q.Ref("classes/todos"), todoItem))
380 | .then((response) => {
381 | console.log("success", response)
382 | /* Success! return the response with statusCode 200 */
383 | return callback(null, {
384 | statusCode: 200,
385 | body: JSON.stringify(response)
386 | })
387 | }).catch((error) => {
388 | console.log("error", error)
389 | /* Error! return the error with statusCode 400 */
390 | return callback(null, {
391 | statusCode: 400,
392 | body: JSON.stringify(error)
393 | })
394 | })
395 | }
396 | ```
397 |
398 | ### 4. Connect the function to the frontend app
399 |
400 | Inside of the React app, we can now wire up the `/.netlify/functions/todos-create` endpoint to an AJAX request.
401 |
402 | ```js
403 | // Function using fetch to POST to our API endpoint
404 | function createTodo(data) {
405 | return fetch('/.netlify/functions/todos-create', {
406 | body: JSON.stringify(data),
407 | method: 'POST'
408 | }).then(response => {
409 | return response.json()
410 | })
411 | }
412 |
413 | // Todo data
414 | const myTodo = {
415 | title: 'My todo title',
416 | completed: false,
417 | }
418 |
419 | // create it!
420 | createTodo(myTodo).then((response) => {
421 | console.log('API response', response)
422 | // set app state
423 | }).catch((error) => {
424 | console.log('API error', error)
425 | })
426 | ```
427 |
428 | Requests to `/.netlify/function/[Function-File-Name]` will work seamlessly on localhost and on the live site because we are using the local proxy with webpack.
429 |
430 | We will be skipping over the rest of the frontend parts of the app because you can use whatever framework you'd like to build your application.
431 |
432 | All the demo React frontend code is [available here.](https://github.com/netlify/netlify-faunadb-example/tree/17a9ba47a8b1b2408b68e793fba4c5fd17bf85da/src)
433 |
434 | ### 5. Finishing the backend Functions
435 |
436 | So far we have created our `todo-create` function and we've seen how we make requests to our live function endpoints. It's now time to add the rest of our CRUD functions to manage our todos.
437 |
438 | 1. **Read Todos by ID**
439 |
440 | Then create a new function file in `/functions` called `todos-read.js`
441 |
442 | ```js
443 | /* code from functions/todos-read.js */
444 | import faunadb from 'faunadb'
445 | import getId from './utils/getId'
446 |
447 | const q = faunadb.query
448 | const client = new faunadb.Client({
449 | secret: process.env.FAUNADB_SECRET
450 | })
451 |
452 | exports.handler = (event, context, callback) => {
453 | const id = getId(event.path)
454 | console.log(`Function 'todo-read' invoked. Read id: ${id}`)
455 | return client.query(q.Get(q.Ref(`classes/todos/${id}`)))
456 | .then((response) => {
457 | console.log("success", response)
458 | return callback(null, {
459 | statusCode: 200,
460 | body: JSON.stringify(response)
461 | })
462 | }).catch((error) => {
463 | console.log("error", error)
464 | return callback(null, {
465 | statusCode: 400,
466 | body: JSON.stringify(error)
467 | })
468 | })
469 | }
470 | ```
471 |
472 | 2. **Read All Todos**
473 |
474 | Then create a new function file in `/functions` called `todos-read-all.js`
475 |
476 | ```js
477 | /* code from functions/todos-read-all.js */
478 | import faunadb from 'faunadb'
479 |
480 | const q = faunadb.query
481 | const client = new faunadb.Client({
482 | secret: process.env.FAUNADB_SECRET
483 | })
484 |
485 | exports.handler = (event, context, callback) => {
486 | console.log("Function `todo-read-all` invoked")
487 | return client.query(q.Paginate(q.Match(q.Ref("indexes/all_todos"))))
488 | .then((response) => {
489 | const todoRefs = response.data
490 | console.log("Todo refs", todoRefs)
491 | console.log(`${todoRefs.length} todos found`)
492 | // create new query out of todo refs. http://bit.ly/2LG3MLg
493 | const getAllTodoDataQuery = todoRefs.map((ref) => {
494 | return q.Get(ref)
495 | })
496 | // then query the refs
497 | return client.query(getAllTodoDataQuery).then((ret) => {
498 | return callback(null, {
499 | statusCode: 200,
500 | body: JSON.stringify(ret)
501 | })
502 | })
503 | }).catch((error) => {
504 | console.log("error", error)
505 | return callback(null, {
506 | statusCode: 400,
507 | body: JSON.stringify(error)
508 | })
509 | })
510 | }
511 | ```
512 |
513 | 3. **Update todo by ID**
514 |
515 | Then create a new function file in `/functions` called `todos-update.js`
516 |
517 | ```js
518 | /* code from functions/todos-update.js */
519 | import faunadb from 'faunadb'
520 | import getId from './utils/getId'
521 |
522 | const q = faunadb.query
523 | const client = new faunadb.Client({
524 | secret: process.env.FAUNADB_SECRET
525 | })
526 |
527 | exports.handler = (event, context, callback) => {
528 | const data = JSON.parse(event.body)
529 | const id = getId(event.path)
530 | console.log(`Function 'todo-update' invoked. update id: ${id}`)
531 | return client.query(q.Update(q.Ref(`classes/todos/${id}`), {data}))
532 | .then((response) => {
533 | console.log("success", response)
534 | return callback(null, {
535 | statusCode: 200,
536 | body: JSON.stringify(response)
537 | })
538 | }).catch((error) => {
539 | console.log("error", error)
540 | return callback(null, {
541 | statusCode: 400,
542 | body: JSON.stringify(error)
543 | })
544 | })
545 | }
546 | ```
547 |
548 |
549 | 4. **Delete by ID**
550 |
551 | Then create a new function file in `/functions` called `todos-delete.js`
552 |
553 | ```js
554 | /* code from functions/todos-delete.js */
555 | import faunadb from 'faunadb'
556 | import getId from './utils/getId'
557 |
558 | const q = faunadb.query
559 | const client = new faunadb.Client({
560 | secret: process.env.FAUNADB_SECRET
561 | })
562 |
563 | exports.handler = (event, context, callback) => {
564 | const id = getId(event.path)
565 | console.log(`Function 'todo-delete' invoked. delete id: ${id}`)
566 | return client.query(q.Delete(q.Ref(`classes/todos/${id}`)))
567 | .then((response) => {
568 | console.log("success", response)
569 | return callback(null, {
570 | statusCode: 200,
571 | body: JSON.stringify(response)
572 | })
573 | }).catch((error) => {
574 | console.log("error", error)
575 | return callback(null, {
576 | statusCode: 400,
577 | body: JSON.stringify(error)
578 | })
579 | })
580 | }
581 | ```
582 |
583 |
584 | 4. **Delete batch todos**
585 |
586 | Then create a new function file in `/functions` called `todos-delete-batch.js`
587 |
588 | ```js
589 | /* code from functions/todos-delete-batch.js */
590 | import faunadb from 'faunadb'
591 | import getId from './utils/getId'
592 |
593 | const q = faunadb.query
594 | const client = new faunadb.Client({
595 | secret: process.env.FAUNADB_SECRET
596 | })
597 |
598 | exports.handler = (event, context, callback) => {
599 | const data = JSON.parse(event.body)
600 | console.log('data', data)
601 | console.log("Function `todo-delete-batch` invoked", data.ids)
602 | // construct batch query from IDs
603 | const deleteAllCompletedTodoQuery = data.ids.map((id) => {
604 | return q.Delete(q.Ref(`classes/todos/${id}`))
605 | })
606 | // Hit fauna with the query to delete the completed items
607 | return client.query(deleteAllCompletedTodoQuery)
608 | .then((response) => {
609 | console.log("success", response)
610 | return callback(null, {
611 | statusCode: 200,
612 | body: JSON.stringify(response)
613 | })
614 | }).catch((error) => {
615 | console.log("error", error)
616 | return callback(null, {
617 | statusCode: 400,
618 | body: JSON.stringify(error)
619 | })
620 | })
621 | }
622 | ```
623 |
624 | After we deploy all these functions, we will be able to call them from our frontend code with these fetch calls:
625 |
626 | ```js
627 | /* Frontend code from src/utils/api.js */
628 | /* Api methods to call /functions */
629 |
630 | const create = (data) => {
631 | return fetch('/.netlify/functions/todos-create', {
632 | body: JSON.stringify(data),
633 | method: 'POST'
634 | }).then(response => {
635 | return response.json()
636 | })
637 | }
638 |
639 | const readAll = () => {
640 | return fetch('/.netlify/functions/todos-read-all').then((response) => {
641 | return response.json()
642 | })
643 | }
644 |
645 | const update = (todoId, data) => {
646 | return fetch(`/.netlify/functions/todos-update/${todoId}`, {
647 | body: JSON.stringify(data),
648 | method: 'POST'
649 | }).then(response => {
650 | return response.json()
651 | })
652 | }
653 |
654 | const deleteTodo = (todoId) => {
655 | return fetch(`/.netlify/functions/todos-delete/${todoId}`, {
656 | method: 'POST',
657 | }).then(response => {
658 | return response.json()
659 | })
660 | }
661 |
662 | const batchDeleteTodo = (todoIds) => {
663 | return fetch(`/.netlify/functions/todos-delete-batch`, {
664 | body: JSON.stringify({
665 | ids: todoIds
666 | }),
667 | method: 'POST'
668 | }).then(response => {
669 | return response.json()
670 | })
671 | }
672 |
673 | export default {
674 | create: create,
675 | readAll: readAll,
676 | update: update,
677 | delete: deleteTodo,
678 | batchDelete: batchDeleteTodo
679 | }
680 | ```
681 |
682 | ### Wrapping Up
683 |
684 | That's it. You now have your own CRUD API using Netlify Functions and FaunaDB.
685 |
686 | As you can see, functions can be extremely powerful when combined with a cloud database!
687 |
688 | The sky is the limit on what you can build with the JAMstack and we'd love to hear about what you make. Give us a shout about it on [Twitter](https://twitter.com/netlify)
689 |
690 | **Next Steps**
691 |
692 | This example can be improved with users/authentication. Next steps to build out the app would be:
693 |
694 | - Add in the concept of users for everyone to have their own todo list
695 | - Wire up authentication using the JSON web token-based [Netlify Identity](https://identity.netlify.com/)
696 | - Add in due dates to todos and wire up Functions to notify users via email/SMS
697 | - File for IPO?
698 |
--------------------------------------------------------------------------------
/functions/todos-create.js:
--------------------------------------------------------------------------------
1 | /* Import faunaDB sdk */
2 | const faunadb = require('faunadb')
3 | const q = faunadb.query
4 |
5 | /* export our lambda function as named "handler" export */
6 | exports.handler = async (event, context) => {
7 | /* configure faunaDB Client with our secret */
8 | const client = new faunadb.Client({
9 | secret: process.env.FAUNADB_SERVER_SECRET
10 | })
11 | /* parse the string body into a useable JS object */
12 | const data = JSON.parse(event.body)
13 | console.log('Function `todo-create` invoked', data)
14 | const todoItem = {
15 | data: data
16 | }
17 | /* construct the fauna query */
18 | return client.query(q.Create(q.Ref('classes/todos'), todoItem))
19 | .then((response) => {
20 | console.log('success', response)
21 | /* Success! return the response with statusCode 200 */
22 | return {
23 | statusCode: 200,
24 | body: JSON.stringify(response)
25 | }
26 | }).catch((error) => {
27 | console.log('error', error)
28 | /* Error! return the error with statusCode 400 */
29 | return {
30 | statusCode: 400,
31 | body: JSON.stringify(error)
32 | }
33 | })
34 | }
35 |
--------------------------------------------------------------------------------
/functions/todos-delete-batch.js:
--------------------------------------------------------------------------------
1 | /* Import faunaDB sdk */
2 | const faunadb = require('faunadb')
3 | const q = faunadb.query
4 |
5 | exports.handler = async (event, context) => {
6 | /* configure faunaDB Client with our secret */
7 | const client = new faunadb.Client({
8 | secret: process.env.FAUNADB_SERVER_SECRET
9 | })
10 | const data = JSON.parse(event.body)
11 | console.log('data', data)
12 | console.log('Function `todo-delete-batch` invoked', data.ids)
13 | // construct batch query from IDs
14 | const deleteAllCompletedTodoQuery = data.ids.map((id) => {
15 | return q.Delete(q.Ref(`classes/todos/${id}`))
16 | })
17 | // Hit fauna with the query to delete the completed items
18 | return client.query(deleteAllCompletedTodoQuery)
19 | .then((response) => {
20 | console.log('success', response)
21 | return {
22 | statusCode: 200,
23 | body: JSON.stringify(response)
24 | }
25 | }).catch((error) => {
26 | console.log('error', error)
27 | return {
28 | statusCode: 400,
29 | body: JSON.stringify(error)
30 | }
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/functions/todos-delete.js:
--------------------------------------------------------------------------------
1 | /* Import faunaDB sdk */
2 | const faunadb = require('faunadb')
3 | const getId = require('./utils/getId')
4 | const q = faunadb.query
5 |
6 |
7 | exports.handler = async (event, context) => {
8 | /* configure faunaDB Client with our secret */
9 | const client = new faunadb.Client({
10 | secret: process.env.FAUNADB_SERVER_SECRET
11 | })
12 | const id = getId(event.path)
13 | console.log(`Function 'todo-delete' invoked. delete id: ${id}`)
14 | return client.query(q.Delete(q.Ref(`classes/todos/${id}`)))
15 | .then((response) => {
16 | console.log('success', response)
17 | return {
18 | statusCode: 200,
19 | body: JSON.stringify(response)
20 | }
21 | }).catch((error) => {
22 | console.log('error', error)
23 | return {
24 | statusCode: 400,
25 | body: JSON.stringify(error)
26 | }
27 | })
28 | }
29 |
--------------------------------------------------------------------------------
/functions/todos-read-all.js:
--------------------------------------------------------------------------------
1 | /* Import faunaDB sdk */
2 | const faunadb = require('faunadb')
3 | const q = faunadb.query
4 |
5 |
6 | exports.handler = (event, context) => {
7 | console.log('Function `todo-read-all` invoked')
8 | /* configure faunaDB Client with our secret */
9 | const client = new faunadb.Client({
10 | secret: process.env.FAUNADB_SERVER_SECRET
11 | })
12 | return client.query(q.Paginate(q.Match(q.Ref('indexes/all_todos'))))
13 | .then((response) => {
14 | const todoRefs = response.data
15 | console.log('Todo refs', todoRefs)
16 | console.log(`${todoRefs.length} todos found`)
17 | // create new query out of todo refs. http://bit.ly/2LG3MLg
18 | const getAllTodoDataQuery = todoRefs.map((ref) => {
19 | return q.Get(ref)
20 | })
21 | // then query the refs
22 | return client.query(getAllTodoDataQuery).then((ret) => {
23 | return {
24 | statusCode: 200,
25 | body: JSON.stringify(ret)
26 | }
27 | })
28 | }).catch((error) => {
29 | console.log('error', error)
30 | return {
31 | statusCode: 400,
32 | body: JSON.stringify(error)
33 | }
34 | })
35 | }
36 |
--------------------------------------------------------------------------------
/functions/todos-read.js:
--------------------------------------------------------------------------------
1 | /* Import faunaDB sdk */
2 | const faunadb = require('faunadb')
3 | const getId = require('./utils/getId')
4 | const q = faunadb.query
5 |
6 | exports.handler = (event, context) => {
7 | /* configure faunaDB Client with our secret */
8 | const client = new faunadb.Client({
9 | secret: process.env.FAUNADB_SERVER_SECRET
10 | })
11 | const id = getId(event.path)
12 | console.log(`Function 'todo-read' invoked. Read id: ${id}`)
13 | return client.query(q.Get(q.Ref(`classes/todos/${id}`)))
14 | .then((response) => {
15 | console.log('success', response)
16 | return {
17 | statusCode: 200,
18 | body: JSON.stringify(response)
19 | }
20 | }).catch((error) => {
21 | console.log('error', error)
22 | return {
23 | statusCode: 400,
24 | body: JSON.stringify(error)
25 | }
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/functions/todos-update.js:
--------------------------------------------------------------------------------
1 | const faunadb = require('faunadb')
2 | const getId = require('./utils/getId')
3 | const q = faunadb.query
4 |
5 | exports.handler = (event, context) => {
6 | /* configure faunaDB Client with our secret */
7 | const client = new faunadb.Client({
8 | secret: process.env.FAUNADB_SERVER_SECRET
9 | })
10 | const data = JSON.parse(event.body)
11 | const id = getId(event.path)
12 | console.log(`Function 'todo-update' invoked. update id: ${id}`)
13 | return client.query(q.Update(q.Ref(`classes/todos/${id}`), {data}))
14 | .then((response) => {
15 | console.log('success', response)
16 | return {
17 | statusCode: 200,
18 | body: JSON.stringify(response)
19 | }
20 | }).catch((error) => {
21 | console.log('error', error)
22 | return {
23 | statusCode: 400,
24 | body: JSON.stringify(error)
25 | }
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/functions/utils/getId.js:
--------------------------------------------------------------------------------
1 |
2 | module.exports = function getId(urlPath) {
3 | return urlPath.match(/([^\/]*)\/*$/)[0]
4 | }
5 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | functions = "functions"
3 | # This will be run the site build
4 | command = "npm run build"
5 | # This is the directory is publishing to netlify's CDN
6 | publish = "build"
7 |
8 | [dev]
9 | # Local dev command. A.k.a npm start
10 | command = "react-scripts start"
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "netlify-fauna",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@analytics/google-analytics": "^0.3.1",
7 | "analytics": "^0.3.5",
8 | "faunadb": "^2.13.1",
9 | "markdown-magic": "^1.0.0",
10 | "react": "^16.13.1",
11 | "react-dom": "^16.13.1",
12 | "react-scripts": "3.4.1"
13 | },
14 | "scripts": {
15 | "bootstrap": "netlify dev:exec node ./scripts/bootstrap-fauna-database.js",
16 | "docs": "md-magic --path '**/*.md' --ignore 'node_modules'",
17 | "start": "netlify dev",
18 | "prebuild": "echo 'setup faunaDB' && npm run bootstrap",
19 | "build": "react-scripts build"
20 | },
21 | "devDependencies": {
22 | "netlify-cli": "^2.19.0"
23 | },
24 | "browserslist": [
25 | ">0.2%",
26 | "not dead",
27 | "not ie <= 11",
28 | "not op_mini all"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/netlify/netlify-faunadb-example/159b95d05edb95dc599b0072a366cfb1d1f43f31/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Fauna + Netlify Functions
12 |
13 |
14 |
17 |
18 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "FaunaDB Example",
3 | "name": "Fauna + Netlify Functions",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/scripts/bootstrap-fauna-database.js:
--------------------------------------------------------------------------------
1 | /* bootstrap database in your FaunaDB account */
2 | const faunadb = require('faunadb')
3 | const chalk = require('chalk')
4 | const insideNetlify = insideNetlifyBuildContext()
5 | const q = faunadb.query
6 |
7 | console.log(chalk.cyan('Creating your FaunaDB Database...\n'))
8 |
9 | // 1. Check for required enviroment variables
10 | if (!process.env.FAUNADB_SERVER_SECRET) {
11 | console.log(chalk.yellow('Required FAUNADB_SERVER_SECRET enviroment variable not found.'))
12 | console.log(`Make sure you have created your Fauna databse with "netlify addons:create fauna"`)
13 | console.log(`Then run "npm run bootstrap" to setup your database schema`)
14 | if (insideNetlify) {
15 | process.exit(1)
16 | }
17 | }
18 |
19 | // Has var. Do the thing
20 | if (process.env.FAUNADB_SERVER_SECRET) {
21 | createFaunaDB(process.env.FAUNADB_SERVER_SECRET).then(() => {
22 | console.log('Fauna Database schema has been created')
23 | console.log('Claim your fauna database with "netlify addons:auth fauna"')
24 | })
25 | }
26 |
27 | /* idempotent operation */
28 | function createFaunaDB(key) {
29 | console.log('Create the fauna database schema!')
30 | const client = new faunadb.Client({
31 | secret: key
32 | })
33 |
34 | /* Based on your requirements, change the schema here */
35 | return client.query(q.Create(q.Ref('classes'), { name: 'todos' }))
36 | .then(() => {
37 | return client.query(
38 | q.Create(q.Ref('indexes'), {
39 | name: 'all_todos',
40 | source: q.Ref('classes/todos')
41 | }))
42 | }).catch((e) => {
43 | // Database already exists
44 | if (e.requestResult.statusCode === 400 && e.message === 'instance not unique') {
45 | console.log('Fauna already setup! Good to go')
46 | console.log('Claim your fauna database with "netlify addons:auth fauna"')
47 | throw e
48 | }
49 | })
50 | }
51 |
52 | /* util methods */
53 |
54 | // Test if inside netlify build context
55 | function insideNetlifyBuildContext() {
56 | if (process.env.DEPLOY_PRIME_URL) {
57 | return true
58 | }
59 | return false
60 | }
61 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .todo-list {
2 | padding: 60px;
3 | padding-top: 10px;
4 | width: 600px;
5 | }
6 | .todo-settings-toggle {
7 | fill: #b7b9bd;
8 | width: 25px;
9 | margin-left: 10px;
10 | cursor: pointer;
11 | }
12 | .todo-create-wrapper {
13 | margin-bottom: 20px;
14 | display: flex;
15 | align-items: center;
16 | }
17 | .todo-actions {
18 | display: flex;
19 | align-items: center;
20 | }
21 | .todo-create-input {
22 | font-size: 14px;
23 | padding: 11px 15px;
24 | min-width: 300px;
25 | display: inline-block;
26 | box-shadow: 0px 0px 0px 2px rgba(120, 130, 152, 0.25);
27 | border: none;
28 | outline: none;
29 | transition: all 0.3s ease;
30 | }
31 | .todo-create-input:hover, .todo-create-input:active, .todo-create-input:focus {
32 | box-shadow: 0px 0px 0px 2px rgb(43, 190, 185);
33 | box-shadow: 0px 0px 0px 2px #00ad9f;
34 | }
35 |
36 | .todo-item {
37 | padding: 15px 0;
38 | display: flex;
39 | align-items: center;
40 | justify-content: space-between;
41 | min-height: 43px;
42 | }
43 | .todo-list-title {
44 | font-size: 17px;
45 | font-weight: 500;
46 | color: #5a5a5a;
47 | flex-grow: 1;
48 | position: relative;
49 | z-index: 2;
50 | margin-left: 45px;
51 | width: 470px;
52 | }
53 | .todo-list-title:hover span[contenteditable="false"]:before {
54 | content: 'click to edit';
55 | position: absolute;
56 | top: -6px;
57 | left: 11px;
58 | font-size: 11px;
59 | font-weight: 300;
60 | color: #adadad;
61 | letter-spacing: 1px;
62 | }
63 | .mobile-toggle {
64 | display: none;
65 | }
66 | .desktop-toggle {
67 | margin-left: 10px;
68 | margin-bottom: 3px;
69 | }
70 |
71 | @media (max-width: 768px) {
72 | .mobile-toggle {
73 | display: inline-flex;
74 | }
75 | .desktop-toggle {
76 | display: none;
77 | }
78 | .todo-list {
79 | padding: 15px;
80 | padding-top: 10px;
81 | width: auto;
82 | }
83 | .todo-list h2 {
84 | display: flex;
85 | justify-content: space-between;
86 | align-items: center;
87 | margin-bottom: 15px;
88 | max-width: 95%;
89 | }
90 | .todo-list-title {
91 | /* Disable Auto Zoom in Input “Text” tag - Safari on iPhone */
92 | font-size: 16px;
93 | max-width: 80%;
94 | margin-left: 40px;
95 | }
96 | .todo-create-wrapper {
97 | flex-direction: column;
98 | align-items: flex-start;
99 | margin-bottom: 15px;
100 | }
101 | .todo-create-input {
102 | appearance: none;
103 | border: 1px solid rgba(120, 130, 152, 0.25);
104 | /* Disable Auto Zoom in Input “Text” tag - Safari on iPhone */
105 | font-size: 16px;
106 | margin-bottom: 15px;
107 | min-width: 85%;
108 | }
109 | .todo-item button {
110 | padding: 4px 12px;
111 | font-size: 14px;
112 | margin-bottom: 0px;
113 | min-width: 63px;
114 | display: flex;
115 | align-items: center;
116 | justify-content: center;
117 | }
118 | .todo-list-title:hover span[contenteditable="false"]:before {
119 | content: ''
120 | }
121 | .todo-list-title:hover span[contenteditable="true"]:before {
122 | content: 'click to edit';
123 | position: absolute;
124 | top: -20px;
125 | left: 9px;
126 | font-size: 11px;
127 | font-weight: 300;
128 | color: #adadad;
129 | letter-spacing: 1px;
130 | }
131 | }
132 |
133 | /** todo css via https://codepen.io/shshaw/pen/WXMdwE 😻 */
134 | .todo {
135 | display: inline-block;
136 | position: relative;
137 | padding: 0;
138 | margin: 0;
139 | min-height: 40px;
140 | min-width: 40px;
141 | cursor: pointer;
142 | padding-right: 5px;
143 | }
144 | .todo__state {
145 | position: absolute;
146 | top: 0;
147 | left: 0;
148 | opacity: 0;
149 | }
150 |
151 | .todo__icon {
152 | position: absolute;
153 | top: 0;
154 | bottom: 0;
155 | left: 0;
156 | width: 280px;
157 | height: 100%;
158 | margin: auto;
159 | fill: none;
160 | stroke: #27FDC7;
161 | stroke-width: 2;
162 | stroke-linejoin: round;
163 | stroke-linecap: round;
164 | z-index: 1;
165 | }
166 |
167 | .todo__state:checked ~ .todo-list-title {
168 | text-decoration: line-through;
169 | }
170 |
171 | .todo__box {
172 | stroke-dasharray: 56.1053, 56.1053;
173 | stroke-dashoffset: 0;
174 | transition-delay: 0.16s;
175 | }
176 | .todo__check {
177 | stroke: #27FDC7;
178 | stroke-dasharray: 9.8995, 9.8995;
179 | stroke-dashoffset: 9.8995;
180 | transition-duration: 0.25s;
181 | }
182 |
183 | .todo__state:checked ~ .todo__icon .todo__box {
184 | stroke-dashoffset: 56.1053;
185 | transition-delay: 0s;
186 | stroke-dasharray: 56.1053, 56.1053;
187 | stroke-dashoffset: 0;
188 | stroke: red;
189 | }
190 |
191 | .todo__state:checked ~ .todo__icon .todo__check {
192 | stroke-dashoffset: 0;
193 | transition-delay: 0s;
194 | }
195 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import ContentEditable from './components/ContentEditable'
3 | import AppHeader from './components/AppHeader'
4 | import SettingsMenu from './components/SettingsMenu'
5 | import SettingsIcon from './components/SettingsIcon'
6 | import analytics from './utils/analytics'
7 | import api from './utils/api'
8 | import sortByDate from './utils/sortByDate'
9 | import isLocalHost from './utils/isLocalHost'
10 | import './App.css'
11 |
12 | export default class App extends Component {
13 | state = {
14 | todos: [],
15 | showMenu: false
16 | }
17 | componentDidMount() {
18 |
19 | /* Track a page view */
20 | analytics.page()
21 |
22 | // Fetch all todos
23 | api.readAll().then((todos) => {
24 | if (todos.message === 'unauthorized') {
25 | if (isLocalHost()) {
26 | alert('FaunaDB key is not unauthorized. Make sure you set it in terminal session where you ran `npm start`. Visit http://bit.ly/set-fauna-key for more info')
27 | } else {
28 | alert('FaunaDB key is not unauthorized. Verify the key `FAUNADB_SERVER_SECRET` set in Netlify enviroment variables is correct')
29 | }
30 | return false
31 | }
32 |
33 | console.log('all todos', todos)
34 | this.setState({
35 | todos: todos
36 | })
37 | })
38 | }
39 | saveTodo = (e) => {
40 | e.preventDefault()
41 | const { todos } = this.state
42 | const todoValue = this.inputElement.value
43 |
44 | if (!todoValue) {
45 | alert('Please add Todo title')
46 | this.inputElement.focus()
47 | return false
48 | }
49 |
50 | // reset input to empty
51 | this.inputElement.value = ''
52 |
53 | const todoInfo = {
54 | title: todoValue,
55 | completed: false,
56 | }
57 | // Optimistically add todo to UI
58 | const newTodoArray = [{
59 | data: todoInfo,
60 | ts: new Date().getTime() * 10000
61 | }]
62 |
63 | const optimisticTodoState = newTodoArray.concat(todos)
64 |
65 | this.setState({
66 | todos: optimisticTodoState
67 | })
68 | // Make API request to create new todo
69 | api.create(todoInfo).then((response) => {
70 | console.log(response)
71 | /* Track a custom event */
72 | analytics.track('todoCreated', {
73 | category: 'todos',
74 | label: todoValue,
75 | })
76 | // remove temporaryValue from state and persist API response
77 | const persistedState = removeOptimisticTodo(todos).concat(response)
78 | // Set persisted value to state
79 | this.setState({
80 | todos: persistedState
81 | })
82 | }).catch((e) => {
83 | console.log('An API error occurred', e)
84 | const revertedState = removeOptimisticTodo(todos)
85 | // Reset to original state
86 | this.setState({
87 | todos: revertedState
88 | })
89 | })
90 | }
91 | deleteTodo = (e) => {
92 | const { todos } = this.state
93 | const todoId = e.target.dataset.id
94 |
95 | // Optimistically remove todo from UI
96 | const filteredTodos = todos.reduce((acc, current) => {
97 | const currentId = getTodoId(current)
98 | if (currentId === todoId) {
99 | // save item being removed for rollback
100 | acc.rollbackTodo = current
101 | return acc
102 | }
103 | // filter deleted todo out of the todos list
104 | acc.optimisticState = acc.optimisticState.concat(current)
105 | return acc
106 | }, {
107 | rollbackTodo: {},
108 | optimisticState: []
109 | })
110 |
111 | this.setState({
112 | todos: filteredTodos.optimisticState
113 | })
114 |
115 | // Make API request to delete todo
116 | api.delete(todoId).then(() => {
117 | console.log(`deleted todo id ${todoId}`)
118 | analytics.track('todoDeleted', {
119 | category: 'todos',
120 | })
121 | }).catch((e) => {
122 | console.log(`There was an error removing ${todoId}`, e)
123 | // Add item removed back to list
124 | this.setState({
125 | todos: filteredTodos.optimisticState.concat(filteredTodos.rollbackTodo)
126 | })
127 | })
128 | }
129 | handleTodoCheckbox = (event) => {
130 | const { todos } = this.state
131 | const { target } = event
132 | const todoCompleted = target.checked
133 | const todoId = target.dataset.id
134 |
135 | const updatedTodos = todos.map((todo, i) => {
136 | const { data } = todo
137 | const id = getTodoId(todo)
138 | if (id === todoId && data.completed !== todoCompleted) {
139 | data.completed = todoCompleted
140 | }
141 | return todo
142 | })
143 |
144 | this.setState({
145 | todos: updatedTodos
146 | }, () => {
147 | api.update(todoId, {
148 | completed: todoCompleted
149 | }).then(() => {
150 | console.log(`update todo ${todoId}`, todoCompleted)
151 | const eventName = (todoCompleted) ? 'todoCompleted' : 'todoUnfinished'
152 | analytics.track(eventName, {
153 | category: 'todos'
154 | })
155 | }).catch((e) => {
156 | console.log('An API error occurred', e)
157 | })
158 | })
159 | }
160 | updateTodoTitle = (event, currentValue) => {
161 | let isDifferent = false
162 | const todoId = event.target.dataset.key
163 |
164 | const updatedTodos = this.state.todos.map((todo, i) => {
165 | const id = getTodoId(todo)
166 | if (id === todoId && todo.data.title !== currentValue) {
167 | todo.data.title = currentValue
168 | isDifferent = true
169 | }
170 | return todo
171 | })
172 |
173 | // only set state if input different
174 | if (isDifferent) {
175 | this.setState({
176 | todos: updatedTodos
177 | }, () => {
178 | api.update(todoId, {
179 | title: currentValue
180 | }).then(() => {
181 | console.log(`update todo ${todoId}`, currentValue)
182 | analytics.track('todoUpdated', {
183 | category: 'todos',
184 | label: currentValue
185 | })
186 | }).catch((e) => {
187 | console.log('An API error occurred', e)
188 | })
189 | })
190 | }
191 | }
192 | clearCompleted = () => {
193 | const { todos } = this.state
194 |
195 | // Optimistically remove todos from UI
196 | const data = todos.reduce((acc, current) => {
197 | if (current.data.completed) {
198 | // save item being removed for rollback
199 | acc.completedTodoIds = acc.completedTodoIds.concat(getTodoId(current))
200 | return acc
201 | }
202 | // filter deleted todo out of the todos list
203 | acc.optimisticState = acc.optimisticState.concat(current)
204 | return acc
205 | }, {
206 | completedTodoIds: [],
207 | optimisticState: []
208 | })
209 |
210 | // only set state if completed todos exist
211 | if (!data.completedTodoIds.length) {
212 | alert('Please check off some todos to batch remove them')
213 | this.closeModal()
214 | return false
215 | }
216 |
217 | this.setState({
218 | todos: data.optimisticState
219 | }, () => {
220 | setTimeout(() => {
221 | this.closeModal()
222 | }, 600)
223 |
224 | api.batchDelete(data.completedTodoIds).then(() => {
225 | console.log(`Batch removal complete`, data.completedTodoIds)
226 | analytics.track('todosBatchDeleted', {
227 | category: 'todos',
228 | })
229 | }).catch((e) => {
230 | console.log('An API error occurred', e)
231 | })
232 | })
233 | }
234 | closeModal = (e) => {
235 | this.setState({
236 | showMenu: false
237 | })
238 | analytics.track('modalClosed', {
239 | category: 'modal'
240 | })
241 | }
242 | openModal = () => {
243 | this.setState({
244 | showMenu: true
245 | })
246 | analytics.track('modalOpened', {
247 | category: 'modal'
248 | })
249 | }
250 | renderTodos() {
251 | const { todos } = this.state
252 |
253 | if (!todos || !todos.length) {
254 | // Loading State here
255 | return null
256 | }
257 |
258 | const timeStampKey = 'ts'
259 | const orderBy = 'desc' // or `asc`
260 | const sortOrder = sortByDate(timeStampKey, orderBy)
261 | const todosByDate = todos.sort(sortOrder)
262 |
263 | return todosByDate.map((todo, i) => {
264 | const { data, ref } = todo
265 | const id = getTodoId(todo)
266 | // only show delete button after create API response returns
267 | let deleteButton
268 | if (ref) {
269 | deleteButton = (
270 |
273 | )
274 | }
275 | const boxIcon = (data.completed) ? '#todo__box__done' : '#todo__box'
276 | return (
277 |
278 |
300 | {deleteButton}
301 |
302 | )
303 | })
304 | }
305 | render() {
306 | return (
307 |
308 |
309 |
310 |
311 |
312 |
313 | Create todo
314 |
315 |
316 |
332 |
333 | {this.renderTodos()}
334 |
335 |
340 |
341 | )
342 | }
343 | }
344 |
345 | function removeOptimisticTodo(todos) {
346 | // return all 'real' todos
347 | return todos.filter((todo) => {
348 | return todo.ref
349 | })
350 | }
351 |
352 | function getTodoId(todo) {
353 | if (!todo.ref) {
354 | return null
355 | }
356 | return todo.ref['@ref'].id
357 | }
358 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import App from './App'
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div')
7 | ReactDOM.render(, div)
8 | ReactDOM.unmountComponentAtNode(div)
9 | })
10 |
--------------------------------------------------------------------------------
/src/assets/deploy-to-netlify.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/src/assets/github.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/components/AppHeader/AppHeader.css:
--------------------------------------------------------------------------------
1 | .app-logo {
2 | height: 95px;
3 | margin-right: 20px;
4 | }
5 | .app-title-wrapper {
6 | display: flex;
7 | align-items: center;
8 | justify-content: space-between;
9 | }
10 | .app-title-text {
11 | flex-grow: 1;
12 | }
13 | .app-header {
14 | background-color: #222;
15 | height: 105px;
16 | padding: 20px;
17 | color: white;
18 | padding-left: 60px;
19 | }
20 | .app-left-nav {
21 | display: flex;
22 | }
23 | .app-title {
24 | font-size: 32px;
25 | margin: 0px;
26 | margin-top: 10px;
27 | }
28 | .app-intro {
29 | font-size: large;
30 | }
31 | .deploy-button-wrapper {
32 | margin-right: 20px;
33 | display: flex;
34 | flex-direction: column;
35 | align-items: center;
36 | }
37 | .deploy-button {
38 | width: 200px;
39 | }
40 | .view-src {
41 | margin-top: 15px;
42 | text-align: center;
43 | }
44 | .view-src img {
45 | margin-right: 10px;
46 | }
47 | .view-src a {
48 | text-decoration: none;
49 | color: #fff;
50 | display: flex;
51 | align-items: center;
52 | justify-content: center;
53 | }
54 | .github-icon {
55 | width: 25px;
56 | fill: white;
57 | }
58 |
59 | @keyframes App-logo-spin {
60 | from { transform: rotate(0deg); }
61 | to { transform: rotate(360deg); }
62 | }
63 |
64 | /* Mobile view */
65 | @media (max-width: 768px) {
66 | .app-title-wrapper {
67 | flex-direction: column;
68 | align-items: flex-start;
69 | }
70 | .app-title {
71 | font-size: 23px;
72 | margin-top: 0px;
73 | }
74 | .app-intro {
75 | font-size: 14px;
76 | }
77 | .app-header {
78 | padding-left: 20px;
79 | height: auto;
80 | }
81 | .app-logo {
82 | height: 60px;
83 | margin-right: 20px;
84 | animation: none;
85 | }
86 | .deploy-button-wrapper {
87 | margin-left: 80px;
88 | margin-top: 5px;
89 | }
90 | .deploy-button {
91 | width: 150px;
92 | }
93 | .view-src {
94 | display: none;
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/AppHeader/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import deployButton from '../../assets/deploy-to-netlify.svg'
3 | import logo from '../../assets/logo.svg'
4 | import github from '../../assets/github.svg'
5 | import styles from './AppHeader.css' // eslint-disable-line
6 |
7 | const AppHeader = (props) => {
8 | return (
9 |
10 |
11 |
12 |
13 |

14 |
15 |
Netlify + Fauna DB
16 |
17 | Using FaunaDB & Netlify functions
18 |
19 |
20 |
21 |
22 |
39 |
40 |
41 | )
42 | }
43 |
44 | export default AppHeader
45 |
--------------------------------------------------------------------------------
/src/components/ContentEditable/ContentEditable.css:
--------------------------------------------------------------------------------
1 | .editable {
2 | cursor: text;
3 | display: block;
4 | padding: 10px;
5 | width: 95%;
6 | }
7 | .editable[contenteditable="true"] {
8 | outline: 3px solid #efefef;
9 | }
10 |
--------------------------------------------------------------------------------
/src/components/ContentEditable/Editable.js:
--------------------------------------------------------------------------------
1 | /* fork of https://github.com/lovasoa/react-contenteditable */
2 | import React from 'react'
3 |
4 | export default class Editable extends React.Component {
5 | shouldComponentUpdate(nextProps) {
6 | // We need not rerender if the change of props simply reflects the user's
7 | // edits. Rerendering in this case would make the cursor/caret jump.
8 | return (
9 | // Rerender if there is no element yet...
10 | !this.htmlEl
11 | // ...or if html really changed... (programmatically, not by user edit)
12 | || (nextProps.html !== this.htmlEl.innerHTML
13 | && nextProps.html !== this.props.html)
14 | // ...or if editing is enabled or disabled.
15 | || this.props.disabled !== nextProps.disabled
16 | )
17 | }
18 | componentDidUpdate() {
19 | if (this.htmlEl && this.props.html !== this.htmlEl.innerHTML) {
20 | // Perhaps React (whose VDOM gets outdated because we often prevent
21 | // rerendering) did not update the DOM. So we update it manually now.
22 | this.htmlEl.innerHTML = this.props.html
23 | }
24 | }
25 | preventEnter = (evt) => {
26 | if (evt.which === 13) {
27 | evt.preventDefault()
28 | if (!this.htmlEl) {
29 | return false
30 | }
31 | this.htmlEl.blur()
32 | return false
33 | }
34 | }
35 | emitChange = (evt) => {
36 | if (!this.htmlEl) {
37 | return false
38 | }
39 | const html = this.htmlEl.innerHTML
40 | if (this.props.onChange && html !== this.lastHtml) {
41 | evt.target.value = html
42 | this.props.onChange(evt, html)
43 | }
44 | this.lastHtml = html
45 | }
46 | render() {
47 | const { tagName, html, onChange, ...props } = this.props
48 |
49 | const domNodeType = tagName || 'div'
50 | const elementProps = {
51 | ...props,
52 | ref: (e) => this.htmlEl = e,
53 | onKeyDown: this.preventEnter,
54 | onInput: this.emitChange,
55 | onBlur: this.props.onBlur || this.emitChange,
56 | contentEditable: !this.props.disabled,
57 | }
58 |
59 | let children = this.props.children
60 | if (html) {
61 | elementProps.dangerouslySetInnerHTML = { __html: html }
62 | children = null
63 | }
64 | return React.createElement(domNodeType, elementProps, children)
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/ContentEditable/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Editable from './Editable'
3 | import './ContentEditable.css'
4 |
5 | export default class ContentEditable extends React.Component {
6 | constructor(props) {
7 | super(props)
8 | this.state = {
9 | disabled: true
10 | }
11 | this.hasFocused = false
12 | }
13 | handleClick = (e) => {
14 | e.preventDefault()
15 | const event = e || window.event
16 | // hacks to give the contenteditable block a better UX
17 | event.persist()
18 | if (!this.hasFocused) {
19 | const caretRange = getMouseEventCaretRange(event)
20 | window.setTimeout(() => {
21 | selectRange(caretRange)
22 | this.hasFocused = true
23 | }, 0)
24 | }
25 | // end hacks to give the contenteditable block a better UX
26 | this.setState({
27 | disabled: false
28 | })
29 | }
30 | handleClickOutside = (evt) => {
31 | const event = evt || window.event
32 | // presist blur event for react
33 | event.persist()
34 | const value = evt.target.value || evt.target.innerText
35 | this.setState({
36 | disabled: true
37 | }, () => {
38 | this.hasFocused = false // reset single click functionality
39 | if (this.props.onBlur) {
40 | this.props.onBlur(evt, value)
41 | }
42 | })
43 | }
44 | render() {
45 | const { onChange, children, html, editKey, tagName } = this.props
46 | const content = html || children
47 | return (
48 |
58 | )
59 | }
60 | }
61 |
62 | function getMouseEventCaretRange(event) {
63 | const x = event.clientX
64 | const y = event.clientY
65 | let range
66 |
67 | if (document.body.createTextRange) {
68 | // IE
69 | range = document.body.createTextRange()
70 | range.moveToPoint(x, y)
71 | } else if (typeof document.createRange !== 'undefined') {
72 | // Try Firefox rangeOffset + rangeParent properties
73 | if (typeof event.rangeParent !== 'undefined') {
74 | range = document.createRange()
75 | range.setStart(event.rangeParent, event.rangeOffset)
76 | range.collapse(true)
77 | } else if (document.caretPositionFromPoint) {
78 | // Try the standards-based way next
79 | const pos = document.caretPositionFromPoint(x, y)
80 | range = document.createRange()
81 | range.setStart(pos.offsetNode, pos.offset)
82 | range.collapse(true)
83 | } else if (document.caretRangeFromPoint) {
84 | // WebKit
85 | range = document.caretRangeFromPoint(x, y)
86 | }
87 | }
88 | return range
89 | }
90 |
91 | function selectRange(range) {
92 | if (range) {
93 | if (typeof range.select !== 'undefined') {
94 | range.select()
95 | } else if (typeof window.getSelection !== 'undefined') {
96 | const sel = window.getSelection()
97 | sel.removeAllRanges()
98 | sel.addRange(range)
99 | }
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/SettingsIcon/SettingIcon.css:
--------------------------------------------------------------------------------
1 | .setting-toggle-wrapper {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | }
6 | .settings-toggle {
7 | fill: #b7b9bd;
8 | width: 35px;
9 | height: 35px;
10 | cursor: pointer;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/SettingsIcon/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styles from './SettingIcon.css' // eslint-disable-line
3 |
4 | const SettingIcon = (props) => {
5 | const className = props.className || ''
6 | return (
7 |
8 |
11 |
12 | )
13 | }
14 |
15 | export default SettingIcon
16 |
--------------------------------------------------------------------------------
/src/components/SettingsMenu/SettingsMenu.css:
--------------------------------------------------------------------------------
1 |
2 | .settings-wrapper {
3 | position: fixed;
4 | left: 0;
5 | top: 0;
6 | background: rgba(95, 95, 95, 0.50);
7 | font-size: 13px;
8 | width: 100%;
9 | height: 100%;
10 | z-index: 9;
11 | display: flex;
12 | align-items: center;
13 | justify-content: center;
14 | }
15 | .settings-content {
16 | margin-top: 3em;
17 | margin-bottom: 3em;
18 | padding: 1.5em 3em;
19 | padding-bottom: 3em;
20 | background: #fff;
21 | color: rgba(14,30,37,0.54);
22 | border-radius: 8px;
23 | box-shadow: 0 1px 6px 0 rgba(14,30,37,0.12);
24 | position: relative;
25 | }
26 | .settings-close {
27 | position: absolute;
28 | right: 20px;
29 | top: 15px;
30 | font-size: 16px;
31 | cursor: pointer;
32 | }
33 | .settings-content h2 {
34 | color: #000;
35 | }
36 | .settings-section {
37 | margin-top: 20px;
38 | }
39 | .settings-header {
40 | font-size: 16px;
41 | font-weight: 600;
42 | }
43 | .settings-options-wrapper {
44 | display: flex;
45 | align-items: center;
46 | flex-wrap: wrap;
47 | }
48 | .settings-option {
49 | padding: 3px 8px;
50 | margin: 5px;
51 | border: 1px solid;
52 | font-size: 12px;
53 | cursor: pointer;
54 | &:hover, &.activeClass {
55 | color: #fff;
56 | }
57 | }
58 |
59 | @media (max-width: 768px) {
60 | .settings-close {
61 | top: 15px;
62 | right: 20px;
63 | font-size: 18px;
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/SettingsMenu/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react'
2 | import styles from './SettingsMenu.css' // eslint-disable-line
3 |
4 | export default class Menu extends Component {
5 | componentDidMount() {
6 | // attach event listeners
7 | document.body.addEventListener('keydown', this.handleEscKey)
8 | }
9 | componentWillUnmount() {
10 | // remove event listeners
11 | document.body.removeEventListener('keydown', this.handleEscKey)
12 | }
13 | handleEscKey = (e) => {
14 | if (this.props.showMenu && e.which === 27) {
15 | this.props.handleModalClose()
16 | }
17 | }
18 | handleDelete = (e) => {
19 | e.preventDefault()
20 | const deleteConfirm = window.confirm("Are you sure you want to clear all completed todos?");
21 | if (deleteConfirm) {
22 | console.log('delete')
23 | this.props.handleClearCompleted()
24 | }
25 | }
26 | render() {
27 | const { showMenu } = this.props
28 | const showOrHide = (showMenu) ? 'flex' : 'none'
29 | return (
30 |
31 |
32 |
33 | ❌
34 |
35 |
Settings
36 |
37 |
40 |
41 |
42 |
Sort Todos:
43 |
44 |
48 | Oldest First ▼
49 |
50 |
54 | Most Recent First ▲
55 |
56 |
57 |
58 |
59 |
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: Roboto,-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
5 | -webkit-font-smoothing: antialiased;
6 | -moz-osx-font-smoothing: grayscale;
7 | }
8 |
9 | button {
10 | padding: 7px 15px;
11 | font-family: inherit;
12 | font-weight: 500;
13 | font-size: 16px;
14 | line-height: 24px;
15 | text-align: center;
16 | border: 1px solid #e9ebeb;
17 | border-bottom: 1px solid #e1e3e3;
18 | border-radius: 4px;
19 | background-color: #fff;
20 | color: rgba(14,30,37,.87);
21 | box-shadow: 0 2px 4px 0 rgba(14,30,37,.12);
22 | transition: all .2s ease;
23 | transition-property: background-color,color,border,box-shadow;
24 | outline: 0;
25 | cursor: pointer;
26 | margin-bottom: 3px;
27 | }
28 |
29 | button:focus, button:hover {
30 | background-color: #f5f5f5;
31 | color: rgba(14,30,37,.87);
32 | box-shadow: 0 8px 12px 0 rgba(233,235,235,.16), 0 2px 8px 0 rgba(0,0,0,.08);
33 | text-decoration: none;
34 | }
35 |
36 |
37 | .btn-danger {
38 | background-color: #fb6d77;
39 | border-color: #fb6d77;
40 | border-bottom-color: #e6636b;
41 | color: #fff;
42 | }
43 | .btn-danger:focus, .btn-danger:hover {
44 | background-color: #fa3b49;
45 | border-color: #fa3b49;
46 | color: #fff;
47 | }
48 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom'
3 | import './index.css'
4 | import App from './App'
5 |
6 | ReactDOM.render(, document.getElementById('root'))
7 |
--------------------------------------------------------------------------------
/src/utils/analytics.js:
--------------------------------------------------------------------------------
1 | import Analytics from 'analytics'
2 | import googleAnalytics from '@analytics/google-analytics'
3 | const { REACT_APP_GOOGLE_ANALYTICS_ID } = process.env
4 |
5 | let plugins = []
6 | // If google analytics ID set attach plugin
7 | if (REACT_APP_GOOGLE_ANALYTICS_ID) {
8 | plugins = [ googleAnalytics({
9 | trackingId: REACT_APP_GOOGLE_ANALYTICS_ID
10 | })
11 | ]
12 | }
13 |
14 | export default Analytics({
15 | app: 'fauna-db-example',
16 | plugins: plugins
17 | })
18 |
--------------------------------------------------------------------------------
/src/utils/api.js:
--------------------------------------------------------------------------------
1 | /* Api methods to call /functions */
2 |
3 | const create = (data) => {
4 | return fetch('/.netlify/functions/todos-create', {
5 | body: JSON.stringify(data),
6 | method: 'POST'
7 | }).then(response => {
8 | return response.json()
9 | })
10 | }
11 |
12 | const readAll = () => {
13 | return fetch('/.netlify/functions/todos-read-all').then((response) => {
14 | return response.json()
15 | })
16 | }
17 |
18 | const update = (todoId, data) => {
19 | return fetch(`/.netlify/functions/todos-update/${todoId}`, {
20 | body: JSON.stringify(data),
21 | method: 'POST'
22 | }).then(response => {
23 | return response.json()
24 | })
25 | }
26 |
27 | const deleteTodo = (todoId) => {
28 | return fetch(`/.netlify/functions/todos-delete/${todoId}`, {
29 | method: 'POST',
30 | }).then(response => {
31 | return response.json()
32 | })
33 | }
34 |
35 | const batchDeleteTodo = (todoIds) => {
36 | return fetch(`/.netlify/functions/todos-delete-batch`, {
37 | body: JSON.stringify({
38 | ids: todoIds
39 | }),
40 | method: 'POST'
41 | }).then(response => {
42 | return response.json()
43 | })
44 | }
45 |
46 | export default {
47 | create: create,
48 | readAll: readAll,
49 | update: update,
50 | delete: deleteTodo,
51 | batchDelete: batchDeleteTodo
52 | }
53 |
--------------------------------------------------------------------------------
/src/utils/isLocalHost.js:
--------------------------------------------------------------------------------
1 |
2 | export default function isLocalHost() {
3 | const isLocalhostName = window.location.hostname === 'localhost';
4 | const isLocalhostIPv6 = window.location.hostname === '[::1]';
5 | const isLocalhostIPv4 = window.location.hostname.match(
6 | // 127.0.0.1/8
7 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
8 | );
9 |
10 | return isLocalhostName || isLocalhostIPv6 || isLocalhostIPv4;
11 | }
12 |
--------------------------------------------------------------------------------
/src/utils/sortByDate.js:
--------------------------------------------------------------------------------
1 | export default function sortDate(dateKey, order) {
2 | return function (a, b) {
3 | const timeA = new Date(a[dateKey]).getTime()
4 | const timeB = new Date(b[dateKey]).getTime()
5 | if (order === 'asc') {
6 | return timeA - timeB
7 | }
8 | // default 'desc' descending order
9 | return timeB - timeA
10 | }
11 | }
12 |
--------------------------------------------------------------------------------