├── .env ├── src ├── vite-env.d.ts ├── main.tsx ├── index.css ├── App.css ├── api │ └── sheets.ts ├── assets │ └── react.svg └── App.tsx ├── vite.config.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── tsconfig.json ├── package.json ├── public └── vite.svg └── README.md /.env: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render(); 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | color: #10141f; 6 | background-color: #fff; 7 | 8 | font-synthesis: none; 9 | text-rendering: optimizeLegibility; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-react-todo-google-sheets", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "axios": "^1.6.7", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "^18.2.55", 19 | "@types/react-dom": "^18.2.19", 20 | "@typescript-eslint/eslint-plugin": "^6.21.0", 21 | "@typescript-eslint/parser": "^6.21.0", 22 | "@vitejs/plugin-react": "^4.2.1", 23 | "eslint": "^8.56.0", 24 | "eslint-plugin-react-hooks": "^4.6.0", 25 | "eslint-plugin-react-refresh": "^0.4.5", 26 | "typescript": "^5.2.2", 27 | "vite": "^5.1.0" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | width: 100%; 3 | } 4 | 5 | .container { 6 | width: 350px; 7 | margin: 20px auto; 8 | } 9 | 10 | h1.title { 11 | font-size: 1.5rem; 12 | margin-bottom: 20px; 13 | padding: 10px 0px; 14 | border-bottom: 1px solid #eee; 15 | } 16 | 17 | form { 18 | display: flex; 19 | flex-direction: row; 20 | align-items: center; 21 | gap: 10px; 22 | 23 | input.todo-input { 24 | padding: 0px 10px; 25 | font-size: 1rem; 26 | border: 1px solid #eee; 27 | border-radius: 5px; 28 | height: 32px; 29 | flex: 1; 30 | } 31 | 32 | button.submit-btn { 33 | padding: 0px 10px; 34 | font-size: 1rem; 35 | border: 1px solid #eee; 36 | border-radius: 5px; 37 | height: 32px; 38 | width: 80px; 39 | background-color: #007bff; 40 | color: #fff; 41 | cursor: pointer; 42 | } 43 | } 44 | 45 | ul.todos { 46 | list-style: none; 47 | padding: 10px 0; 48 | margin: 0px; 49 | 50 | li { 51 | display: flex; 52 | flex-direction: row; 53 | align-items: center; 54 | justify-content: space-between; 55 | padding: 10px 0px; 56 | border-bottom: 1px solid #eee; 57 | gap: 10px; 58 | 59 | span { 60 | flex: 1; 61 | } 62 | 63 | button { 64 | padding: 0px 10px; 65 | font-size: 1rem; 66 | border: 1px solid #eee; 67 | border-radius: 5px; 68 | height: 32px; 69 | width: 80px; 70 | background-color: #dc3545; 71 | color: #fff; 72 | cursor: pointer; 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Todo Example with Apico Google Sheets 2 | This example showcases how you can build a simple Todo application with Google sheets as a backend. 3 | 4 | 5 | https://github.com/apicodev/example-react-todo-google-sheets/assets/53584487/5c20be45-fd29-41c2-9548-600ef28c18c0 6 | 7 | 8 | 9 | ## Running the project 10 | 11 | --- 12 | ### Setup the repository 13 | Clone the repository into your machine 14 | ``` 15 | $ git clone https://github.com/apicodev/example-react-todo-google-sheets.git 16 | ``` 17 | 18 | 19 | CD into the reposity and install the dependencies 20 | 21 | ``` 22 | $ cd example-react-todo-google-sheets 23 | $ npm install 24 | ``` 25 | 26 | --- 27 | ### Create a Google Sheet Integration in Apico 28 | Login to your [Apico](https://apico.dev) account and create a new Google sheets integration. Note the integration ID in the Readme.md file. 29 | 30 | ![image](https://github.com/apicodev/example-react-todo-google-sheets/assets/53584487/79379d9e-3b6e-4b80-b45d-65be830e3cee) 31 | 32 | --- 33 | ### Create an Empty Google sheet in your Google Account 34 | Login to your [Google Sheets](https://sheets.google.com) account and create a new Google Sheet and note down the URL 35 | 36 | The URL should look something similar to this 37 | ``` 38 | https://docs.google.com/spreadsheets/d/1AzT-z51EMqI_-Fe98434p_AP8Nq343rbheLPUfnw1FGCNo/edit#gid=1196872439 39 | ``` 40 | 41 | Here the variables you might need are as follows 42 | 43 | | Variable | Value | 44 | |--------------|-----------------------------------------------------| 45 | | spreadSheetId| 1AzT-z51EMqI_-Fe98434p_AP8Nq343rbheLPUfnw1FGCNo | 46 | | sheetId | 1196872439 | 47 | 48 | The name of your sheet/page or `SheetName` will be displayed at the bottom of the google sheets page. Optionally you can find the name and sheetId (gid) via the *Get Spreadsheet API*. 49 | 50 | --- 51 | ### Replace the variables in the code 52 | Open the `/src/api/sheets.ts` file and replace the variables in the following lines 53 | ``` 54 | ... 55 | const apicoIntegrationId: string = ""; 56 | const spreadSheetId: string = ""; 57 | const sheetName: string = "Sheet1"; // replace with your sheet name 58 | const sheetId: number = 1196872439; // replace with your sheet/page gid (not sheet name) 59 | ... 60 | ``` 61 | 62 | --- 63 | ### Finally run the project! 64 | ``` 65 | npm run dev 66 | ``` 67 | 68 | ![Screenshot 2024-02-11 at 12 05 30 AM](https://github.com/apicodev/example-react-todo-google-sheets/assets/53584487/1e0d9e05-56ca-4a02-9dc5-915f3c801d7d) 69 | -------------------------------------------------------------------------------- /src/api/sheets.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from "axios"; 2 | 3 | const apicoIntegrationId: string = ""; 4 | const spreadSheetId: string = ""; 5 | const sheetName: string = "Sheet1"; // replace with your sheet name 6 | const sheetId: number = 1196872439; // replace with your sheet/page gid (not sheet name) 7 | // you can look at the URL of your spread sheet in the browser to find the gid 8 | 9 | const apiBaseUrl = `https://api.apico.dev/v1/${apicoIntegrationId}/${spreadSheetId}`; 10 | 11 | export interface SpreadSheetResponse { 12 | values: string[][]; 13 | } 14 | export const getSpreasheetData = async () => { 15 | const response = await axios.get( 16 | `${apiBaseUrl}/values/${sheetName}` 17 | ); 18 | return response.data; 19 | }; 20 | 21 | /** 22 | * Function to append data to the spreadsheet 23 | * @param data string[] 24 | * @returns 25 | */ 26 | export const appendSpreadsheetData = async ( 27 | data: (string | number | boolean)[] 28 | ) => { 29 | const options: AxiosRequestConfig = { 30 | method: "POST", 31 | url: `${apiBaseUrl}/values/${sheetName}:append`, 32 | params: { 33 | valueInputOption: "USER_ENTERED", 34 | insertDataOption: "INSERT_ROWS", 35 | includeValuesInResponse: true, 36 | }, 37 | data: { 38 | values: [data], 39 | }, 40 | }; 41 | 42 | const response = await axios(options); 43 | return response.data; 44 | }; 45 | 46 | export const updateSpreadsheetData = async ( 47 | index: number, 48 | values: (string | number | boolean)[] 49 | ) => { 50 | const options: AxiosRequestConfig = { 51 | method: "PUT", 52 | url: `${apiBaseUrl}/values/${sheetName}!A${index + 1}`, 53 | params: { 54 | valueInputOption: "USER_ENTERED", 55 | includeValuesInResponse: true, 56 | }, 57 | data: { 58 | values: [values], 59 | }, 60 | }; 61 | 62 | const response = await axios(options); 63 | return response.data; 64 | }; 65 | 66 | export const deleteSpreadsheetRow = async (index: number) => { 67 | const range = { 68 | sheetId: sheetId, 69 | dimension: "ROWS", 70 | startIndex: index, 71 | endIndex: index+1, 72 | }; 73 | console.log(`deleting row from ${range.startIndex} to ${range.endIndex}`) 74 | const options: AxiosRequestConfig = { 75 | method: "POST", 76 | url: `${apiBaseUrl}:batchUpdate`, 77 | data: { 78 | requests: [ 79 | { 80 | deleteDimension: { 81 | range, 82 | }, 83 | }, 84 | ], 85 | }, 86 | }; 87 | 88 | const response = await axios(options); 89 | return response.data; 90 | }; 91 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FormEvent, useEffect, useState } from "react"; 2 | import "./App.css"; 3 | import { 4 | appendSpreadsheetData, 5 | deleteSpreadsheetRow, 6 | getSpreasheetData, 7 | updateSpreadsheetData, 8 | } from "./api/sheets"; 9 | 10 | interface Todo { 11 | id: number; // Unique identifier for the todo within the list 12 | value: string; // Text content of the todo 13 | isCompleted: boolean; // Flag indicating if the todo is completed 14 | } 15 | 16 | function App() { 17 | // to store the todos in state 18 | const [todos, setTodos] = useState([]); 19 | // to store the current todo input value in state 20 | const [todo, setTodo] = useState(""); 21 | 22 | // load the todos from the spreadsheet when the app loads 23 | const loadTodos = async () => { 24 | // load the todos from the spreadsheet 25 | const response = await getSpreasheetData(); 26 | const todos = response.values.map((t: string[]) => ({ 27 | id: parseInt(t[0]), 28 | value: t[1], 29 | isCompleted: t[2] === "TRUE", 30 | })); 31 | setTodos(todos); 32 | }; 33 | 34 | useEffect(() => { 35 | console.log(`Loading todos...`); 36 | loadTodos(); 37 | }, []); 38 | 39 | // function to add a todo to the list 40 | const addTodo = (todo: string) => { 41 | // if the todo is empty, don't add it to the list 42 | if (!todo.trim()) return; 43 | // store the todo in state 44 | const todoToAdd: Todo = { 45 | id: Date.now(), 46 | value: todo.trim(), 47 | isCompleted: false, 48 | }; 49 | 50 | setTodos((prev) => [...prev, todoToAdd]); 51 | // clear the todo input value 52 | setTodo(""); 53 | // store it in the spreadsheet 54 | appendSpreadsheetData([ 55 | todoToAdd.id, 56 | todoToAdd.value, 57 | todoToAdd.isCompleted.toString(), 58 | ]); 59 | }; 60 | 61 | // function to remove a todo from the list 62 | const removeTodo = (id: number) => { 63 | // remove the todo from the list 64 | setTodos((prev) => 65 | prev.filter((p, index) => { 66 | if (p.id === id) { 67 | deleteSpreadsheetRow(index); 68 | return false; 69 | } else return true; 70 | }) 71 | ); 72 | }; 73 | 74 | // function to toggle the isCompleted flag of a todo 75 | const toggleTodo = (id: number) => { 76 | // toggle the isCompleted flag of the todo 77 | console.log(`toggling todo with id: ${id}`); 78 | setTodos((prev) => 79 | prev.map((p, index) => { 80 | if (p.id === id) { 81 | const updatedTodo = { ...p, isCompleted: !p.isCompleted }; 82 | updateSpreadsheetData(index, [ 83 | updatedTodo.id, 84 | updatedTodo.value, 85 | updatedTodo.isCompleted.toString(), 86 | ]); 87 | return updatedTodo; 88 | } else { 89 | return p; 90 | } 91 | }) 92 | ); 93 | }; 94 | 95 | return ( 96 |
97 |

Todos

98 | {/* Input Area to add new todos */} 99 |
{ 101 | e.preventDefault(); 102 | addTodo(todo); 103 | }} 104 | > 105 | setTodo(e.target.value)} 112 | autoFocus 113 | /> 114 | 117 |
118 | {/* Area to render the todos */} 119 |
    120 | {todos.map((t) => ( 121 |
  • 122 | { 126 | toggleTodo(t.id); 127 | }} 128 | /> 129 | {t.value} 130 | 140 |
  • 141 | ))} 142 |
143 |
144 | ); 145 | } 146 | 147 | export default App; 148 | --------------------------------------------------------------------------------