├── .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 | ![faunadb netlify](https://user-images.githubusercontent.com/532272/42067494-5c4c2b94-7afb-11e8-91b4-0bef66d85584.png) 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 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](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 | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](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 | ![setup steps](https://user-images.githubusercontent.com/532272/42069927-28e1c436-7b09-11e8-96e9-272987fc9e15.gif) 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 | ![Sign up for Fauna](https://user-images.githubusercontent.com/6691035/69237909-50e05f80-0b5c-11ea-9ddb-174058f056d9.png) 164 | 165 | 2. **Create a key** 166 | 167 | ![Create a fauna key](https://user-images.githubusercontent.com/6691035/69237938-5ccc2180-0b5c-11ea-93c1-0ac61c9da429.png) 168 | 169 | 3. **Name your key and create** 170 | 171 | ![Name the fauna key and create](https://user-images.githubusercontent.com/6691035/69237999-86854880-0b5c-11ea-8e95-6d242a6e5f51.png) 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 | ![Copy API key for future use](https://user-images.githubusercontent.com/6691035/69238071-addc1580-0b5c-11ea-80ce-8aa894875008.png) 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 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 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 |
317 | this.inputElement = el} 322 | autoComplete='off' 323 | style={{marginRight: 20}} 324 | /> 325 |
326 | 329 | 330 |
331 |
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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/assets/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 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 | logo 14 |
15 |

Netlify + Fauna DB

16 |

17 | Using FaunaDB & Netlify functions 18 |

19 |
20 |
21 |
22 |
23 | 27 | deploy to netlify 28 | 29 |
30 | 34 | view repo on github 35 | View the source Luke 36 | 37 |
38 |
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 | 9 | 10 | 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 | --------------------------------------------------------------------------------