├── .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 | 
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 | 
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 |
118 | {/* Area to render the todos */}
119 |
143 |
144 | );
145 | }
146 |
147 | export default App;
148 |
--------------------------------------------------------------------------------