├── public
├── favicon.ico
├── manifest.json
└── index.html
├── functions
├── utils
│ └── getId.js
├── todos-read.js
├── todos-delete.js
├── todos-update.js
├── todos-delete-batch.js
├── todos-read-all.js
└── todos-create.js
├── src
├── index.js
├── components
│ ├── ContentEditable
│ │ ├── ContentEditable.css
│ │ ├── Editable.js
│ │ └── index.js
│ ├── SettingsIcon
│ │ ├── SettingIcon.css
│ │ └── index.js
│ ├── SettingsMenu
│ │ ├── SettingsMenu.css
│ │ └── index.js
│ └── AppHeader
│ │ ├── index.js
│ │ └── AppHeader.css
├── App.test.js
├── utils
│ ├── sortByDate.js
│ ├── isLocalHost.js
│ └── api.js
├── assets
│ ├── github.svg
│ ├── logo.svg
│ └── deploy-to-netlify.svg
├── index.css
├── App.css
└── App.js
├── netlify.toml
├── webpack.config.js
├── .gitignore
├── scripts
├── check-for-fauna-key.js
└── bootstrap-fauna-database.js
├── package.json
└── README.md
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jchris/fauna-one-click/master/public/favicon.ico
--------------------------------------------------------------------------------
/functions/utils/getId.js:
--------------------------------------------------------------------------------
1 |
2 | export default function getId(urlPath) {
3 | return urlPath.match(/([^\/]*)\/*$/)[0]
4 | }
5 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | [build]
2 | functions = "functions-build"
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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var webpack = require("webpack");
2 |
3 | /* fix for https://medium.com/@danbruder/typeerror-require-is-not-a-function-webpack-faunadb-6e785858d23b */
4 | module.exports = {
5 | plugins: [
6 | new webpack.DefinePlugin({ "global.GENTLY": false })
7 | ],
8 | node: {
9 | __dirname: true,
10 | }
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/scripts/check-for-fauna-key.js:
--------------------------------------------------------------------------------
1 | const chalk = require('chalk')
2 |
3 | function checkForFaunaKey() {
4 | if (!process.env.FAUNADB_SERVER_SECRET) {
5 | console.log(chalk.yellow('Required FAUNADB_SERVER_SECRET enviroment variable not found.'))
6 | console.log(`
7 | =========================
8 |
9 | You can create fauna DB keys here: https://dashboard.fauna.com/db/keys
10 |
11 | In your terminal run the following command:
12 |
13 | export FAUNADB_SERVER_SECRET=YourFaunaDBKeyHere
14 |
15 | =========================
16 | `)
17 |
18 | process.exit(1)
19 | }
20 | }
21 |
22 | checkForFaunaKey()
23 |
--------------------------------------------------------------------------------
/functions/todos-read.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb'
2 | import getId from './utils/getId'
3 |
4 | const q = faunadb.query
5 | const client = new faunadb.Client({
6 | secret: process.env.FAUNADB_SERVER_SECRET
7 | })
8 |
9 | exports.handler = (event, context, callback) => {
10 | const id = getId(event.path)
11 | console.log(`Function 'todo-read' invoked. Read id: ${id}`)
12 | return client.query(q.Get(q.Ref(`classes/todos/${id}`)))
13 | .then((response) => {
14 | console.log('success', response)
15 | return callback(null, {
16 | statusCode: 200,
17 | body: JSON.stringify(response)
18 | })
19 | }).catch((error) => {
20 | console.log('error', error)
21 | return callback(null, {
22 | statusCode: 400,
23 | body: JSON.stringify(error)
24 | })
25 | })
26 | }
27 |
--------------------------------------------------------------------------------
/functions/todos-delete.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb'
2 | import getId from './utils/getId'
3 |
4 | const q = faunadb.query
5 | const client = new faunadb.Client({
6 | secret: process.env.FAUNADB_SERVER_SECRET
7 | })
8 |
9 | exports.handler = (event, context, callback) => {
10 | const id = getId(event.path)
11 | console.log(`Function 'todo-delete' invoked. delete id: ${id}`)
12 | return client.query(q.Delete(q.Ref(`classes/todos/${id}`)))
13 | .then((response) => {
14 | console.log('success', response)
15 | return callback(null, {
16 | statusCode: 200,
17 | body: JSON.stringify(response)
18 | })
19 | }).catch((error) => {
20 | console.log('error', error)
21 | return callback(null, {
22 | statusCode: 400,
23 | body: JSON.stringify(error)
24 | })
25 | })
26 | }
27 |
--------------------------------------------------------------------------------
/functions/todos-update.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb'
2 | import getId from './utils/getId'
3 |
4 | const q = faunadb.query
5 | const client = new faunadb.Client({
6 | secret: process.env.FAUNADB_SERVER_SECRET
7 | })
8 |
9 | exports.handler = (event, context, callback) => {
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 callback(null, {
17 | statusCode: 200,
18 | body: JSON.stringify(response)
19 | })
20 | }).catch((error) => {
21 | console.log('error', error)
22 | return callback(null, {
23 | statusCode: 400,
24 | body: JSON.stringify(error)
25 | })
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/src/assets/github.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/functions/todos-delete-batch.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb'
2 | import getId from './utils/getId'
3 |
4 | const q = faunadb.query
5 | const client = new faunadb.Client({
6 | secret: process.env.FAUNADB_SERVER_SECRET
7 | })
8 |
9 | exports.handler = (event, context, callback) => {
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 callback(null, {
22 | statusCode: 200,
23 | body: JSON.stringify(response)
24 | })
25 | }).catch((error) => {
26 | console.log('error', error)
27 | return callback(null, {
28 | statusCode: 400,
29 | body: JSON.stringify(error)
30 | })
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/functions/todos-read-all.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb'
2 |
3 | const q = faunadb.query
4 | const client = new faunadb.Client({
5 | secret: process.env.FAUNADB_SERVER_SECRET
6 | })
7 |
8 | exports.handler = (event, context, callback) => {
9 | console.log('Function `todo-read-all` invoked')
10 | return client.query(q.Paginate(q.Match(q.Ref('indexes/all_todos'))))
11 | .then((response) => {
12 | const todoRefs = response.data
13 | console.log('Todo refs', todoRefs)
14 | console.log(`${todoRefs.length} todos found`)
15 | // create new query out of todo refs. http://bit.ly/2LG3MLg
16 | const getAllTodoDataQuery = todoRefs.map((ref) => {
17 | return q.Get(ref)
18 | })
19 | // then query the refs
20 | return client.query(getAllTodoDataQuery).then((ret) => {
21 | return callback(null, {
22 | statusCode: 200,
23 | body: JSON.stringify(ret)
24 | })
25 | })
26 | }).catch((error) => {
27 | console.log('error', error)
28 | return callback(null, {
29 | statusCode: 400,
30 | body: JSON.stringify(error)
31 | })
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/functions/todos-create.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb' /* Import faunaDB sdk */
2 |
3 | /* configure faunaDB Client with our secret */
4 | const q = faunadb.query
5 | const client = new faunadb.Client({
6 | secret: process.env.FAUNADB_SERVER_SECRET
7 | })
8 |
9 | /* export our lambda function as named "handler" export */
10 | exports.handler = (event, context, callback) => {
11 | /* parse the string body into a useable JS object */
12 | const data = JSON.parse(event.body)
13 | console.log('Hello webinar. 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 callback(null, {
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 callback(null, {
30 | statusCode: 400,
31 | body: JSON.stringify(error)
32 | })
33 | })
34 | }
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "netlify-fauna",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "faunadb": "^0.2.2",
7 | "react": "^16.4.0",
8 | "react-dom": "^16.4.0",
9 | "react-scripts": "1.1.4"
10 | },
11 | "scripts": {
12 | "bootstrap": "node ./scripts/bootstrap-fauna-database.js",
13 | "docs": "md-magic --path '**/*.md' --ignore 'node_modules'",
14 | "checkForFaunaKey": "node ./scripts/check-for-fauna-key.js",
15 | "start": "npm-run-all --parallel checkForFaunaKey start:app start:server",
16 | "start:app": "react-scripts start",
17 | "start:server": "netlify-lambda serve functions -c ./webpack.config.js",
18 | "prebuild": "echo 'setup faunaDB' && npm run bootstrap",
19 | "build": "npm-run-all --parallel build:**",
20 | "build:app": "react-scripts build",
21 | "build:functions": "netlify-lambda build functions -c ./webpack.config.js",
22 | "test": "react-scripts test --env=jsdom"
23 | },
24 | "devDependencies": {
25 | "markdown-magic": "^0.1.23",
26 | "netlify-lambda": "^0.4.0",
27 | "npm-run-all": "^4.1.3"
28 | },
29 | "proxy": {
30 | "/.netlify/functions": {
31 | "target": "http://localhost:9000",
32 | "pathRewrite": {
33 | "^/\\.netlify/functions": ""
34 | }
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/scripts/bootstrap-fauna-database.js:
--------------------------------------------------------------------------------
1 | /* bootstrap database in your FaunaDB account */
2 | const readline = require('readline')
3 | const faunadb = require('faunadb')
4 | const chalk = require('chalk')
5 | const insideNetlify = insideNetlifyBuildContext()
6 | const q = faunadb.query
7 |
8 | if (!process.env.FAUNADB_SERVER_SECRET) {
9 | console.log('No FAUNADB_SERVER_SECRET found')
10 | console.log('Please run `netlify addons:create fauna-staging` and redeploy')
11 | return false
12 | }
13 |
14 | console.log(chalk.cyan('Creating your FaunaDB Database...\n'))
15 | if (insideNetlify) {
16 | // Run idempotent database creation
17 | createFaunaDB(process.env.FAUNADB_SERVER_SECRET).then(() => {
18 | console.log('Database created')
19 | })
20 | } else {
21 | console.log()
22 | console.log('You can create fauna DB keys here: https://dashboard.fauna.com/db/keys')
23 | console.log()
24 | ask(chalk.bold('Enter your faunaDB server key'), (err, answer) => {
25 | if (err) {
26 | console.log(err)
27 | }
28 | if (!answer) {
29 | console.log('Please supply a faunaDB server key')
30 | process.exit(1)
31 | }
32 | createFaunaDB(process.env.FAUNADB_SERVER_SECRET).then(() => {
33 | console.log('Database created')
34 | })
35 | })
36 | }
37 |
38 | /* idempotent operation */
39 | function createFaunaDB(key) {
40 | console.log('Create the database!')
41 | const client = new faunadb.Client({
42 | secret: key
43 | })
44 |
45 | /* Based on your requirements, change the schema here */
46 | return client.query(q.Create(q.Ref('classes'), { name: 'todos' }))
47 | .then(() => {
48 | return client.query(
49 | q.Create(q.Ref('indexes'), {
50 | name: 'all_todos',
51 | source: q.Ref('classes/todos')
52 | }))
53 | }).catch((e) => {
54 | // Database already exists
55 | if (e.requestResult.statusCode === 400 && e.message === 'instance not unique') {
56 | console.log('DB already exists')
57 | throw e
58 | }
59 | })
60 | }
61 |
62 | /* util methods */
63 |
64 | // Test if inside netlify build context
65 | function insideNetlifyBuildContext() {
66 | if (process.env.DEPLOY_PRIME_URL) {
67 | return true
68 | }
69 | return false
70 | }
71 |
72 | // Readline util
73 | function ask(question, callback) {
74 | const rl = readline.createInterface({
75 | input: process.stdin,
76 | output: process.stdout
77 | })
78 | rl.question(question + '\n', function(answer) {
79 | rl.close()
80 | callback(null, answer)
81 | })
82 | }
83 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/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 api from './utils/api'
7 | import sortByDate from './utils/sortByDate'
8 | import isLocalHost from './utils/isLocalHost'
9 | import './App.css'
10 |
11 | export default class App extends Component {
12 | state = {
13 | todos: [],
14 | showMenu: false
15 | }
16 | componentDidMount() {
17 | // Fetch all todos
18 | api.readAll().then((todos) => {
19 | if (todos.message === 'unauthorized') {
20 | if (isLocalHost()) {
21 | 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')
22 | } else {
23 | alert('FaunaDB key is not unauthorized. Verify the key `FAUNADB_SERVER_SECRET` set in Netlify enviroment variables is correct')
24 | }
25 | return false
26 | }
27 |
28 | console.log('all todos', todos)
29 | this.setState({
30 | todos: todos
31 | })
32 | })
33 | }
34 | saveTodo = (e) => {
35 | e.preventDefault()
36 | const { todos } = this.state
37 | const todoValue = this.inputElement.value
38 |
39 | if (!todoValue) {
40 | alert('Please add Todo title')
41 | this.inputElement.focus()
42 | return false
43 | }
44 |
45 | // reset input to empty
46 | this.inputElement.value = ''
47 |
48 | const todoInfo = {
49 | title: todoValue,
50 | completed: false,
51 | }
52 | // Optimistically add todo to UI
53 | const newTodoArray = [{
54 | data: todoInfo,
55 | ts: new Date().getTime() * 10000
56 | }]
57 |
58 | const optimisticTodoState = newTodoArray.concat(todos)
59 |
60 | this.setState({
61 | todos: optimisticTodoState
62 | })
63 | // Make API request to create new todo
64 | api.create(todoInfo).then((response) => {
65 | console.log(response)
66 | // remove temporaryValue from state and persist API response
67 | const persistedState = removeOptimisticTodo(todos).concat(response)
68 | // Set persisted value to state
69 | this.setState({
70 | todos: persistedState
71 | })
72 | }).catch((e) => {
73 | console.log('An API error occurred', e)
74 | const revertedState = removeOptimisticTodo(todos)
75 | // Reset to original state
76 | this.setState({
77 | todos: revertedState
78 | })
79 | })
80 | }
81 | deleteTodo = (e) => {
82 | const { todos } = this.state
83 | const todoId = e.target.dataset.id
84 |
85 | // Optimistically remove todo from UI
86 | const filteredTodos = todos.reduce((acc, current) => {
87 | const currentId = getTodoId(current)
88 | if (currentId === todoId) {
89 | // save item being removed for rollback
90 | acc.rollbackTodo = current
91 | return acc
92 | }
93 | // filter deleted todo out of the todos list
94 | acc.optimisticState = acc.optimisticState.concat(current)
95 | return acc
96 | }, {
97 | rollbackTodo: {},
98 | optimisticState: []
99 | })
100 |
101 | this.setState({
102 | todos: filteredTodos.optimisticState
103 | })
104 |
105 | // Make API request to delete todo
106 | api.delete(todoId).then(() => {
107 | console.log(`deleted todo id ${todoId}`)
108 | }).catch((e) => {
109 | console.log(`There was an error removing ${todoId}`, e)
110 | // Add item removed back to list
111 | this.setState({
112 | todos: filteredTodos.optimisticState.concat(filteredTodos.rollbackTodo)
113 | })
114 | })
115 | }
116 | handleTodoCheckbox = (event) => {
117 | const { todos } = this.state
118 | const { target } = event
119 | const todoCompleted = target.checked
120 | const todoId = target.dataset.id
121 |
122 | const updatedTodos = todos.map((todo, i) => {
123 | const { data } = todo
124 | const id = getTodoId(todo)
125 | if (id === todoId && data.completed !== todoCompleted) {
126 | data.completed = todoCompleted
127 | }
128 | return todo
129 | })
130 |
131 | this.setState({
132 | todos: updatedTodos
133 | }, () => {
134 | api.update(todoId, {
135 | completed: todoCompleted
136 | }).then(() => {
137 | console.log(`update todo ${todoId}`, todoCompleted)
138 | }).catch((e) => {
139 | console.log('An API error occurred', e)
140 | })
141 | })
142 | }
143 | updateTodoTitle = (event, currentValue) => {
144 | let isDifferent = false
145 | const todoId = event.target.dataset.key
146 |
147 | const updatedTodos = this.state.todos.map((todo, i) => {
148 | const id = getTodoId(todo)
149 | if (id === todoId && todo.data.title !== currentValue) {
150 | todo.data.title = currentValue
151 | isDifferent = true
152 | }
153 | return todo
154 | })
155 |
156 | // only set state if input different
157 | if (isDifferent) {
158 | this.setState({
159 | todos: updatedTodos
160 | }, () => {
161 | api.update(todoId, {
162 | title: currentValue
163 | }).then(() => {
164 | console.log(`update todo ${todoId}`, currentValue)
165 | }).catch((e) => {
166 | console.log('An API error occurred', e)
167 | })
168 | })
169 | }
170 | }
171 | clearCompleted = () => {
172 | const { todos } = this.state
173 |
174 | // Optimistically remove todos from UI
175 | const data = todos.reduce((acc, current) => {
176 | if (current.data.completed) {
177 | // save item being removed for rollback
178 | acc.completedTodoIds = acc.completedTodoIds.concat(getTodoId(current))
179 | return acc
180 | }
181 | // filter deleted todo out of the todos list
182 | acc.optimisticState = acc.optimisticState.concat(current)
183 | return acc
184 | }, {
185 | completedTodoIds: [],
186 | optimisticState: []
187 | })
188 |
189 | // only set state if completed todos exist
190 | if (!data.completedTodoIds.length) {
191 | alert('Please check off some todos to batch remove them')
192 | this.closeModal()
193 | return false
194 | }
195 |
196 | this.setState({
197 | todos: data.optimisticState
198 | }, () => {
199 | setTimeout(() => {
200 | this.closeModal()
201 | }, 600)
202 |
203 | api.batchDelete(data.completedTodoIds).then(() => {
204 | console.log(`Batch removal complete`, data.completedTodoIds)
205 | }).catch((e) => {
206 | console.log('An API error occurred', e)
207 | })
208 | })
209 |
210 | }
211 | closeModal = (e) => {
212 | this.setState({
213 | showMenu: false
214 | })
215 | }
216 | openModal = () => {
217 | this.setState({
218 | showMenu: true
219 | })
220 | }
221 | renderTodos() {
222 | const { todos } = this.state
223 |
224 | if (!todos || !todos.length) {
225 | // Loading State here
226 | return null
227 | }
228 |
229 | const timeStampKey = 'ts'
230 | const orderBy = 'desc' // or `asc`
231 | const sortOrder = sortByDate(timeStampKey, orderBy)
232 | const todosByDate = todos.sort(sortOrder)
233 |
234 | return todosByDate.map((todo, i) => {
235 | const { data, ref } = todo
236 | const id = getTodoId(todo)
237 | // only show delete button after create API response returns
238 | let deleteButton
239 | if (ref) {
240 | deleteButton = (
241 |
244 | )
245 | }
246 | const boxIcon = (data.completed) ? '#todo__box__done' : '#todo__box'
247 | return (
248 |
249 |
271 | {deleteButton}
272 |
273 | )
274 | })
275 | }
276 | render() {
277 | return (
278 |
279 |
280 |
281 |
282 |
283 |
284 | Create todo
285 |
286 |
287 |
303 |
304 | {this.renderTodos()}
305 |
306 |
311 |
312 | )
313 | }
314 | }
315 |
316 | function removeOptimisticTodo(todos) {
317 | // return all 'real' todos
318 | return todos.filter((todo) => {
319 | return todo.ref
320 | })
321 | }
322 |
323 | function getTodoId(todo) {
324 | if (!todo.ref) {
325 | return null
326 | }
327 | return todo.ref['@ref'].split('/').pop()
328 | }
329 |
--------------------------------------------------------------------------------
/src/assets/deploy-to-netlify.svg:
--------------------------------------------------------------------------------
1 |
17 |
--------------------------------------------------------------------------------
/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 | - [About this application](#about-this-application)
7 | - [Setup & Run Locally](#setup--run-locally)
8 | - [TLDR; Quick Deploy](#tldr-quick-deploy)
9 | - [Tutorial](#tutorial)
10 | * [Background](#background)
11 | * [1. Create React app](#1-create-react-app)
12 | * [2. Create a function](#2-create-a-function)
13 | + [Anatomy of a Lambda function](#anatomy-of-a-lambda-function)
14 | + [Setting up functions for local development](#setting-up-functions-for-local-development)
15 | * [4. Connect the function to the frontend app](#4-connect-the-function-to-the-frontend-app)
16 | * [5. Finishing the Backend Functions](#5-finishing-the-backend-functions)
17 | * [Wrapping Up](#wrapping-up)
18 |
19 |
20 | ## About this application
21 |
22 | 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.
23 |
24 | 
25 |
26 | ## Setup & Run Locally
27 |
28 | 1. Clone down the repository
29 |
30 | ```bash
31 | git clone git@github.com:netlify/netlify-faunadb-example.git
32 | ```
33 |
34 | 2. Install the dependencies
35 |
36 | ```bash
37 | npm install
38 | ```
39 |
40 | 3. Run project locally
41 |
42 | ```bash
43 | npm start
44 | ```
45 |
46 | ## TLDR; Quick Deploy
47 |
48 | 1. Click the [Deploy to Netlify Button](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/fauna-one-click&stack=fauna)
49 |
50 | [](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/fauna-one-click&stack=fauna)
51 |
52 | ## Tutorial
53 |
54 | ### Background
55 |
56 | 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.
57 |
58 | We are going to explore how to get up and running with netlify functions and how to deploy your own serverless backend.
59 |
60 | So, lets dive right in!
61 |
62 | ### 1. Create React app
63 |
64 | We are using React for this demo app, but you can use whatever you want to manage the frontend.
65 |
66 | Into VueJS? Awesome use that.
67 |
68 | Miss the days of jQuery? Righto jQuery away!
69 |
70 | Fan of vanillaJS? By all means, have at it!
71 |
72 | 1. Install create react app
73 |
74 | ```bash
75 | npm install create-react-app -g
76 | ```
77 | 2. Create the react app!
78 |
79 | ```bash
80 | create-react-app my-app
81 | ```
82 |
83 | 3. The react app is now setup!
84 |
85 | ```bash
86 | # change directories into my-app
87 | cd my-app
88 | # start the app
89 | npm start
90 | ```
91 |
92 | ### 2. Create a function
93 |
94 | Now, lets create a function for our app and wire that up to run locally.
95 |
96 | 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.
97 |
98 | #### Anatomy of a Lambda function
99 |
100 | All AWS Lambda functions have the following signature:
101 |
102 | ```js
103 | exports.handler = (event, context, callback) => {
104 | // "event" has informatiom about the path, body, headers etc of the request
105 | console.log('event', event)
106 | // "context" has information about the lambda environment and user details
107 | console.log('context', context)
108 | // The "callback" ends the execution of the function and returns a reponse back to the caller
109 | return callback(null, {
110 | statusCode: 200,
111 | body: JSON.stringify({
112 | data: '⊂◉‿◉つ'
113 | })
114 | })
115 | }
116 | ```
117 |
118 | We are going to use the `faunadb` npm package to connect to our Fauna Database and create an item
119 |
120 | #### Setting up functions for local development
121 |
122 | Lets rock and roll.
123 |
124 | 1. **Create a `./functions` directory**
125 |
126 | ```bash
127 | # make functions directory
128 | mdkir functions
129 | ```
130 |
131 | 2. **Install `netlify-lambda`**
132 |
133 | [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)
134 |
135 | ```
136 | npm i netlify-lambda --save-dev
137 | ```
138 |
139 | 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.
140 |
141 | In `package.json` add:
142 |
143 | ```json
144 | {
145 | "name": "react-lambda",
146 | ...
147 | "proxy": {
148 | "/.netlify/functions": {
149 | "target": "http://localhost:9000",
150 | "pathRewrite": {
151 | "^/\\.netlify/functions": ""
152 | }
153 | }
154 | }
155 | }
156 | ```
157 |
158 | This will proxy requests we make to `/.netlify/functions` to our locally running function server at port 9000.
159 |
160 | 3. **Add our `start` & `build` commands**
161 |
162 | Lets go ahead and add our `start` & `build` command to npm scripts in `package.json`. These will let us running things locally and give a command for netlify to run to build our app & functions when we are ready to deploy.
163 |
164 | We are going to be using the `npm-run-all` npm module to run our frontend & backend in parallel in the same terminal window.
165 |
166 | So install it!
167 |
168 | ```
169 | npm install npm-run-all --save-dev
170 | ```
171 |
172 | **About `npm start`**
173 |
174 | The `start:app` command will run `react-scripts start` to run our react app
175 |
176 | 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.
177 |
178 | Running `npm start` in our terminal will run `npm-run-all --parallel start:app start:server` to fire them both up at once.
179 |
180 | **About `npm build`**
181 |
182 | The `build:app` command will run `react-scripts build` to run our react app
183 |
184 | The `build:server` command will run `netlify-lambda build functions -c ./webpack.config.js` to run our function code locally.
185 |
186 | Running `npm run build` in our terminal will run `npm-run-all --parallel build:**` to fire them both up at once.
187 |
188 |
189 | **Your `package.json` should look like**
190 |
191 | ```json
192 | {
193 | "name": "netlify-fauna",
194 | "scripts": {
195 | "👇 ABOUT-bootstrap-command": "💡 scaffold and setup FaunaDB #",
196 | "bootstrap": "node ./scripts/bootstrap-fauna-database.js",
197 | "👇 ABOUT-start-command": "💡 start the app and server #",
198 | "start": "npm-run-all --parallel start:app start:server",
199 | "start:app": "react-scripts start",
200 | "start:server": "netlify-lambda serve functions -c ./webpack.config.js",
201 | "👇 ABOUT-prebuild-command": "💡 before 'build' runs, run the 'bootstrap' command #",
202 | "prebuild": "echo 'setup faunaDB' && npm run bootstrap",
203 | "👇 ABOUT-build-command": "💡 build the react app and the serverless functions #",
204 | "build": "npm-run-all --parallel build:**",
205 | "build:app": "react-scripts build",
206 | "build:functions": "netlify-lambda build functions -c ./webpack.config.js",
207 | },
208 | "dependencies": {
209 | "faunadb": "^0.2.2",
210 | "react": "^16.4.0",
211 | "react-dom": "^16.4.0",
212 | "react-scripts": "1.1.4"
213 | },
214 | "devDependencies": {
215 | "netlify-lambda": "^0.4.0",
216 | "npm-run-all": "^4.1.3"
217 | },
218 | "proxy": {
219 | "/.netlify/functions": {
220 | "target": "http://localhost:9000",
221 | "pathRewrite": {
222 | "^/\\.netlify/functions": ""
223 | }
224 | }
225 | }
226 | }
227 |
228 | ```
229 |
230 | 4. **Install FaunaDB and write the create function**
231 |
232 | We are going to be using the `faunadb` npm module to call into our todos index in FaunaDB.
233 |
234 | So install it in the project
235 |
236 | ```bash
237 | npm i faunadb --save
238 | ```
239 |
240 | Then create a new function file in `/functions` called `todos-create.js`
241 |
242 |
243 |
244 | ```js
245 | /* code from functions/todos-create.js */
246 | import faunadb from 'faunadb' /* Import faunaDB sdk */
247 |
248 | /* configure faunaDB Client with our secret */
249 | const q = faunadb.query
250 | const client = new faunadb.Client({
251 | secret: process.env.FAUNADB_SERVER_SECRET
252 | })
253 |
254 | /* export our lambda function as named "handler" export */
255 | exports.handler = (event, context, callback) => {
256 | /* parse the string body into a useable JS object */
257 | const data = JSON.parse(event.body)
258 | console.log('Hello webinar. Function `todo-create` invoked', data)
259 | const todoItem = {
260 | data: data
261 | }
262 | /* construct the fauna query */
263 | return client.query(q.Create(q.Ref('classes/todos'), todoItem))
264 | .then((response) => {
265 | console.log('success', response)
266 | /* Success! return the response with statusCode 200 */
267 | return callback(null, {
268 | statusCode: 200,
269 | body: JSON.stringify(response)
270 | })
271 | }).catch((error) => {
272 | console.log('error', error)
273 | /* Error! return the error with statusCode 400 */
274 | return callback(null, {
275 | statusCode: 400,
276 | body: JSON.stringify(error)
277 | })
278 | })
279 | }
280 | ```
281 |
282 |
283 | ### 4. Connect the function to the frontend app
284 |
285 | Inside of the react app, we can now wire up the `/.netlify/functions/todos-create` endpoint to an AJAX request.
286 |
287 | ```js
288 | // Function using fetch to POST to our API endpoint
289 | function createTodo(data) {
290 | return fetch('/.netlify/functions/todos-create', {
291 | body: JSON.stringify(data),
292 | method: 'POST'
293 | }).then(response => {
294 | return response.json()
295 | })
296 | }
297 |
298 | // Todo data
299 | const myTodo = {
300 | title: 'My todo title',
301 | completed: false,
302 | }
303 |
304 | // create it!
305 | createTodo(myTodo).then((response) => {
306 | console.log('API response', response)
307 | // set app state
308 | }).catch((error) => {
309 | console.log('API error', error)
310 | })
311 | ```
312 |
313 | 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.
314 |
315 |
316 | 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.
317 |
318 | All the demo React frontend code is [available here](https://github.com/netlify/netlify-faunadb-example/tree/17a9ba47a8b1b2408b68e793fba4c5fd17bf85da/src)
319 |
320 | ### 5. Finishing the Backend Functions
321 |
322 | So far we have created our `todo-create` function done 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.
323 |
324 | 1. **Read Todos by ID**
325 |
326 | Then create a new function file in `/functions` called `todos-read.js`
327 |
328 |
329 |
330 | ```js
331 | /* code from functions/todos-read.js */
332 | import faunadb from 'faunadb'
333 | import getId from './utils/getId'
334 |
335 | const q = faunadb.query
336 | const client = new faunadb.Client({
337 | secret: process.env.FAUNADB_SERVER_SECRET
338 | })
339 |
340 | exports.handler = (event, context, callback) => {
341 | const id = getId(event.path)
342 | console.log(`Function 'todo-read' invoked. Read id: ${id}`)
343 | return client.query(q.Get(q.Ref(`classes/todos/${id}`)))
344 | .then((response) => {
345 | console.log('success', response)
346 | return callback(null, {
347 | statusCode: 200,
348 | body: JSON.stringify(response)
349 | })
350 | }).catch((error) => {
351 | console.log('error', error)
352 | return callback(null, {
353 | statusCode: 400,
354 | body: JSON.stringify(error)
355 | })
356 | })
357 | }
358 | ```
359 |
360 |
361 | 2. **Read All Todos**
362 |
363 | Then create a new function file in `/functions` called `todos-read-all.js`
364 |
365 |
366 |
367 | ```js
368 | /* code from functions/todos-read-all.js */
369 | import faunadb from 'faunadb'
370 |
371 | const q = faunadb.query
372 | const client = new faunadb.Client({
373 | secret: process.env.FAUNADB_SERVER_SECRET
374 | })
375 |
376 | exports.handler = (event, context, callback) => {
377 | console.log('Function `todo-read-all` invoked')
378 | return client.query(q.Paginate(q.Match(q.Ref('indexes/all_todos'))))
379 | .then((response) => {
380 | const todoRefs = response.data
381 | console.log('Todo refs', todoRefs)
382 | console.log(`${todoRefs.length} todos found`)
383 | // create new query out of todo refs. http://bit.ly/2LG3MLg
384 | const getAllTodoDataQuery = todoRefs.map((ref) => {
385 | return q.Get(ref)
386 | })
387 | // then query the refs
388 | return client.query(getAllTodoDataQuery).then((ret) => {
389 | return callback(null, {
390 | statusCode: 200,
391 | body: JSON.stringify(ret)
392 | })
393 | })
394 | }).catch((error) => {
395 | console.log('error', error)
396 | return callback(null, {
397 | statusCode: 400,
398 | body: JSON.stringify(error)
399 | })
400 | })
401 | }
402 | ```
403 |
404 |
405 | 3. **Update todo by ID**
406 |
407 | Then create a new function file in `/functions` called `todos-update.js`
408 |
409 |
410 |
411 | ```js
412 | /* code from functions/todos-update.js */
413 | import faunadb from 'faunadb'
414 | import getId from './utils/getId'
415 |
416 | const q = faunadb.query
417 | const client = new faunadb.Client({
418 | secret: process.env.FAUNADB_SERVER_SECRET
419 | })
420 |
421 | exports.handler = (event, context, callback) => {
422 | const data = JSON.parse(event.body)
423 | const id = getId(event.path)
424 | console.log(`Function 'todo-update' invoked. update id: ${id}`)
425 | return client.query(q.Update(q.Ref(`classes/todos/${id}`), {data}))
426 | .then((response) => {
427 | console.log('success', response)
428 | return callback(null, {
429 | statusCode: 200,
430 | body: JSON.stringify(response)
431 | })
432 | }).catch((error) => {
433 | console.log('error', error)
434 | return callback(null, {
435 | statusCode: 400,
436 | body: JSON.stringify(error)
437 | })
438 | })
439 | }
440 | ```
441 |
442 |
443 |
444 | 4. **Delete by ID**
445 |
446 | Then create a new function file in `/functions` called `todos-delete.js`
447 |
448 |
449 |
450 | ```js
451 | /* code from functions/todos-delete.js */
452 | import faunadb from 'faunadb'
453 | import getId from './utils/getId'
454 |
455 | const q = faunadb.query
456 | const client = new faunadb.Client({
457 | secret: process.env.FAUNADB_SERVER_SECRET
458 | })
459 |
460 | exports.handler = (event, context, callback) => {
461 | const id = getId(event.path)
462 | console.log(`Function 'todo-delete' invoked. delete id: ${id}`)
463 | return client.query(q.Delete(q.Ref(`classes/todos/${id}`)))
464 | .then((response) => {
465 | console.log('success', response)
466 | return callback(null, {
467 | statusCode: 200,
468 | body: JSON.stringify(response)
469 | })
470 | }).catch((error) => {
471 | console.log('error', error)
472 | return callback(null, {
473 | statusCode: 400,
474 | body: JSON.stringify(error)
475 | })
476 | })
477 | }
478 | ```
479 |
480 |
481 |
482 |
483 | 4. **Delete batch todos**
484 |
485 | Then create a new function file in `/functions` called `todos-delete-batch.js`
486 |
487 |
488 |
489 | ```js
490 | /* code from functions/todos-delete-batch.js */
491 | import faunadb from 'faunadb'
492 | import getId from './utils/getId'
493 |
494 | const q = faunadb.query
495 | const client = new faunadb.Client({
496 | secret: process.env.FAUNADB_SERVER_SECRET
497 | })
498 |
499 | exports.handler = (event, context, callback) => {
500 | const data = JSON.parse(event.body)
501 | console.log('data', data)
502 | console.log('Function `todo-delete-batch` invoked', data.ids)
503 | // construct batch query from IDs
504 | const deleteAllCompletedTodoQuery = data.ids.map((id) => {
505 | return q.Delete(q.Ref(`classes/todos/${id}`))
506 | })
507 | // Hit fauna with the query to delete the completed items
508 | return client.query(deleteAllCompletedTodoQuery)
509 | .then((response) => {
510 | console.log('success', response)
511 | return callback(null, {
512 | statusCode: 200,
513 | body: JSON.stringify(response)
514 | })
515 | }).catch((error) => {
516 | console.log('error', error)
517 | return callback(null, {
518 | statusCode: 400,
519 | body: JSON.stringify(error)
520 | })
521 | })
522 | }
523 | ```
524 |
525 |
526 | After we deploy all these functions, we will be able to call them from our frontend code with these fetch calls:
527 |
528 |
529 |
530 | ```js
531 | /* Frontend code from src/utils/api.js */
532 | /* Api methods to call /functions */
533 |
534 | const create = (data) => {
535 | return fetch('/.netlify/functions/todos-create', {
536 | body: JSON.stringify(data),
537 | method: 'POST'
538 | }).then(response => {
539 | return response.json()
540 | })
541 | }
542 |
543 | const readAll = () => {
544 | return fetch('/.netlify/functions/todos-read-all').then((response) => {
545 | return response.json()
546 | })
547 | }
548 |
549 | const update = (todoId, data) => {
550 | return fetch(`/.netlify/functions/todos-update/${todoId}`, {
551 | body: JSON.stringify(data),
552 | method: 'POST'
553 | }).then(response => {
554 | return response.json()
555 | })
556 | }
557 |
558 | const deleteTodo = (todoId) => {
559 | return fetch(`/.netlify/functions/todos-delete/${todoId}`, {
560 | method: 'POST',
561 | }).then(response => {
562 | return response.json()
563 | })
564 | }
565 |
566 | const batchDeleteTodo = (todoIds) => {
567 | return fetch(`/.netlify/functions/todos-delete-batch`, {
568 | body: JSON.stringify({
569 | ids: todoIds
570 | }),
571 | method: 'POST'
572 | }).then(response => {
573 | return response.json()
574 | })
575 | }
576 |
577 | export default {
578 | create: create,
579 | readAll: readAll,
580 | update: update,
581 | delete: deleteTodo,
582 | batchDelete: batchDeleteTodo
583 | }
584 | ```
585 |
586 |
587 | ### Wrapping Up
588 |
589 | I hope you have enjoyed this tutorial on building your own CRUD API using Netlify serverless functions and FaunaDB.
590 |
591 | As you can see, functions can be extremely powerful when combined with a cloud database!
592 |
593 | The sky is the limit on what you can build with the JAM stack and we'd love to hear about what you make.
594 |
595 | **Next Steps**
596 |
597 | This example can be improved with users/authentication. Next steps to build out the app would be:
598 |
599 | - Add in the concept of users for everyone to have their own todo list
600 | - Wire up authentication using [Netlify Identity](https://identity.netlify.com/) JWTs
601 | - Add in due dates to todos and wire up Functions to notify users via email/SMS
602 | - File for IPO?
603 |
--------------------------------------------------------------------------------