├── .clasp.json.SAMPLE ├── .claspignore ├── .eslintignore ├── .eslintrc.json ├── .github └── workflows │ ├── integration-tests-basic.yaml │ ├── integration-tests-extended.yaml │ └── lint.yaml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .stylelintrc.json ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── appsscript.json ├── dev └── dev-server-wrapper.html ├── jest.config.js ├── package.json ├── postcss.config.cjs ├── scripts └── generate-cert.ps1 ├── src ├── client │ ├── .eslintrc.json │ ├── README.md │ ├── dialog-demo-bootstrap │ │ ├── components │ │ │ ├── FormInput.tsx │ │ │ └── SheetEditor.jsx │ │ ├── index.html │ │ ├── index.jsx │ │ └── styles.css │ ├── dialog-demo-mui │ │ ├── components │ │ │ ├── FormInput.tsx │ │ │ ├── SheetEditor.jsx │ │ │ └── SheetTable.tsx │ │ ├── index.html │ │ ├── index.jsx │ │ └── styles.css │ ├── dialog-demo-tailwindcss │ │ ├── components │ │ │ ├── FormInput.tsx │ │ │ ├── SheetButton.jsx │ │ │ └── SheetEditor.jsx │ │ ├── index.html │ │ ├── index.jsx │ │ └── styles.css │ ├── dialog-demo │ │ ├── components │ │ │ ├── FormInput.jsx │ │ │ ├── SheetButton.jsx │ │ │ └── SheetEditor.jsx │ │ ├── index.html │ │ ├── index.jsx │ │ └── styles.css │ ├── sidebar-about-page │ │ ├── components │ │ │ └── About.jsx │ │ ├── index.html │ │ └── index.jsx │ └── utils │ │ └── serverFunctions.ts └── server │ ├── .eslintrc.json │ ├── README.md │ ├── index.ts │ ├── sheets.ts │ └── ui.js ├── tailwind.config.js ├── test ├── .babelrc ├── .eslintrc.json ├── README.md ├── __image_snapshots__ │ ├── local-development-test-js-local-setup-extended-should-load-bootstrap-example-1-snap.png │ ├── local-development-test-js-local-setup-extended-should-modify-bootstrap-title-example-1-snap.png │ ├── local-development-test-js-local-setup-extended-should-modify-bootstrap-title-example-back-to-original-1-snap.png │ ├── local-development-test-js-local-setup-should-load-bootstrap-example-1-snap.png │ ├── local-development-test-js-local-setup-should-modify-bootstrap-title-example-1-snap.png │ └── local-development-test-js-local-setup-should-modify-bootstrap-title-example-back-to-original-1-snap.png ├── global-setup.js ├── global-teardown.js ├── jest-puppeteer.config.js ├── local-development.test.js ├── puppeteer-environment.js └── utils │ ├── image-reporter-standalone.js │ ├── image-reporter.js │ └── open-addon.js ├── tsconfig.json ├── tsconfig.vite.json ├── vite.config.ts └── yarn.lock /.clasp.json.SAMPLE: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": "dist", 3 | "scriptId": "...add scriptId here...", 4 | "parentId": [ 5 | "...spreadsheet/doc url ID here..." 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.claspignore: -------------------------------------------------------------------------------- 1 | main.js 2 | *-impl.html 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "airbnb-base", 6 | "plugin:prettier/recommended", 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "plugins": ["prettier", "googleappsscript"], 11 | "env": { 12 | "googleappsscript/googleappsscript": true 13 | }, 14 | "rules": { 15 | "prettier/prettier": "error", 16 | "camelcase": "warn", 17 | "import/prefer-default-export": "warn", 18 | "import/no-extraneous-dependencies": "warn", 19 | "prefer-object-spread": "warn", 20 | "import/extensions": [ 21 | "error", 22 | "ignorePackages", 23 | { 24 | "js": "never", 25 | "ts": "never" 26 | } 27 | ] 28 | }, 29 | "ignorePatterns": ["dist", ".eslintrc.json"], 30 | "settings": { 31 | "import/resolver": { 32 | "node": { 33 | "extensions": [".js", ".ts"] 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests-basic.yaml: -------------------------------------------------------------------------------- 1 | name: Local integration tests - Basic Version 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | basic-integration-test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [macos-13, windows-2022] 13 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 14 | node-version: [20] 15 | timeout-minutes: 8 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install packages 23 | run: yarn install 24 | - name: Allow running mkcert on Mac 25 | run: sudo security authorizationdb write com.apple.trust-settings.admin allow 26 | if: runner.os == 'MacOS' 27 | - name: Install mkcert 28 | run: brew install mkcert 29 | if: runner.os == 'MacOS' 30 | - name: Run mkcert setup [mkcert -install] 31 | run: mkcert -install 32 | if: runner.os == 'MacOS' 33 | - name: Install https cert [yarn setup:https] 34 | run: yarn setup:https 35 | if: runner.os == 'MacOS' 36 | - run: | 37 | mkdir certs 38 | .\scripts\generate-cert.ps1 39 | shell: pwsh 40 | if: runner.os == 'Windows' 41 | - name: Run integration tests 42 | run: yarn test:integration 43 | shell: bash -------------------------------------------------------------------------------- /.github/workflows/integration-tests-extended.yaml: -------------------------------------------------------------------------------- 1 | name: Local integration tests - Extended Version 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | extended-integration-test: 9 | runs-on: ${{ matrix.os }} 10 | strategy: 11 | matrix: 12 | os: [macos-13, windows-2022] 13 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 14 | node-version: [20] 15 | timeout-minutes: 11 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | - name: Install packages 23 | run: yarn install 24 | - name: Allow running mkcert on Mac 25 | run: sudo security authorizationdb write com.apple.trust-settings.admin allow 26 | if: runner.os == 'MacOS' 27 | - name: Install mkcert 28 | run: brew install mkcert 29 | if: runner.os == 'MacOS' 30 | - name: Run mkcert setup [mkcert -install] 31 | run: mkcert -install 32 | if: runner.os == 'MacOS' 33 | - name: Install https cert [yarn setup:https] 34 | run: yarn setup:https 35 | if: runner.os == 'MacOS' 36 | - run: | 37 | mkdir certs 38 | .\scripts\generate-cert.ps1 39 | shell: pwsh 40 | if: runner.os == 'Windows' 41 | - name: Add .clasprc.json to home folder 42 | run: echo "$DOT_CLASPRC" > $HOME/.clasprc.json 43 | shell: bash 44 | env: 45 | DOT_CLASPRC: ${{ secrets.DOT_CLASPRC }} 46 | - name: Add .clasp.json to project directory 47 | run: echo "$DOT_CLASP" > .clasp.json 48 | shell: bash 49 | env: 50 | DOT_CLASP: ${{ secrets.DOT_CLASP }} 51 | - name: Add environment variables to .env file 52 | run: | 53 | echo "EMAIL=$TEST_ACCOUNT_EMAIL" > .env 54 | echo "TEST_RECOVERY_EMAIL=$TEST_RECOVERY_EMAIL" >> .env 55 | echo "PASSWORD=$TEST_ACCOUNT_PASSWORD" >> .env 56 | echo "SHEET_URL=$TEST_SPREADSHEET_URL" >> .env 57 | echo "S3_BUCKET_NAME=$S3_BUCKET_NAME" >> .env 58 | echo "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY" >> .env 59 | echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID" >> .env 60 | shell: bash 61 | env: 62 | TEST_ACCOUNT_EMAIL: ${{ secrets.TEST_ACCOUNT_EMAIL }} 63 | TEST_RECOVERY_EMAIL: ${{ secrets.TEST_RECOVERY_EMAIL }} 64 | TEST_ACCOUNT_PASSWORD: ${{ secrets.TEST_ACCOUNT_PASSWORD }} 65 | TEST_SPREADSHEET_URL: ${{ secrets.TEST_SPREADSHEET_URL }} 66 | S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} 67 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 68 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 69 | - name: Build and deploy dev setup [yarn deploy:dev] 70 | run: yarn deploy:dev 71 | env: 72 | NODE_OPTIONS: '--max_old_space_size=4096' 73 | - name: Run integration tests 74 | # use ci-reporter to publish failing diff images to s3 bucket 75 | # run: yarn test:integration:extended:ci-reporter 76 | run: yarn test:integration:extended 77 | shell: bash 78 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: macos-13 8 | timeout-minutes: 8 9 | steps: 10 | - uses: actions/checkout@v4 11 | - name: Use Node.js ${{ matrix.node-version }} 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | - name: Install packages 16 | run: yarn install 17 | - name: Run lint 18 | run: yarn lint 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules 3 | 4 | # clasp files 5 | creds.json 6 | 7 | # certs 8 | *.pem 9 | certs/ 10 | 11 | # build 12 | dist/ 13 | 14 | # mac 15 | .DS_Store 16 | 17 | #secret 18 | .env -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/gallium 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "at-rule-no-unknown": [ 4 | true, 5 | { 6 | "ignoreAtRules": [ 7 | "extends", 8 | "apply", 9 | "tailwind", 10 | "components", 11 | "utilities", 12 | "screen" 13 | ] 14 | } 15 | ] 16 | } 17 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch spreadsheet with debugger", 11 | "trace": true, 12 | "sourceMaps": true, 13 | "pauseForSourceMap": false, 14 | "skipFiles": ["**/node_modules/**", "!${workspaceFolder}/**"], 15 | "webRoot": "${workspaceFolder}/src/client", 16 | // Need random open port for logging into spreadsheets: 17 | // https://github.com/microsoft/vscode-js-debug/issues/918 18 | "port": 12345, 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "json.schemas": [{ 3 | "fileMatch": [ 4 | "appsscript.json" 5 | ], 6 | "url": "http://json.schemastore.org/appsscript" 7 | }, { 8 | "fileMatch": [ 9 | ".clasp.json" 10 | ], 11 | "url": "http://json.schemastore.org/clasp" 12 | }] 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Elisha Nuchi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | React & Google Apps Script logos 4 |

5 |

6 | Built with React v18 and Vite for best-in-class frontend development. 7 |

8 | 9 |
10 | 11 | [![Status](https://img.shields.io/badge/status-active-success.svg?color=46963a&style=flat-square)]() 12 | [![GitHub Issues](https://img.shields.io/github/issues/enuchi/React-Google-Apps-Script.svg?color=lightblue&style=flat-square)](https://github.com/enuchi/React-Google-Apps-Script/issues) 13 | [![GitHub Pull Requests](https://img.shields.io/github/issues-pr/enuchi/React-Google-Apps-Script.svg?color=blue&style=flat-square)](https://github.com/enuchi/React-Google-Apps-Script/pulls) 14 | [![License](https://img.shields.io/github/license/enuchi/React-Google-Apps-Script?color=pink&style=flat-square)](/LICENSE) 15 | 16 |
17 | 18 |

🚀 This is your boilerplate project for developing React apps inside Google Sheets, Docs, Forms and Slides projects. It's perfect for personal projects and for publishing complex add-ons in the Google Workspace Marketplace. 19 |

20 | 21 | --- 22 | 23 | ## 📝 Table of Contents 24 | 25 | - [About](#about) 26 | - [Install](#install) 27 | - [Prerequisites](#prerequisites) 28 | - [Getting started](#getting-started) 29 | - [Deploy](#deploy) 30 | - [Local Development](#local-development) 31 | - [Using React DevTools](#dev-tools) 32 | - [Usage](#usage) 33 | - [The included sample app](#the-included-sample-app) 34 | - [Typescript](#new-typescript) 35 | - [Adding packages](#adding-packages) 36 | - [Styles](#styles) 37 | - [Modifying scopes](#modifying-scopes) 38 | - [Calling server-side Google Apps Script functions](#calling-server-side-google-apps-script-functions) 39 | - [Autocomplete](#Autocomplete) 40 | - [Authors](#authors) 41 | - [Acknowledgments](#acknowledgement) 42 | 43 |
44 | 45 | ## 🔎 About 46 | 47 | [Google Apps Script](https://developers.google.com/apps-script/overview) is Google's Javascript-based development platform for building applications and add-ons for Google Sheets, Docs, Forms and other Google Apps. 48 | 49 | Google Apps Scripts lets you add custom [user interfaces inside dialog windows](https://developers.google.com/apps-script/guides/html). Using this template, it's easy to run [React](https://reactjs.org/) apps inside these dialogs, and build everything from small projects to advanced add-ons that can be published in the Google Workspace Marketplace. 50 | 51 |

52 | React & Google Apps Script 53 |

54 | 55 | This repo is a boilerplate project for developing React apps with Google Apps Script projects. You can use this starter template to build your own React apps and deploy them inside Google Sheets, Docs, Forms and Slides for use in dialogs and sidebars. Sample code is provided showing how your React app can interact with the underlying Google Apps Script server-side code. 56 | 57 | Read on to get started with your own project! 58 |
59 | 60 | ## 🚜 Install 61 | 62 | These instructions will get you set up with a copy of the React project code on your local machine. It will also get you logged in to `clasp`, which lets you manage script projects from the command line. 63 | 64 | See [deploy](#deploy) for notes on how to deploy the project and see it live in a Google Spreadsheet. 65 | 66 | ### Prerequisites 67 | 68 | - Make sure you're running at least [Node.js](https://nodejs.org/en/download/) v18 and [yarn (classic)](https://classic.yarnpkg.com/lang/en/docs/install/). 69 | 70 | - You'll need to enable the Google Apps Script API. You can do that by visiting [script.google.com/home/usersettings](https://script.google.com/home/usersettings). 71 | 72 | - To use live reload while developing, you'll need to serve your files locally using HTTPS. See [local development](#local-development) below for instructions on setting up your local environment. 73 | 74 | ### 🏁 Getting started 75 | 76 | Full steps to getting your local environment set up, deploying your app, and also running your app locally for local development are shown in the video below: 77 | 78 | https://github.com/enuchi/React-Google-Apps-Script/assets/31550519/83622b83-0d0e-43de-a589-36f96d51c9c4 79 | 80 | 81 | **1.** First, let's clone the repo and install the dependencies. This project is published as a public template, so you can also fork the repo or select "Use this template" in GitHub. 82 | 83 | ```bash 84 | git clone https://github.com/enuchi/React-Google-Apps-Script.git 85 | cd React-Google-Apps-Script 86 | yarn install 87 | ``` 88 | 89 | 90 | **2.** Next, we'll need to log in to [clasp](https://github.com/google/clasp), which lets us manage our Google Apps Script projects locally. 91 | 92 | ```bash 93 | yarn run login 94 | ``` 95 | **3.** Now let's run the setup script to create a New spreadsheet and script project from the command line. 96 | 97 | ```bash 98 | yarn run setup 99 | ``` 100 | 101 | Alternatively, you can use an existing Google Spreadsheet and Script file instead of creating a new one. 102 | 103 |
104 | See instructions here for using an existing project. 105 | 106 | You will need to update the `.clasp.json` file in the root of this project with the following three key/value pairs (see .clasp.json.SAMPLE for reference): 107 | 108 | ```json 109 | { 110 | "scriptId": "1PY037hPcy................................................", 111 | "parentId": ["1Df30......................................."], 112 | "rootDir": "./dist" 113 | } 114 | ``` 115 | 116 | - `scriptId`: Your existing script project's `scriptId`. You can find it by opening your spreadsheet, selecting **Tools > Script Editor** from the menubar, then **File > Project properties**, and it will be listed as "Script ID". 117 | 118 | - `parentId`: An array with a single string, the ID of the parent file (spreadsheet, doc, etc.) that the script project is bound to. You can get this ID from the url, where the format is usually `https://docs.google.com/spreadsheets/d/{id}/edit`. This allows you to run `npm run open` and open your file directly from the command line. 119 | 120 | - `rootDir`: This should always be `"./dist"`, i.e. the local build folder that is used to store project files. 121 | 122 |
123 | 124 | Next, let's deploy the app so we can see it live in Google Spreadsheets. 125 | 126 | https://github.com/enuchi/React-Google-Apps-Script/assets/31550519/0c67c4b8-e3f5-4345-8460-470e9211aeb9 127 | 128 |
129 | 130 | ## 🚀 Deploy 131 | 132 | Run the deploy command. You may be prompted to update your manifest file. Type 'yes'. 133 | 134 | ```bash 135 | yarn run deploy 136 | ``` 137 | 138 | The deploy command will build all necessary files using production settings, including all server code (Google Apps Script code), client code (React bundle), and config files. All bundled files will be outputted to the `dist/` folder, then pushed to the Google Apps Script project. 139 | 140 | Now open Google Sheets and navigate to your new spreadsheet (e.g. the file "My React Project"). You can also run `yarn run open`. Make sure to refresh the page if you already had it open. You will now see a new menu item appear containing your app! 141 | 142 |
143 | 144 | ## 🎈 Local Development 145 | 146 | We can develop our client-side React apps locally, and see our changes directly inside our Google Spreadsheet dialog window. 147 | 148 | There are two steps to getting started: installing a certificate (first time only), and running the start command. 149 | 150 | 1. Generating a certificate for local development 151 | 152 | Install the mkcert package: 153 | 154 | ```bash 155 | # mac: 156 | brew install mkcert 157 | 158 | # windows: 159 | choco install mkcert 160 | ``` 161 | 162 | [More install options here.](https://github.com/FiloSottile/mkcert#installation) 163 | 164 | Then run the mkcert install script: 165 | 166 | ```bash 167 | mkcert -install 168 | ``` 169 | 170 | Create the certs in your repo: 171 | 172 | ``` 173 | yarn run setup:https 174 | ``` 175 | 176 | 2. Now you're ready to start: 177 | ```bash 178 | yarn run start 179 | ``` 180 | 181 | The start command will create and deploy a development build, and serve your local files. 182 | 183 | After running the start command, navigate to your spreadsheet and open one of the menu items. It should now be serving your local files. When you make and save changes to your React app, your app will reload instantly within the Google Spreadsheet, and have access to any server-side functions! 184 | 185 | https://github.com/enuchi/React-Google-Apps-Script/assets/31550519/981604ac-bdea-489d-97fa-72e6d24ba6dd 186 | 187 |
188 | 189 | ### 🔍 Using React DevTools 190 | 191 | React DevTools is a tool that lets you inspect the React component hierarchies during development. 192 | 193 |
194 | Instructions for installing React DevTools 195 | 196 |
197 | 198 | You will need to use the "standalone" version of React DevTools since our React App is running in an iframe ([more details here](https://github.com/facebook/react/tree/master/packages/react-devtools#usage-with-react-dom)). 199 | 200 | 1. In your repo install the React DevTools package as a dev dependency: 201 | 202 | ```bash 203 | yarn add -D react-devtools 204 | ``` 205 | 206 | 2. In a new terminal window run `npx react-devtools` to launch the DevTools standalone app. 207 | 208 | 3. Add `` to the top of your `` in your React app, e.g. in the [index.html](https://github.com/enuchi/React-Google-Apps-Script/blob/e73e51e56e99903885ef8dd5525986f99038d8bf/src/client/dialog-demo-bootstrap/index.html) file in the sample Bootstrap app. 209 | 210 | 4. Deploy your app (`yarn run deploy:dev`) and you should see DevTools tool running and displaying your app hierarchy. 211 | 212 | 213 | 214 | 5. Don't forget to remove the ``. 279 | 280 | If set up properly, this will load packages from the CDN in production and will reduce your overall bundle size. 281 | 282 | Make sure that you update the script tag with the same version of the package you are installing with yarn, so that you are using the same version in development and production. 283 | 284 | ### Styles 285 | 286 | By default this project supports global CSS stylesheets. Make sure to import your stylesheet in your entrypoint file [index.js](./src/client/dialog-demo/index.js): 287 | 288 | ```javascript 289 | import './styles.css'; 290 | ``` 291 | 292 | Many external component libraries require a css stylesheet in order to work properly. You can import stylesheets in the HTML template, [as shown here with the Bootstrap stylesheet](./src/client/dialog-demo-bootstrap/index.html). 293 | 294 | ### Modifying scopes 295 | 296 | The included app only requires access to Google Spreadsheets and to loading dialog windows. If you make changes to the app's requirements, for instance, if you modify this project to work with Google Forms or Docs, make sure to edit the oauthScopes in the [appscript.json file](./appsscript.json). 297 | 298 | See https://developers.google.com/apps-script/manifest for information on the `appsscript.json` structure. 299 | 300 | ### Calling server-side Google Apps Script functions 301 | 302 | This project uses the [gas-client](https://github.com/enuchi/gas-client) package to more easily call server-side functions using promises. 303 | 304 | ```js 305 | // Google's client-side google.script.run utility requires calling server-side functions like this: 306 | google.script.run 307 | .withSuccessHandler((response) => doSomething(response)) 308 | .withFailureHandler((err) => handleError(err)) 309 | .addSheet(sheetTitle); 310 | 311 | // Using gas-client we can use more familiar promises style like this: 312 | import { GASClient } from 'gas-client'; 313 | const { serverFunctions, scriptHostFunctions } = new GASClient({}); 314 | 315 | // We now have access to all our server functions, which return promises! 316 | serverFunctions 317 | .addSheet(sheetTitle) 318 | .then((response) => doSomething(response)) 319 | .catch((err) => handleError(err)); 320 | 321 | // Or with async/await: 322 | async () => { 323 | try { 324 | const response = await serverFunctions.addSheet(sheetTitle); 325 | doSomething(response); 326 | } catch (err) { 327 | handleError(err); 328 | } 329 | }; 330 | 331 | // Use scriptHostFunctions to control dialogs 332 | scriptHostFunctions.close(); // close a dialog or sidebar 333 | scriptHostFunctions.setWidth(400); // set dialog width to 400px 334 | scriptHostFunctions.setHeight(800); // set dialog height to 800px 335 | 336 | ``` 337 | 338 | In development, `gas-client` will allow you to call server-side functions from your local environment. In production, it will use Google's underlying `google.script.run` utility. 339 | 340 | ### Autocomplete 341 | 342 | This project includes support for autocompletion and complete type definitions for Google Apps Script methods. 343 | 344 | ![autocomplete support](https://i.imgur.com/E7FLeTX.gif 'autocomplete') 345 | 346 | All available methods from the Google Apps Script API are shown with full definitions and links to the official documentation, plus information on argument, return type and sample code. 347 | 348 |
349 | 350 | ## ✍️ Authors 351 | 352 | - [@enuchi](https://github.com/enuchi) - Creator and maintainer 353 | 354 | See the list of [contributors](https://github.com/enuchi/React-Google-Apps-Script/contributors) who participated in this project. 355 | 356 |
357 | 358 | ## 🎉 Acknowledgements 359 | 360 | Part of this project has been adapted from [apps-script-starter](https://github.com/labnol/apps-script-starter), a great starter project for server-side projects ([license here](https://github.com/labnol/apps-script-starter/blob/master/LICENSE)). 361 | -------------------------------------------------------------------------------- /appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": {}, 4 | "exceptionLogging": "STACKDRIVER", 5 | "oauthScopes": [ 6 | "https://www.googleapis.com/auth/script.container.ui", 7 | "https://www.googleapis.com/auth/spreadsheets" 8 | ], 9 | "runtimeVersion": "V8" 10 | } 11 | -------------------------------------------------------------------------------- /dev/dev-server-wrapper.html: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | 20 | 21 | Dev Server 22 | 23 | 24 | 32 | 79 | 80 | 81 |
82 | 86 |
87 | 88 | 89 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | globalSetup: './test/global-setup.js', 3 | globalTeardown: './test/global-teardown.js', 4 | testEnvironment: './test/puppeteer-environment.js', 5 | reporters: ['default', '/test/utils/image-reporter.js'], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-google-apps-script", 3 | "version": "3.1.0", 4 | "type": "module", 5 | "description": "Starter project for using React with Google Apps Script", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/enuchi/React-Google-Apps-Script.git" 9 | }, 10 | "scripts": { 11 | "dev": "vite", 12 | "lint": "eslint .", 13 | "test:integration": "jest --forceExit test/local-development.test", 14 | "test:integration:extended": "cross-env IS_EXTENDED=true jest --forceExit test/local-development.test", 15 | "test:integration:extended:ci-reporter": "cross-env IS_EXTENDED=true jest --forceExit || node test/utils/image-reporter-standalone.js", 16 | "login": "clasp login", 17 | "setup": "rimraf .clasp.json && mkdirp dist && clasp create --type sheets --title \"My React Project\" --rootDir ./dist && mv ./dist/.clasp.json ./.clasp.json && rimraf dist", 18 | "open": "clasp open --addon", 19 | "push": "clasp push", 20 | "setup:https": "mkdirp certs && mkcert -key-file ./certs/key.pem -cert-file ./certs/cert.pem localhost 127.0.0.1", 21 | "build:dev": "tsc && vite build --mode development", 22 | "build": "tsc && vite build --mode production", 23 | "deploy:dev": "yarn build:dev && yarn push", 24 | "deploy": "yarn build && yarn push", 25 | "start": "yarn deploy:dev && yarn dev" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "google", 30 | "apps", 31 | "script", 32 | "sheets" 33 | ], 34 | "author": "Elisha Nuchi", 35 | "license": "MIT", 36 | "engines": { 37 | "node": ">=10.0.0", 38 | "npm": ">=6.0.0" 39 | }, 40 | "dependencies": { 41 | "@emotion/react": "^11.10.6", 42 | "@emotion/styled": "^11.10.6", 43 | "@mui/material": "^5.11.11", 44 | "gas-client": "^1.2.0", 45 | "prop-types": "^15.8.1", 46 | "react": "^18.2.0", 47 | "react-bootstrap": "^2.4.0", 48 | "react-dom": "^18.2.0", 49 | "react-transition-group": "^4.4.2" 50 | }, 51 | "devDependencies": { 52 | "@babel/preset-env": "^7.24.6", 53 | "@google/clasp": "^2.4.2", 54 | "@types/expect-puppeteer": "^5.0.0", 55 | "@types/jest-environment-puppeteer": "^5.0.2", 56 | "@types/node": "^20.11.30", 57 | "@types/puppeteer": "^5.4.6", 58 | "@types/react": "^18.2.66", 59 | "@types/react-dom": "^18.2.22", 60 | "@typescript-eslint/eslint-plugin": "^7.2.0", 61 | "@typescript-eslint/parser": "^7.2.0", 62 | "@vitejs/plugin-react-swc": "^3.5.0", 63 | "autoprefixer": "^10.4.19", 64 | "aws-sdk": "^2.1106.0", 65 | "cross-env": "^7.0.3", 66 | "dotenv": "^16.4.5", 67 | "eslint": "^8.57.0", 68 | "eslint-config-airbnb-base": "^15.0.0", 69 | "eslint-config-prettier": "^8.5.0", 70 | "eslint-plugin-googleappsscript": "^1.0.4", 71 | "eslint-plugin-import": "^2.29.1", 72 | "eslint-plugin-jest": "^26.5.3", 73 | "eslint-plugin-prettier": "^4.0.0", 74 | "eslint-plugin-react-hooks": "^4.6.0", 75 | "eslint-plugin-react-refresh": "^0.4.6", 76 | "gas-types-detailed": "^1.1.2", 77 | "jest": "^28.1.1", 78 | "jest-environment-node": "^28.1.1", 79 | "jest-image-snapshot": "^5.1.0", 80 | "postcss": "^8.4.38", 81 | "postcss-preset-env": "^9.5.4", 82 | "prettier": "^2.7.0", 83 | "puppeteer": "^14.3.0", 84 | "puppeteer-extra": "^3.2.3", 85 | "puppeteer-extra-plugin-stealth": "^2.9.0", 86 | "rollup": "^4.18.0", 87 | "tailwindcss": "^3.4.3", 88 | "typescript": "^5.2.2", 89 | "vite": "^5.2.0", 90 | "vite-plugin-singlefile": "^2.0.1", 91 | "vite-plugin-static-copy": "^1.0.1" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /scripts/generate-cert.ps1: -------------------------------------------------------------------------------- 1 | # reference code: https://stackoverflow.com/questions/70226493/webpack-dev-server-and-https-this-site-can-t-be-reached 2 | # reference issue: https://github.com/FiloSottile/mkcert/issues/286 3 | 4 | $dnsName = "localhost" 5 | $expiry = [DateTime]::Now.AddYears(1); 6 | $repoRoot = Split-Path $PSScriptRoot 7 | $certsDir = "$repoRoot\certs"; 8 | $fileName = "cert.pfx"; 9 | $passwordText = "abc123"; 10 | $name = "ReactApp"; 11 | 12 | Write-Host "Creating cert directly into CurrentUser\My store" 13 | 14 | $certificate = New-SelfSignedCertificate ` 15 | -KeyExportPolicy 'Exportable' ` 16 | -CertStoreLocation Cert:\CurrentUser\My ` 17 | -Subject $name ` 18 | -FriendlyName $name ` 19 | -DnsName $dnsName ` 20 | -NotAfter $expiry 21 | 22 | $certFile = Join-Path $certsDir $fileName 23 | 24 | Write-Host "Exporting certificate to $certFile" 25 | 26 | $password = ConvertTo-SecureString ` 27 | -String $passwordText ` 28 | -Force -AsPlainText 29 | 30 | Export-PfxCertificate ` 31 | -Cert $certificate ` 32 | -FilePath $certFile ` 33 | -Password $password | Out-Null 34 | 35 | Write-Host "Importing $certFile to CurrentUser\Root store for immediate system wide trust" 36 | 37 | Import-PfxCertificate ` 38 | -FilePath $certFile ` 39 | -CertStoreLocation Cert:\LocalMachine\Root ` 40 | -Password $password | Out-Null -------------------------------------------------------------------------------- /src/client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "airbnb-base", 5 | "plugin:prettier/recommended", 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/recommended", 8 | "plugin:react-hooks/recommended" 9 | ], 10 | "plugins": ["react-refresh", "prettier"], 11 | "rules": { 12 | "prettier/prettier": "error", 13 | "camelcase": "warn", 14 | "import/prefer-default-export": "warn", 15 | "import/no-extraneous-dependencies": "warn", 16 | "prefer-object-spread": "warn", 17 | "spaced-comment": "off", 18 | "react-refresh/only-export-components": [ 19 | "warn", 20 | { "allowConstantExport": true } 21 | ], 22 | "import/extensions": [ 23 | "error", 24 | "ignorePackages", 25 | { 26 | "js": "never", 27 | "jsx": "never", 28 | "ts": "never", 29 | "tsx": "never" 30 | } 31 | ] 32 | }, 33 | "settings": { 34 | "react": { 35 | "version": "detect" 36 | }, 37 | "import/resolver": { 38 | "node": { 39 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 40 | } 41 | } 42 | }, 43 | "env": { "browser": true, "es2020": true }, 44 | "parser": "@typescript-eslint/parser" 45 | } 46 | -------------------------------------------------------------------------------- /src/client/README.md: -------------------------------------------------------------------------------- 1 | # Client (our React code) 2 | 3 | This directory is where we store the source code for our client-side React apps. 4 | 5 | We have multiple directories in here because our app creates menu items that open multiple dialog windows. Each dialog opens a separate app, so each directory here represents its own distinct React app. Our webpack configuration will generate a separate bundle for each React app. 6 | 7 | ## Requirements 8 | 9 | Each React app will need: 10 | - an entrypoint, usually a file named `index.js`, that loads the app 11 | - an HTML file that acts as a template, in which the bundled React app is loaded 12 | 13 | You'll need to declare the following in [webpack.config.js](../../webpack.config.js): 14 | - **name**: just a name to print in the webpack console, e.g. 'CLIENT - Dialog Demo' 15 | - **entry**: the path to the entry point for the app, e.g. './src/client/dialog-demo/index.js' 16 | - **filename**: the name of the html file that is generated. The server code will reference this filename to load the app into a dialog window. E.g. 'dialog-demo' 17 | - **template**: the path to the HTML template for the app, e.g. './src/client/dialog-demo/index.html' 18 | 19 | 20 | ### Adding or removing an entrypoint 21 | Your app or use case may only require a single dialog or sidebar, or you may want to add more than are included in the sample app. 22 | 23 | To edit the entrypoints, you will need to: 24 | 25 | 1. Create or remove the entrypoint directories in the client source code. For instance, you can remove `./src/client/sidebar-about-page` altogether, or copy it and modify the source code. See above [requirements](#requirements). 26 | 27 | 2. Modify the server-side code to load the correct menu items and expose the correct public functions: 28 | - [ui file](../server/ui.js) 29 | - [index file](../server/index.js) 30 | 31 | 3. Modify the `clientEntrypoints` config in the [webpack config file](../../webpack.config.js). 32 | -------------------------------------------------------------------------------- /src/client/dialog-demo-bootstrap/components/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ChangeEvent, FormEvent } from 'react'; 2 | import { Form, Button, Col, Row } from 'react-bootstrap'; 3 | 4 | interface FormInputProps { 5 | submitNewSheet: (sheetName: string) => { 6 | name: string; 7 | index: number; 8 | isActive: boolean; 9 | }; 10 | } 11 | 12 | const FormInput = ({ submitNewSheet }: FormInputProps) => { 13 | const [newSheetName, setNewSheetName] = useState(''); 14 | 15 | const handleChange = (event: ChangeEvent) => 16 | setNewSheetName(event.target.value); 17 | 18 | const handleSubmit = (event: FormEvent) => { 19 | event.preventDefault(); 20 | if (newSheetName.length === 0) return; 21 | submitNewSheet(newSheetName); 22 | setNewSheetName(''); 23 | }; 24 | 25 | return ( 26 |
27 | 28 | Add a new sheet 29 | 30 | 31 | 37 | 38 | 39 | 42 | 43 | 44 | 45 | Enter the name for your new sheet. 46 | 47 | 48 | This component is written in typescript! 49 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | export default FormInput; 56 | -------------------------------------------------------------------------------- /src/client/dialog-demo-bootstrap/components/SheetEditor.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { TransitionGroup, CSSTransition } from 'react-transition-group'; 3 | import { Button, ListGroup } from 'react-bootstrap'; 4 | import FormInput from './FormInput'; 5 | 6 | // This is a wrapper for google.script.run that lets us use promises. 7 | import { 8 | serverFunctions, 9 | scriptHostFunctions, 10 | } from '../../utils/serverFunctions'; 11 | 12 | const SheetEditor = () => { 13 | const [names, setNames] = useState([]); 14 | const [isExpanded, setIsExpanded] = useState(false); 15 | 16 | useEffect(() => { 17 | serverFunctions.getSheetsData().then(setNames).catch(alert); 18 | }, []); 19 | 20 | const deleteSheet = (sheetIndex) => { 21 | serverFunctions.deleteSheet(sheetIndex).then(setNames).catch(alert); 22 | }; 23 | 24 | const setActiveSheet = (sheetName) => { 25 | serverFunctions.setActiveSheet(sheetName).then(setNames).catch(alert); 26 | }; 27 | 28 | const submitNewSheet = async (newSheetName) => { 29 | try { 30 | const response = await serverFunctions.addSheet(newSheetName); 31 | setNames(response); 32 | } catch (error) { 33 | // eslint-disable-next-line no-alert 34 | alert(error); 35 | } 36 | }; 37 | 38 | return ( 39 |
40 |

41 | ☀️ Bootstrap demo! ☀️ 42 |

43 |

44 | This is a sample app that uses the react-bootstrap library 45 | to help us build a simple React app. Enter a name for a new sheet, hit 46 | enter and the new sheet will be created. Click the red{' '} 47 | × next to the sheet name to 48 | delete it. 49 |

50 | 51 | 52 | 53 | {names.length > 0 && 54 | names.map((name) => ( 55 | 60 | 64 | 72 | 79 | 80 | 81 | ))} 82 | 83 | 84 | {names.length > 0 && ( 85 |
86 | {!isExpanded ? ( 87 | 98 | ) : null} 99 | 105 |
106 | )} 107 |
108 | ); 109 | }; 110 | 111 | export default SheetEditor; 112 | -------------------------------------------------------------------------------- /src/client/dialog-demo-bootstrap/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 18 | 22 | 26 | 30 | 31 | 32 | 36 | 42 | 43 | 44 |
45 | 46 | 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /src/client/dialog-demo-bootstrap/index.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import SheetEditor from './components/SheetEditor'; 3 | 4 | import './styles.css'; 5 | 6 | const container = document.getElementById('index'); 7 | const root = createRoot(container); 8 | root.render(); 9 | -------------------------------------------------------------------------------- /src/client/dialog-demo-bootstrap/styles.css: -------------------------------------------------------------------------------- 1 | /* needed to make consistent test snapshots across OSs */ 2 | html { 3 | height: 100%; 4 | } 5 | body { 6 | font-family: Roboto !important; 7 | height: 100%; 8 | } 9 | 10 | /* 11 | CSSTransitionGroup styling 12 | */ 13 | .sheetNames-enter { 14 | opacity: 0; 15 | } 16 | 17 | .sheetNames-enter.sheetNames-enter-active { 18 | opacity: 1; 19 | transition: opacity 300ms ease-in; 20 | } 21 | 22 | .sheetNames-exit { 23 | opacity: 1; 24 | } 25 | 26 | .sheetNames-exit.sheetNames-exit-active { 27 | opacity: 0; 28 | transition: opacity 300ms ease-in; 29 | } 30 | 31 | .sheetNames-appear { 32 | opacity: 0; 33 | } 34 | 35 | .sheetNames-appear.sheetNames-appear-active { 36 | opacity: 1; 37 | transition: opacity 0.5ms ease-in; 38 | } 39 | -------------------------------------------------------------------------------- /src/client/dialog-demo-mui/components/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ChangeEvent, FormEvent } from 'react'; 2 | import { Button, Grid, TextField, Typography } from '@mui/material'; 3 | 4 | interface FormInputProps { 5 | submitNewSheet: (sheetName: string) => { 6 | name: string; 7 | index: number; 8 | isActive: boolean; 9 | }; 10 | } 11 | 12 | const FormInput = ({ submitNewSheet }: FormInputProps) => { 13 | const [newSheetName, setNewSheetName] = useState(''); 14 | 15 | const handleChange = (event: ChangeEvent) => 16 | setNewSheetName(event.target.value); 17 | 18 | const handleSubmit = (event: FormEvent) => { 19 | event.preventDefault(); 20 | if (newSheetName.length === 0) return; 21 | submitNewSheet(newSheetName); 22 | setNewSheetName(''); 23 | }; 24 | 25 | return ( 26 |
27 | 28 | 29 | 39 | 40 | 48 | 51 | 52 | 53 | 54 | Enter the name for your new sheet. 55 | 56 |
57 | 58 | This component is written in typescript! 59 | 60 |
61 | ); 62 | }; 63 | 64 | export default FormInput; 65 | -------------------------------------------------------------------------------- /src/client/dialog-demo-mui/components/SheetEditor.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Button, Typography } from '@mui/material'; 3 | 4 | import FormInput from './FormInput'; 5 | import SheetTable from './SheetTable'; 6 | 7 | // This is a wrapper for google.script.run that lets us use promises. 8 | import { serverFunctions } from '../../utils/serverFunctions'; 9 | 10 | const SheetEditor = () => { 11 | const [names, setNames] = useState([]); 12 | 13 | useEffect(() => { 14 | serverFunctions.getSheetsData().then(setNames).catch(alert); 15 | }, []); 16 | 17 | const deleteSheet = (sheetIndex) => { 18 | serverFunctions.deleteSheet(sheetIndex).then(setNames).catch(alert); 19 | }; 20 | 21 | const setActiveSheet = (sheetName) => { 22 | serverFunctions.setActiveSheet(sheetName).then(setNames).catch(alert); 23 | }; 24 | 25 | const submitNewSheet = async (newSheetName) => { 26 | try { 27 | const response = await serverFunctions.addSheet(newSheetName); 28 | setNames(response); 29 | } catch (error) { 30 | // eslint-disable-next-line no-alert 31 | alert(error); 32 | } 33 | }; 34 | 35 | return ( 36 |
37 | 38 | ☀️ MUI demo! ☀️ 39 | 40 | 41 | 42 | This is a sample app that uses the mui library to help us 43 | build a simple React app. Enter a name for a new sheet, hit enter and 44 | the new sheet will be created. Click the red button next to the sheet 45 | name to delete it. 46 | 47 | 48 | 49 | {names.length > 0 && ( 50 | { 52 | return { 53 | sheetName: name.name, 54 | goToButton: ( 55 | 62 | ), 63 | deleteButton: ( 64 | 71 | ), 72 | }; 73 | })} 74 | /> 75 | )} 76 |
77 | ); 78 | }; 79 | 80 | export default SheetEditor; 81 | -------------------------------------------------------------------------------- /src/client/dialog-demo-mui/components/SheetTable.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Table, 4 | TableBody, 5 | TableCell, 6 | TableContainer, 7 | TableHead, 8 | TableRow, 9 | } from '@mui/material'; 10 | 11 | type sheetRow = { 12 | sheetName: string; 13 | goToButton: React.ReactNode; 14 | deleteButton: React.ReactNode; 15 | }; 16 | 17 | export default function SheetTable({ rows }: { rows: Array }) { 18 | return ( 19 | 20 | 21 | 22 | 23 | Sheet Name 24 | Go To 25 | Delete 26 | 27 | 28 | 29 | {rows.map((row: sheetRow) => ( 30 | 34 | 35 | {row.sheetName} 36 | 37 | {row.goToButton} 38 | {row.deleteButton} 39 | 40 | ))} 41 | 42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/client/dialog-demo-mui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 18 | 22 | 26 | 27 | 28 |
29 | 30 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /src/client/dialog-demo-mui/index.jsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import SheetEditor from './components/SheetEditor'; 3 | 4 | import './styles.css'; 5 | 6 | ReactDOM.render(, document.getElementById('index')); 7 | -------------------------------------------------------------------------------- /src/client/dialog-demo-mui/styles.css: -------------------------------------------------------------------------------- 1 | /* needed to make consistent test snapshots across OSs */ 2 | body { 3 | font-family: Arial !important; 4 | } 5 | -------------------------------------------------------------------------------- /src/client/dialog-demo-tailwindcss/components/FormInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState, ChangeEvent, FormEvent } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const FormInput = ({ 5 | submitNewSheet, 6 | }: { 7 | submitNewSheet: (arg: string) => void; 8 | }) => { 9 | const [inputValue, setInputValue] = useState(''); 10 | 11 | const handleChange = (event: ChangeEvent) => 12 | setInputValue(event.target.value); 13 | 14 | const handleSubmit = (event: FormEvent) => { 15 | event.preventDefault(); 16 | if (inputValue.length === 0) return; 17 | 18 | submitNewSheet(inputValue); 19 | setInputValue(''); 20 | }; 21 | 22 | return ( 23 |
24 |
25 | 31 |
32 | 38 |
39 | ); 40 | }; 41 | 42 | export default FormInput; 43 | 44 | FormInput.propTypes = { 45 | submitNewSheet: PropTypes.func, 46 | }; 47 | -------------------------------------------------------------------------------- /src/client/dialog-demo-tailwindcss/components/SheetButton.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const SheetButton = ({ sheetDetails, deleteSheet, setActiveSheet }) => { 4 | const { index, name, isActive } = sheetDetails; 5 | 6 | return ( 7 |
14 | 20 | 40 |
41 | ); 42 | }; 43 | 44 | export default SheetButton; 45 | 46 | SheetButton.propTypes = { 47 | sheetDetails: PropTypes.shape({ 48 | index: PropTypes.number, 49 | name: PropTypes.string, 50 | isActive: PropTypes.bool, 51 | }), 52 | deleteSheet: PropTypes.func, 53 | setActiveSheet: PropTypes.func, 54 | }; 55 | -------------------------------------------------------------------------------- /src/client/dialog-demo-tailwindcss/components/SheetEditor.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { TransitionGroup, CSSTransition } from 'react-transition-group'; 3 | import FormInput from './FormInput'; 4 | import SheetButton from './SheetButton'; 5 | 6 | // This is a wrapper for google.script.run that lets us use promises. 7 | import { serverFunctions } from '../../utils/serverFunctions'; 8 | 9 | const SheetEditor = () => { 10 | const [names, setNames] = useState([]); 11 | 12 | useEffect(() => { 13 | // Call a server global function here and handle the response with .then() and .catch() 14 | serverFunctions.getSheetsData().then(setNames).catch(alert); 15 | }, []); 16 | 17 | const deleteSheet = (sheetIndex) => { 18 | serverFunctions.deleteSheet(sheetIndex).then(setNames).catch(alert); 19 | }; 20 | 21 | const setActiveSheet = (sheetName) => { 22 | serverFunctions.setActiveSheet(sheetName).then(setNames).catch(alert); 23 | }; 24 | 25 | // You can also use async/await notation for server calls with our server wrapper. 26 | // (This does the same thing as .then().catch() in the above handlers.) 27 | const submitNewSheet = async (newSheetName) => { 28 | try { 29 | const response = await serverFunctions.addSheet(newSheetName); 30 | setNames(response); 31 | } catch (error) { 32 | // eslint-disable-next-line no-alert 33 | alert(error); 34 | } 35 | }; 36 | 37 | return ( 38 |
39 |

40 | ☀️ React demo! ☀️ 41 |

42 |

43 | This is a sample page that demonstrates a simple React app. Enter a name 44 | for a new sheet, hit enter and the new sheet will be created. Click the 45 | red × next to the sheet name to delete it. 46 |

47 | 48 | 49 | {names.length > 0 && 50 | names.map((name) => ( 51 | 56 | 61 | 62 | ))} 63 | 64 |
65 | ); 66 | }; 67 | 68 | export default SheetEditor; 69 | -------------------------------------------------------------------------------- /src/client/dialog-demo-tailwindcss/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 18 | 22 | 26 | 30 | 31 | 32 |
33 | 34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /src/client/dialog-demo-tailwindcss/index.jsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import SheetEditor from './components/SheetEditor'; 3 | import './styles.css'; 4 | 5 | const App = () => { 6 | return ( 7 | <> 8 | 9 | 10 | ); 11 | }; 12 | 13 | ReactDOM.render(, document.getElementById('index')); 14 | -------------------------------------------------------------------------------- /src/client/dialog-demo-tailwindcss/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | /* 6 | CSSTransitionGroup styling 7 | */ 8 | .sheetNames-enter { 9 | opacity: 0; 10 | } 11 | 12 | .sheetNames-enter.sheetNames-enter-active { 13 | opacity: 1; 14 | transition: opacity 300ms ease-in; 15 | } 16 | 17 | .sheetNames-exit { 18 | opacity: 1; 19 | } 20 | 21 | .sheetNames-exit.sheetNames-exit-active { 22 | opacity: 0; 23 | transition: opacity 300ms ease-in; 24 | } 25 | 26 | .sheetNames-appear { 27 | opacity: 0; 28 | } 29 | 30 | .sheetNames-appear.sheetNames-appear-active { 31 | opacity: 1; 32 | transition: opacity 0.5ms ease-in; 33 | } 34 | -------------------------------------------------------------------------------- /src/client/dialog-demo/components/FormInput.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const FormInput = ({ submitNewSheet }) => { 5 | const [inputValue, setInputValue] = useState(''); 6 | 7 | const handleChange = (event) => setInputValue(event.target.value); 8 | 9 | const handleSubmit = (event) => { 10 | event.preventDefault(); 11 | if (inputValue.length === 0) return; 12 | 13 | submitNewSheet(inputValue); 14 | setInputValue(''); 15 | }; 16 | 17 | return ( 18 |
19 |
20 |
21 | Add a sheet 22 |
23 |
24 | 29 | 32 |
33 |
34 |
35 | ); 36 | }; 37 | 38 | export default FormInput; 39 | 40 | FormInput.propTypes = { 41 | submitNewSheet: PropTypes.func, 42 | }; 43 | -------------------------------------------------------------------------------- /src/client/dialog-demo/components/SheetButton.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const SheetButton = ({ sheetDetails, deleteSheet, setActiveSheet }) => { 4 | const { index, name, isActive } = sheetDetails; 5 | 6 | return ( 7 |
8 | 11 | 16 |
17 | ); 18 | }; 19 | 20 | export default SheetButton; 21 | 22 | SheetButton.propTypes = { 23 | sheetDetails: PropTypes.shape({ 24 | index: PropTypes.number, 25 | name: PropTypes.string, 26 | isActive: PropTypes.bool, 27 | }), 28 | deleteSheet: PropTypes.func, 29 | setActiveSheet: PropTypes.func, 30 | }; 31 | -------------------------------------------------------------------------------- /src/client/dialog-demo/components/SheetEditor.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { TransitionGroup, CSSTransition } from 'react-transition-group'; 3 | import FormInput from './FormInput'; 4 | import SheetButton from './SheetButton'; 5 | 6 | // This is a wrapper for google.script.run that lets us use promises. 7 | import { serverFunctions } from '../../utils/serverFunctions'; 8 | 9 | const SheetEditor = () => { 10 | const [names, setNames] = useState([]); 11 | 12 | useEffect(() => { 13 | // Call a server global function here and handle the response with .then() and .catch() 14 | serverFunctions.getSheetsData().then(setNames).catch(alert); 15 | }, []); 16 | 17 | const deleteSheet = (sheetIndex) => { 18 | serverFunctions.deleteSheet(sheetIndex).then(setNames).catch(alert); 19 | }; 20 | 21 | const setActiveSheet = (sheetName) => { 22 | serverFunctions.setActiveSheet(sheetName).then(setNames).catch(alert); 23 | }; 24 | 25 | // You can also use async/await notation for server calls with our server wrapper. 26 | // (This does the same thing as .then().catch() in the above handlers.) 27 | const submitNewSheet = async (newSheetName) => { 28 | try { 29 | const response = await serverFunctions.addSheet(newSheetName); 30 | setNames(response); 31 | } catch (error) { 32 | // eslint-disable-next-line no-alert 33 | alert(error); 34 | } 35 | }; 36 | 37 | return ( 38 |
39 |

40 | ☀️ React demo! ☀️ 41 |

42 |

43 | This is a sample page that demonstrates a simple React app. Enter a name 44 | for a new sheet, hit enter and the new sheet will be created. Click the 45 | red × next to the sheet name to delete it. 46 |

47 | 48 | 49 | {names.length > 0 && 50 | names.map((name) => ( 51 | 56 | 61 | 62 | ))} 63 | 64 |
65 | ); 66 | }; 67 | 68 | export default SheetEditor; 69 | -------------------------------------------------------------------------------- /src/client/dialog-demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 18 | 22 | 26 | 30 | 31 | 32 |
33 | 34 | 35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /src/client/dialog-demo/index.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import SheetEditor from './components/SheetEditor'; 3 | 4 | import './styles.css'; 5 | 6 | const container = document.getElementById('index'); 7 | const root = createRoot(container); 8 | root.render(); 9 | -------------------------------------------------------------------------------- /src/client/dialog-demo/styles.css: -------------------------------------------------------------------------------- 1 | input { 2 | font-family: 'Nunito', sans-serif; 3 | width: 150px; 4 | height: 28px; 5 | border: 1px solid #6b6b6b; 6 | border-radius: 4px; 7 | } 8 | 9 | input:focus { 10 | outline: 0 none; 11 | } 12 | 13 | button { 14 | cursor: pointer; 15 | font-family: 'Nunito', sans-serif; 16 | } 17 | 18 | button.submit { 19 | background: rgb(234, 234, 255); 20 | margin: 0px 10px; 21 | padding-top: 6px; 22 | padding-bottom: 6px; 23 | border: 1px solid #6b6b6b; 24 | border-radius: 4px; 25 | transition: background 300ms ease-in; 26 | } 27 | 28 | button.submit:hover { 29 | background: rgb(234, 234, 255, 0.4); 30 | } 31 | 32 | button.delete { 33 | width: 17px; 34 | -webkit-transition-duration: 0.4s; /* Safari */ 35 | background-color: #ffffff; 36 | border: 1px solid #f44336; 37 | color: #676767; 38 | display: inline-block; 39 | font-size: 11px; 40 | height: 17px; 41 | line-height: 15px; 42 | margin-left: 3px; 43 | margin-right: 11px; 44 | padding: 1px; 45 | text-transform: uppercase; 46 | transition-duration: 0.6s; 47 | vertical-align: middle; 48 | } 49 | 50 | button.delete:hover { 51 | background-color: #f44336; 52 | color: #ffffff; 53 | } 54 | 55 | button.delete:active { 56 | background-color: #f44336; 57 | 58 | transform: translateY(1px); 59 | transition-duration: 0.3s; 60 | } 61 | 62 | button.delete:focus { 63 | outline: none; 64 | } 65 | 66 | .formBlock { 67 | display: -webkit-box; 68 | font-family: 'Nunito', sans-serif; 69 | font-weight: 700; 70 | padding-bottom: 30px; 71 | } 72 | 73 | .sheetLine { 74 | padding: 10px 0px; 75 | border-bottom: 1px solid #eaeaea; 76 | } 77 | 78 | button.basicButton { 79 | padding: 0; 80 | border: none; 81 | outline: none; 82 | font: inherit; 83 | color: inherit; 84 | background: none; 85 | } 86 | 87 | span.sheetNameText { 88 | cursor: pointer; 89 | font-size: 14px; 90 | font-family: 'Nunito', sans-serif; 91 | border-bottom-color: rgba(51, 130, 54, 0); 92 | border-bottom-width: 3px solid none; 93 | border-bottom-style: solid; 94 | transition: border-bottom-color 300ms ease-in; 95 | } 96 | 97 | span.sheetNameText:hover { 98 | border-bottom-color: rgba(51, 130, 54, 0.2); 99 | } 100 | 101 | span.sheetNameText:active { 102 | border-bottom-color: rgba(51, 130, 54, 0.2); 103 | } 104 | 105 | span.sheetNameText.active-sheet { 106 | border-bottom-color: rgb(51, 130, 54); 107 | } 108 | 109 | /* 110 | CSSTransitionGroup styling 111 | */ 112 | .sheetNames-enter { 113 | opacity: 0; 114 | } 115 | 116 | .sheetNames-enter.sheetNames-enter-active { 117 | opacity: 1; 118 | transition: opacity 300ms ease-in; 119 | } 120 | 121 | .sheetNames-exit { 122 | opacity: 1; 123 | } 124 | 125 | .sheetNames-exit.sheetNames-exit-active { 126 | opacity: 0; 127 | transition: opacity 300ms ease-in; 128 | } 129 | 130 | .sheetNames-appear { 131 | opacity: 0; 132 | } 133 | 134 | .sheetNames-appear.sheetNames-appear-active { 135 | opacity: 1; 136 | transition: opacity 0.5ms ease-in; 137 | } 138 | -------------------------------------------------------------------------------- /src/client/sidebar-about-page/components/About.jsx: -------------------------------------------------------------------------------- 1 | const About = () => ( 2 |
3 |

4 | ☀️ React app inside a sidebar! ☀️ 5 |

6 |

7 | This is a very simple page demonstrating how to build a React app inside a 8 | sidebar. 9 |

10 |

11 | Visit the Github repo for more information on how to use this project. 12 |

13 |

- Elisha Nuchi

14 | 19 | React + Google Apps Script 20 | 21 |
22 | ); 23 | 24 | export default About; 25 | -------------------------------------------------------------------------------- /src/client/sidebar-about-page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 | -------------------------------------------------------------------------------- /src/client/sidebar-about-page/index.jsx: -------------------------------------------------------------------------------- 1 | import { createRoot } from 'react-dom/client'; 2 | import About from './components/About'; 3 | 4 | const container = document.getElementById('index'); 5 | const root = createRoot(container); 6 | root.render(); 7 | -------------------------------------------------------------------------------- /src/client/utils/serverFunctions.ts: -------------------------------------------------------------------------------- 1 | import { GASClient } from 'gas-client'; 2 | import * as publicServerFunctions from '../../server'; 3 | 4 | const { serverFunctions, scriptHostFunctions } = new GASClient< 5 | typeof publicServerFunctions 6 | >({ 7 | // this is necessary for local development but will be ignored in production 8 | allowedDevelopmentDomains: (origin) => 9 | /https:\/\/.*\.googleusercontent\.com$/.test(origin), 10 | }); 11 | 12 | export { serverFunctions, scriptHostFunctions }; 13 | -------------------------------------------------------------------------------- /src/server/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "airbnb-base", 6 | "plugin:prettier/recommended", 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "plugins": ["prettier", "googleappsscript"], 11 | "env": { 12 | "googleappsscript/googleappsscript": true 13 | }, 14 | "rules": { 15 | "prettier/prettier": "error", 16 | "camelcase": "warn", 17 | "import/prefer-default-export": "warn", 18 | "import/no-extraneous-dependencies": "warn", 19 | "prefer-object-spread": "warn", 20 | "import/extensions": [ 21 | "error", 22 | "ignorePackages", 23 | { 24 | "js": "never", 25 | "ts": "never" 26 | } 27 | ] 28 | }, 29 | "settings": { 30 | "import/resolver": { 31 | "node": { 32 | "extensions": [".js", ".ts"] 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/server/README.md: -------------------------------------------------------------------------------- 1 | # Server (our Google Apps Script code) 2 | 3 | This directory is where we store the source code for our Google Apps Script code, which runs on Google's servers. 4 | 5 | ## Requirements 6 | 7 | The server code will need: 8 | - an entrypoint, usually a file named `index.js`, that loads the app. 9 | - the entry point will need to declare any public functions by attaching them to the "`global`" object, like this: 10 | ```javascript 11 | global.onOpen = someFunction; 12 | ``` 13 | 14 | See the [ui.js](./ui.js) file for how to open menu items and set up the development settings properly. 15 | 16 | ## Build 17 | 18 | Server-side code here will be compiled using settings that are compatible with the V8 or Rhino runtime (https://developers.google.com/apps-script/guides/v8-runtime). Update the [appsscript.json](../../appsscript.json) file as needed to switch runtimes. 19 | 20 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | onOpen, 3 | openDialog, 4 | openDialogBootstrap, 5 | openDialogMUI, 6 | openDialogTailwindCSS, 7 | openAboutSidebar, 8 | } from './ui'; 9 | 10 | import { getSheetsData, addSheet, deleteSheet, setActiveSheet } from './sheets'; 11 | 12 | // Public functions must be exported as named exports 13 | export { 14 | onOpen, 15 | openDialog, 16 | openDialogBootstrap, 17 | openDialogMUI, 18 | openDialogTailwindCSS, 19 | openAboutSidebar, 20 | getSheetsData, 21 | addSheet, 22 | deleteSheet, 23 | setActiveSheet, 24 | }; 25 | -------------------------------------------------------------------------------- /src/server/sheets.ts: -------------------------------------------------------------------------------- 1 | const getSheets = () => SpreadsheetApp.getActive().getSheets(); 2 | 3 | const getActiveSheetName = () => SpreadsheetApp.getActive().getSheetName(); 4 | 5 | export const getSheetsData = () => { 6 | const activeSheetName = getActiveSheetName(); 7 | return getSheets().map((sheet, index) => { 8 | const name = sheet.getName(); 9 | return { 10 | name, 11 | index, 12 | isActive: name === activeSheetName, 13 | }; 14 | }); 15 | }; 16 | 17 | export const addSheet = (sheetTitle: string) => { 18 | SpreadsheetApp.getActive().insertSheet(sheetTitle); 19 | return getSheetsData(); 20 | }; 21 | 22 | export const deleteSheet = (sheetIndex: number) => { 23 | const sheets = getSheets(); 24 | SpreadsheetApp.getActive().deleteSheet(sheets[sheetIndex]); 25 | return getSheetsData(); 26 | }; 27 | 28 | export const setActiveSheet = (sheetName: string) => { 29 | SpreadsheetApp.getActive().getSheetByName(sheetName).activate(); 30 | return getSheetsData(); 31 | }; 32 | -------------------------------------------------------------------------------- /src/server/ui.js: -------------------------------------------------------------------------------- 1 | export const onOpen = () => { 2 | const menu = SpreadsheetApp.getUi() 3 | .createMenu('My Sample React Project') // edit me! 4 | .addItem('Sheet Editor', 'openDialog') 5 | .addItem('Sheet Editor (Bootstrap)', 'openDialogBootstrap') 6 | .addItem('Sheet Editor (MUI)', 'openDialogMUI') 7 | .addItem('Sheet Editor (Tailwind CSS)', 'openDialogTailwindCSS') 8 | .addItem('About me', 'openAboutSidebar'); 9 | 10 | menu.addToUi(); 11 | }; 12 | 13 | export const openDialog = () => { 14 | const html = HtmlService.createHtmlOutputFromFile('dialog-demo') 15 | .setWidth(600) 16 | .setHeight(600); 17 | SpreadsheetApp.getUi().showModalDialog(html, 'Sheet Editor'); 18 | }; 19 | 20 | export const openDialogBootstrap = () => { 21 | const html = HtmlService.createHtmlOutputFromFile('dialog-demo-bootstrap') 22 | .setWidth(600) 23 | .setHeight(600); 24 | SpreadsheetApp.getUi().showModalDialog(html, 'Sheet Editor (Bootstrap)'); 25 | }; 26 | 27 | export const openDialogMUI = () => { 28 | const html = HtmlService.createHtmlOutputFromFile('dialog-demo-mui') 29 | .setWidth(600) 30 | .setHeight(600); 31 | SpreadsheetApp.getUi().showModalDialog(html, 'Sheet Editor (MUI)'); 32 | }; 33 | 34 | export const openDialogTailwindCSS = () => { 35 | const html = HtmlService.createHtmlOutputFromFile('dialog-demo-tailwindcss') 36 | .setWidth(600) 37 | .setHeight(600); 38 | SpreadsheetApp.getUi().showModalDialog(html, 'Sheet Editor (Tailwind CSS)'); 39 | }; 40 | 41 | export const openAboutSidebar = () => { 42 | const html = HtmlService.createHtmlOutputFromFile('sidebar-about-page'); 43 | SpreadsheetApp.getUi().showSidebar(html); 44 | }; 45 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | theme: { 3 | extend: {}, 4 | }, 5 | variants: { 6 | extend: {}, 7 | }, 8 | content: ['./src/client/**/*.{js,jsx,ts,tsx}'], 9 | }; 10 | -------------------------------------------------------------------------------- /test/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } -------------------------------------------------------------------------------- /test/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "extends": [ 5 | "airbnb-base", 6 | "plugin:prettier/recommended", 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:jest/recommended" 10 | ], 11 | "plugins": ["prettier"], 12 | "env": { 13 | "browser": true, 14 | "es6": true, 15 | "jest": true 16 | }, 17 | "globals": { 18 | "page": true, 19 | "browser": true 20 | }, 21 | "parserOptions": { 22 | "ecmaVersion": 9, 23 | "sourceType": "module", 24 | "ecmaFeatures": { 25 | "jsx": true 26 | } 27 | }, 28 | "rules": { 29 | "jest/no-done-callback": "off", 30 | "prettier/prettier": "error", 31 | "camelcase": "warn", 32 | "import/prefer-default-export": "off", 33 | "import/no-extraneous-dependencies": "warn", 34 | "import/extensions": "off", 35 | "jest/expect-expect": "off", 36 | "no-new": "off", 37 | "no-underscore-dangle": "off" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Running tests 2 | 3 | Integration tests here are used to ensure the existing local development setup works across different platforms, and that new features do not break the functionality of the local development environment. 4 | 5 | The "extended" local development integration test builds and deploys the local development setup needed to use hot reload functionality. It deploys the local setup to a spreadsheet inside a test Google account, runs a local development server, and checks that changes to the local files are reflected inside the spreadsheet's add-on, similar to how local development is intended to be used. 6 | 7 | Jest is used to run the tests and compare images of the changes, and Puppeteer is used to control the browser. A test account and test spreadsheet need to be created and credentials need to be passed in through environment variables in order for this "extended" version to work. Example `.env` file variables are listed below. Since secret values cannot be used in PRs opened from forks, a "basic" integration test just loads the local development setup locally, instead of deploying to Google Sheets. 8 | 9 | ## Extended integration test 10 | 11 | In order to test the extended integration test locally you will need to create or use a test Google account. It's recommended you create an account solely for this purpose rather than using an existing account. You'll need to create a new project and include the following variables in the `.env` file in the root: 12 | 13 | ```bash 14 | SHEET_URL = https://docs.google.com/spreadsheets/d/****************/edit#gid=0 15 | EMAIL = myisolatedtestaccount@gmail.com 16 | PASSWORD = testaccountpassword 17 | TEST_RECOVERY_EMAIL = myisolatedtestaccountsrecoveryemail@gmail.com 18 | 19 | # For image reporting only 20 | S3_BUCKET_NAME = my-bucket-name 21 | AWS_ACCESS_KEY_ID = A35D************ 22 | AWS_SECRET_ACCESS_KEY = slkf***********/234jdh********** 23 | ``` 24 | 25 | Make sure that you have followed the instructions to install local certs (see main README). The test will use Puppeteer to open the spreadsheet URL and log in to your account using your email and password provided. In case it doesn't recognize you it will ask for this account's recovery email -- you'll need to include this in the `.env` file as well. 26 | 27 | ### Running the extended tests in the pipeline 28 | 29 | To run the extended integration test in a pipeline using Github Actions, reference the .github/workflows directory. You'll need to add some additional environment variables through the repo secrets settings in order to get the full flow working: 30 | 31 | DOT_CLASPRC - the `.clasprs.json` file from user directory containing clasp login credentials 32 | DOT_CLASP - the `.clasp.json` file from this repo matching the test spreadsheet 33 | TEST_ACCOUNT_EMAIL, TEST_RECOVERY_EMAIL, TEST_ACCOUNT_PASSWORD, TEST_SPREADSHEET_URL - see above variables 34 | 35 | # Additional notes 36 | 37 | ### Stealth login 38 | `puppeteer-extra` and `puppeteer-extra-plugin-stealth` are used to help log in to a Google account in the pipeline for the "extended" integration test. They are not easily compatible with the `jest-puppeteer` package, so a custom integration is used that is documented on `jest`'s site [here](jestjs.io/docs/puppeteer#custom-example-without-jest-puppeteer-preset). 39 | 40 | ### Generating certs in the pipeline 41 | There are permissions issues running `mkcert -install` in Windows runners, so a powershell script in `./generate-cert.ps1` is used to generate the certs and enable https. 42 | 43 | ### Jest open handles 44 | There are issues with `jest` keeping handles open after all tests finish on Windows runners, so `jest --forceExit` is used to resolve them, although this flag may not be needed on certain platforms (like Mac OS). 45 | 46 | ### Jest Image Snapshots 47 | `jest-image-snapshot` is used to compare browser snapshots and detect discrepancies or regressions. Thresholds have been adjusted to allow for some variation across different platforms due to differences in font rendering and color profiles. 48 | 49 | If a snapshot fails, a diff image is created in the __diff_output__ directory, showing the comparison. If it fails in the pipeline it may be hard to see this diff. An Image Reporter class is used to upload diff images to an S3 bucket from the pipeline. This can be modified to use any image store. If used, make sure it is enabled in `jest.config.js` and that the necessary environment variables (AWS keys, bucket name, etc.) are added to `.env` file (see above section). -------------------------------------------------------------------------------- /test/__image_snapshots__/local-development-test-js-local-setup-extended-should-load-bootstrap-example-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enuchi/React-Google-Apps-Script/248a90023c14f35ca94b324a27869cbaba3b58f0/test/__image_snapshots__/local-development-test-js-local-setup-extended-should-load-bootstrap-example-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/local-development-test-js-local-setup-extended-should-modify-bootstrap-title-example-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enuchi/React-Google-Apps-Script/248a90023c14f35ca94b324a27869cbaba3b58f0/test/__image_snapshots__/local-development-test-js-local-setup-extended-should-modify-bootstrap-title-example-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/local-development-test-js-local-setup-extended-should-modify-bootstrap-title-example-back-to-original-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enuchi/React-Google-Apps-Script/248a90023c14f35ca94b324a27869cbaba3b58f0/test/__image_snapshots__/local-development-test-js-local-setup-extended-should-modify-bootstrap-title-example-back-to-original-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/local-development-test-js-local-setup-should-load-bootstrap-example-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enuchi/React-Google-Apps-Script/248a90023c14f35ca94b324a27869cbaba3b58f0/test/__image_snapshots__/local-development-test-js-local-setup-should-load-bootstrap-example-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/local-development-test-js-local-setup-should-modify-bootstrap-title-example-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enuchi/React-Google-Apps-Script/248a90023c14f35ca94b324a27869cbaba3b58f0/test/__image_snapshots__/local-development-test-js-local-setup-should-modify-bootstrap-title-example-1-snap.png -------------------------------------------------------------------------------- /test/__image_snapshots__/local-development-test-js-local-setup-should-modify-bootstrap-title-example-back-to-original-1-snap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/enuchi/React-Google-Apps-Script/248a90023c14f35ca94b324a27869cbaba3b58f0/test/__image_snapshots__/local-development-test-js-local-setup-should-modify-bootstrap-title-example-back-to-original-1-snap.png -------------------------------------------------------------------------------- /test/global-setup.js: -------------------------------------------------------------------------------- 1 | // Use custom jest puppeteer preset as described here: 2 | // jestjs.io/docs/puppeteer#custom-example-without-jest-puppeteer-preset 3 | // This allows using stealth mode. 4 | 5 | import fs from 'fs'; 6 | import os from 'os'; 7 | import path from 'path'; 8 | import puppeteer from 'puppeteer-extra'; 9 | 10 | // add stealth plugin and use defaults (all evasion techniques) 11 | import StealthPlugin from 'puppeteer-extra-plugin-stealth'; 12 | 13 | import jestPuppeteerConfig from './jest-puppeteer.config.js'; 14 | 15 | const fsPromises = fs.promises; 16 | const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup'); 17 | 18 | export default async function globalSetup() { 19 | puppeteer.use(StealthPlugin()); 20 | const browser = await puppeteer.launch(jestPuppeteerConfig.launch); 21 | // store the browser instance so we can teardown it later 22 | // this global is only available in the teardown but not in TestEnvironments 23 | global.__BROWSER_GLOBAL__ = browser; 24 | 25 | // use the file system to expose the wsEndpoint for TestEnvironments 26 | await fsPromises.mkdir(DIR, { recursive: true }); 27 | await fsPromises.writeFile( 28 | path.join(DIR, 'wsEndpoint'), 29 | browser.wsEndpoint() 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /test/global-teardown.js: -------------------------------------------------------------------------------- 1 | // Use custom jest puppeteer preset as described here: 2 | // jestjs.io/docs/puppeteer#custom-example-without-jest-puppeteer-preset 3 | // This allows using stealth mode. 4 | 5 | import fs from 'fs'; 6 | import os from 'os'; 7 | import path from 'path'; 8 | 9 | const fsPromises = fs.promises; 10 | 11 | const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup'); 12 | export default async function globalTeardown() { 13 | // close the browser instance 14 | await global.__BROWSER_GLOBAL__.close(); 15 | 16 | // clean-up the wsEndpoint file 17 | await fsPromises.rmdir(DIR, { recursive: true, force: true }); 18 | } 19 | -------------------------------------------------------------------------------- /test/jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | launch: { 3 | headless: false, 4 | product: 'chrome', 5 | args: [ 6 | '--force-color-profile=generic-rgb', 7 | '--font-render-hinting=none', 8 | '--disable-font-subpixel-positioning', 9 | '--enable-font-antialiasing', 10 | '--disable-gpu', 11 | ], 12 | }, 13 | browserContext: 'default', 14 | }; 15 | -------------------------------------------------------------------------------- /test/local-development.test.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { exec } from 'child_process'; 4 | import { configureToMatchImageSnapshot } from 'jest-image-snapshot'; 5 | import dotenv from 'dotenv'; 6 | import { openAddon } from './utils/open-addon'; 7 | 8 | dotenv.config(); 9 | 10 | const isExtended = `${process.env.IS_EXTENDED}` === 'true'; 11 | 12 | const toMatchImageSnapshot = configureToMatchImageSnapshot({ 13 | failureThreshold: 0.04, 14 | failureThresholdType: 'percent', 15 | customDiffConfig: { 16 | threshold: 0.1, 17 | }, 18 | blur: 2, 19 | allowSizeMismatch: true, 20 | }); 21 | expect.extend({ toMatchImageSnapshot }); 22 | jest.setTimeout(180000); 23 | 24 | const srcTestFile = path.join( 25 | __dirname, 26 | '../src/client/dialog-demo-bootstrap/components/SheetEditor.jsx' 27 | ); 28 | 29 | const viteDevServerReady = async (process) => { 30 | console.log('Waiting for vite to serve...'); 31 | return new Promise((resolve) => { 32 | process.stdout.on('data', (data) => { 33 | if (data.includes('ready in')) { 34 | resolve(); 35 | } 36 | }); 37 | }); 38 | }; 39 | 40 | describe(`Local setup ${isExtended ? '*extended*' : ''}`, () => { 41 | let page; 42 | let process; 43 | const containerSelector = isExtended ? 'div[role="dialog"]' : 'body'; 44 | 45 | beforeAll(async () => { 46 | process = exec('yarn dev'); 47 | page = await global.__BROWSER_GLOBAL__.newPage(); 48 | 49 | await page.setViewport({ 50 | width: 800, 51 | height: 800, 52 | deviceScaleFactor: 1, 53 | }); 54 | 55 | await viteDevServerReady(process); 56 | 57 | if (isExtended) { 58 | await openAddon(page); 59 | } else { 60 | await page.goto( 61 | 'https://localhost:3000/dialog-demo-bootstrap/index.html' 62 | ); 63 | await page.waitForTimeout(3000); 64 | } 65 | }); 66 | 67 | afterAll(() => { 68 | console.log('Closing process.'); 69 | process.kill(); 70 | }); 71 | 72 | it('should load Bootstrap example', async () => { 73 | const container = await page.$(containerSelector); 74 | const image = await container.screenshot(); 75 | await expect(image).toMatchImageSnapshot(); 76 | }); 77 | 78 | it('should modify Bootstrap title example', async () => { 79 | const data = await fs.promises.readFile(srcTestFile, 'utf8'); 80 | const result = data 81 | .replace( 82 | '☀️ Bootstrap demo! ☀️', 83 | '☀️ This is modified text in local development ☀️' 84 | ) 85 | .replace( 86 | "{ padding: '3px', overflowX: 'hidden' }", 87 | "{ padding: '3px', overflowX: 'hidden', backgroundColor: 'black', color: 'white' }" 88 | ); 89 | await fs.promises.writeFile(srcTestFile, result, 'utf8'); 90 | await page.waitForTimeout(4000); 91 | const container = await page.$(containerSelector); 92 | const image = await container.screenshot(); 93 | await expect(image).toMatchImageSnapshot(); 94 | }); 95 | 96 | it('should modify Bootstrap title example back to original', async () => { 97 | const data = await fs.promises.readFile(srcTestFile, 'utf8'); 98 | const result = data 99 | .replace( 100 | '☀️ This is modified text in local development ☀️', 101 | '☀️ Bootstrap demo! ☀️' 102 | ) 103 | .replace( 104 | "{ padding: '3px', overflowX: 'hidden', backgroundColor: 'black', color: 'white' }", 105 | "{ padding: '3px', overflowX: 'hidden' }" 106 | ); 107 | await fs.promises.writeFile(srcTestFile, result, 'utf8'); 108 | await page.waitForTimeout(4000); 109 | const container = await page.$(containerSelector); 110 | const image = await container.screenshot(); 111 | await expect(image).toMatchImageSnapshot(); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /test/puppeteer-environment.js: -------------------------------------------------------------------------------- 1 | // Use custom jest puppeteer preset as described here: 2 | // jestjs.io/docs/puppeteer#custom-example-without-jest-puppeteer-preset 3 | // This allows using stealth mode. 4 | 5 | import { readFile } from 'fs/promises'; 6 | import os from 'os'; 7 | import path from 'path'; 8 | import puppeteer from 'puppeteer'; 9 | import NodeEnvironment from 'jest-environment-node'; 10 | 11 | const DIR = path.join(os.tmpdir(), 'jest_puppeteer_global_setup'); 12 | 13 | export default class PuppeteerEnvironment extends NodeEnvironment.default { 14 | async setup() { 15 | await super.setup(); 16 | // get the wsEndpoint 17 | const wsEndpoint = await readFile(path.join(DIR, 'wsEndpoint'), 'utf8'); 18 | if (!wsEndpoint) { 19 | throw new Error('wsEndpoint not found'); 20 | } 21 | 22 | // connect to puppeteer 23 | this.global.__BROWSER_GLOBAL__ = await puppeteer.connect({ 24 | browserWSEndpoint: wsEndpoint, 25 | }); 26 | } 27 | 28 | async teardown() { 29 | await super.teardown(); 30 | } 31 | 32 | getVmContext() { 33 | return super.getVmContext(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/utils/image-reporter-standalone.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /** 4 | * Reference: https://github.com/americanexpress/jest-image-snapshot/blob/main/examples/image-reporter.js 5 | * 6 | * To enable this image reporter, add it to your `jest.config.js` "reporters" definition: 7 | * "reporters": [ "default", "/image-reporter.js" ] 8 | * 9 | * Note: Image Reporter may not work with jest's --forceExit flag 10 | * 11 | * Note: If image reporter doesn't work in pipeline can try running as standalone script 12 | * by creating a separate script file and running like this: 13 | * "test:integration:extended:report": "cross-env IS_EXTENDED=true jest --forceExit test/local-development.test || node test/utils/image-reporter-standalone.js", 14 | */ 15 | 16 | import fs from 'fs'; 17 | import AWS from 'aws-sdk/global.js'; 18 | import S3 from 'aws-sdk/clients/s3.js'; // this is needed 19 | 20 | import dotenv from 'dotenv'; 21 | dotenv.config(); 22 | 23 | const UPLOAD_BUCKET = process.env.S3_BUCKET_NAME; 24 | 25 | if ( 26 | !process.env.S3_BUCKET_NAME || 27 | !process.env.AWS_SECRET_ACCESS_KEY || 28 | !process.env.AWS_ACCESS_KEY_ID 29 | ) { 30 | console.log('Missing env variables. Skipping upload of image diff files.'); 31 | } 32 | 33 | AWS.config.update({ 34 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 35 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 36 | }); 37 | 38 | const s3 = new AWS.S3({ apiVersion: '2006-03-01' }); 39 | 40 | const targetDirectories = [ 41 | './test/__image_snapshots__/', 42 | './test/__image_snapshots__/__diff_output__/', 43 | ]; 44 | targetDirectories.forEach((targetDirectory) => { 45 | fs.readdirSync(targetDirectory, { withFileTypes: true }).forEach((dirent) => { 46 | if (!dirent.isFile()) return; 47 | const path = `images/${dirent.name}`; 48 | const params = { 49 | Body: fs.readFileSync(`${targetDirectory}/${dirent.name}`), 50 | Bucket: UPLOAD_BUCKET, 51 | Key: path, 52 | ContentType: 'image/png', 53 | }; 54 | s3.putObject(params, (err) => { 55 | if (err) { 56 | console.log(err, err.stack); 57 | } else { 58 | console.log( 59 | `Uploaded file to https://${UPLOAD_BUCKET}.s3.amazonaws.com/${path}` 60 | ); 61 | } 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/utils/image-reporter.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | /** 4 | * Reference: https://github.com/americanexpress/jest-image-snapshot/blob/main/examples/image-reporter.js 5 | * 6 | * To enable this image reporter, add it to your `jest.config.js` "reporters" definition: 7 | * "reporters": [ "default", "/image-reporter.js" ] 8 | * 9 | * Note: Image Reporter may not work with jest's --forceExit flag 10 | * 11 | * Note: If image reporter doesn't work in pipeline can try running as standalone script 12 | * by creating a separate script file and running like this: 13 | * "test:integration": "jest test/local-development.test || node test/utils/image-reporter-standalone.js" 14 | */ 15 | 16 | import fs from 'fs'; 17 | import AWS from 'aws-sdk/global.js'; 18 | import S3 from 'aws-sdk/clients/s3.js'; // this is needed 19 | 20 | import dotenv from "dotenv"; 21 | dotenv.config(); 22 | 23 | const UPLOAD_BUCKET = process.env.S3_BUCKET_NAME; 24 | 25 | AWS.config.update({ 26 | accessKeyId: process.env.AWS_ACCESS_KEY_ID, 27 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, 28 | }); 29 | 30 | const s3 = new AWS.S3({ apiVersion: '2006-03-01' }); 31 | 32 | export default class ImageReporter { 33 | constructor(globalConfig, options) { 34 | this._globalConfig = globalConfig; 35 | this._options = options; 36 | } 37 | 38 | onTestResult(test, testResult, aggregateResults) { 39 | if ( 40 | testResult.numFailingTests && 41 | testResult.failureMessage.match(/different from snapshot/) 42 | ) { 43 | if ( 44 | !process.env.S3_BUCKET_NAME || 45 | !process.env.AWS_SECRET_ACCESS_KEY || 46 | !process.env.AWS_ACCESS_KEY_ID 47 | ) { 48 | console.log( 49 | 'Missing env variables. Skipping upload of image diff files.' 50 | ); 51 | return; 52 | } 53 | 54 | const targetDirectories = [ 55 | './test/__image_snapshots__/', 56 | './test/__image_snapshots__/__diff_output__/', 57 | ]; 58 | targetDirectories.forEach((targetDirectory) => { 59 | fs.readdirSync(targetDirectory, { withFileTypes: true }).forEach( 60 | (dirent) => { 61 | if (!dirent.isFile()) return; 62 | const path = `images/${dirent.name}`; 63 | const params = { 64 | Body: fs.readFileSync(`${targetDirectory}/${dirent.name}`), 65 | Bucket: UPLOAD_BUCKET, 66 | Key: path, 67 | ContentType: 'image/png', 68 | }; 69 | s3.putObject(params, (err) => { 70 | if (err) { 71 | console.log(err, err.stack); 72 | } else { 73 | console.log( 74 | `Uploaded file to https://${UPLOAD_BUCKET}.s3.amazonaws.com/${path}` 75 | ); 76 | } 77 | }); 78 | } 79 | ); 80 | }); 81 | } 82 | } 83 | } -------------------------------------------------------------------------------- /test/utils/open-addon.js: -------------------------------------------------------------------------------- 1 | export const openAddon = async (page) => { 2 | await page.goto(process.env.SHEET_URL); 3 | 4 | await page.waitForTimeout(5000); // pause for 3 seconds 5 | await page.click('a:nth-child(2)'); // click on signin button 6 | 7 | await page.waitForSelector('input[name="identifier"]', { visible: true }); 8 | await page.type('input[name="identifier"]', process.env.EMAIL); // type email 9 | await page.click('#identifierNext'); // click "next" button 10 | 11 | await page.waitForSelector('input[name="Passwd"]', { visible: true }); 12 | await page.type('input[name="Passwd"]', process.env.PASSWORD); // type pass 13 | await page.waitForTimeout(500); 14 | 15 | await page.click('#passwordNext'); // click "next" button 16 | await page.waitForTimeout(3000); 17 | 18 | if ( 19 | await page.evaluate( 20 | () => 21 | document.querySelector('h1#headingText') && 22 | document.querySelector('h1#headingText').innerText.includes('erify') 23 | ) 24 | ) { 25 | try { 26 | await page.click('li:nth-child(3)'); 27 | await page.waitForTimeout(6000); 28 | } catch { 29 | // eslint-disable-next-line no-console 30 | console.log('The "choose account recovery method" page isn\'t shown'); 31 | } 32 | 33 | await page.type( 34 | 'input[name="knowledgePreregisteredEmailResponse"]', 35 | process.env.TEST_RECOVERY_EMAIL 36 | ); // type recovery email 37 | await page.waitForTimeout(6000); 38 | await page.click('div[data-primary-action-label] button'); // click "next" button 39 | await page.waitForTimeout(5000); 40 | } 41 | 42 | if ( 43 | await page.evaluate( 44 | () => 45 | document.querySelector('h1#headingText') && 46 | document 47 | .querySelector('h1#headingText') 48 | .innerText.includes('implify your sign') 49 | ) 50 | ) { 51 | try { 52 | await page.click( 53 | 'div[data-secondary-action-label] > div > div:nth-child(2) button' 54 | ); 55 | await page.waitForTimeout(6000); 56 | } catch { 57 | // eslint-disable-next-line no-console 58 | console.log('The "Simplify your sign-in" page isn\'t shown'); 59 | } 60 | } 61 | 62 | await page.waitForSelector( 63 | 'div.menu-button.goog-control.goog-inline-block:nth-child(10)', 64 | { visible: true } 65 | ); 66 | 67 | // open new addon menubar item 68 | await page.evaluate(() => { 69 | const addOnMenuButton = document.querySelector( 70 | 'div.menu-button.goog-control.goog-inline-block:nth-child(10)' 71 | ); 72 | addOnMenuButton.dispatchEvent( 73 | new MouseEvent('mousedown', { bubbles: true }) 74 | ); 75 | addOnMenuButton.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); 76 | }); 77 | 78 | await page.waitForSelector( 79 | 'div.goog-menu.goog-menu-vertical.apps-menu-hide-mnemonics:last-child > div:nth-child(2) > div', 80 | { visible: true } 81 | ); 82 | 83 | // open "bootstrap" menu item 84 | await page.evaluate(() => { 85 | const bootstrapMenuButton = document.querySelector( 86 | 'div.goog-menu.goog-menu-vertical.apps-menu-hide-mnemonics:last-child > div:nth-child(2) > div' 87 | ); 88 | bootstrapMenuButton.dispatchEvent( 89 | new MouseEvent('mousedown', { bubbles: true }) 90 | ); 91 | bootstrapMenuButton.dispatchEvent( 92 | new MouseEvent('mouseup', { bubbles: true }) 93 | ); 94 | bootstrapMenuButton.dispatchEvent( 95 | new MouseEvent('mousedown', { bubbles: true }) 96 | ); 97 | bootstrapMenuButton.dispatchEvent( 98 | new MouseEvent('mouseup', { bubbles: true }) 99 | ); 100 | }); 101 | await page.waitForSelector('div[role="dialog"]', { 102 | visible: true, 103 | timeout: 10000, 104 | }); 105 | 106 | await page.waitForTimeout(15000); 107 | }; 108 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2019", 4 | "types": ["gas-types-detailed"], 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "allowJs": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true 24 | }, 25 | "include": ["src"], 26 | "references": [{ "path": "./tsconfig.vite.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.vite.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 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { resolve } from 'path'; 3 | import { BuildOptions, ServerOptions, build, defineConfig } from 'vite'; 4 | import { existsSync, readFileSync } from 'fs'; 5 | import react from '@vitejs/plugin-react-swc'; 6 | import { viteStaticCopy } from 'vite-plugin-static-copy'; 7 | import { viteSingleFile } from 'vite-plugin-singlefile'; 8 | import { writeFile } from 'fs/promises'; 9 | 10 | const PORT = 3000; 11 | const clientRoot = './src/client'; 12 | const outDir = './dist'; 13 | const serverEntry = 'src/server/index.ts'; 14 | const copyAppscriptEntry = './appsscript.json'; 15 | const devServerWrapper = './dev/dev-server-wrapper.html'; 16 | 17 | const clientEntrypoints = [ 18 | { 19 | name: 'CLIENT - Dialog Demo', 20 | filename: 'dialog-demo', // we'll add the .html suffix to these 21 | template: 'dialog-demo/index.html', 22 | }, 23 | { 24 | name: 'CLIENT - Dialog Demo Bootstrap', 25 | filename: 'dialog-demo-bootstrap', 26 | template: 'dialog-demo-bootstrap/index.html', 27 | }, 28 | { 29 | name: 'CLIENT - Dialog Demo MUI', 30 | filename: 'dialog-demo-mui', 31 | template: 'dialog-demo-mui/index.html', 32 | }, 33 | { 34 | name: 'CLIENT - Dialog Demo Tailwind CSS', 35 | filename: 'dialog-demo-tailwindcss', 36 | template: 'dialog-demo-tailwindcss/index.html', 37 | }, 38 | { 39 | name: 'CLIENT - Sidebar About Page', 40 | filename: 'sidebar-about-page', 41 | template: 'sidebar-about-page/index.html', 42 | }, 43 | ]; 44 | 45 | const keyPath = resolve(__dirname, './certs/key.pem'); 46 | const certPath = resolve(__dirname, './certs/cert.pem'); 47 | const pfxPath = resolve(__dirname, './certs/cert.pfx'); // if needed for Windows 48 | 49 | const devServerOptions: ServerOptions = { 50 | port: PORT, 51 | }; 52 | 53 | // use key and cert settings only if they are found 54 | if (existsSync(keyPath) && existsSync(certPath)) { 55 | devServerOptions.https = { 56 | key: readFileSync(resolve(__dirname, './certs/key.pem')), 57 | cert: readFileSync(resolve(__dirname, './certs/cert.pem')), 58 | }; 59 | } 60 | 61 | // If mkcert -install cannot be used on Windows machines (in pipeline, for example), the 62 | // script at scripts/generate-cert.ps1 can be used to create a .pfx cert 63 | if (existsSync(pfxPath)) { 64 | // use pfx file if it's found 65 | devServerOptions.https = { 66 | pfx: readFileSync(pfxPath), 67 | passphrase: 'abc123', 68 | }; 69 | } 70 | 71 | const clientServeConfig = () => 72 | defineConfig({ 73 | plugins: [react()], 74 | server: devServerOptions, 75 | root: clientRoot, 76 | }); 77 | 78 | const clientBuildConfig = ({ 79 | clientEntrypointRoot, 80 | template, 81 | }: { 82 | clientEntrypointRoot: string; 83 | template: string; 84 | }) => 85 | defineConfig({ 86 | plugins: [react(), viteSingleFile({ useRecommendedBuildConfig: true })], 87 | root: resolve(__dirname, clientRoot, clientEntrypointRoot), 88 | build: { 89 | sourcemap: false, 90 | write: false, // don't write to disk 91 | outDir, 92 | emptyOutDir: true, 93 | minify: true, 94 | rollupOptions: { 95 | external: [ 96 | 'react', 97 | 'react-dom', 98 | 'react-transition-group', 99 | 'react-bootstrap', 100 | '@mui/material', 101 | '@emotion/react', 102 | '@emotion/styled', 103 | 'gas-client', 104 | '@types/react', 105 | ], 106 | output: { 107 | format: 'iife', // needed to use globals from UMD builds 108 | dir: outDir, 109 | globals: { 110 | react: 'React', 111 | 'react-dom': 'ReactDOM', 112 | 'react-transition-group': 'ReactTransitionGroup', 113 | 'react-bootstrap': 'ReactBootstrap', 114 | '@mui/material': 'MaterialUI', 115 | '@emotion/react': 'emotionReact', 116 | '@emotion/styled': 'emotionStyled', 117 | 'gas-client': 'GASClient', 118 | '@types/react': '@types/react', 119 | }, 120 | }, 121 | input: resolve(__dirname, clientRoot, template), 122 | }, 123 | }, 124 | }); 125 | 126 | const serverBuildConfig: BuildOptions = { 127 | emptyOutDir: true, 128 | minify: false, // needed to work with footer 129 | lib: { 130 | entry: resolve(__dirname, serverEntry), 131 | fileName: 'code', 132 | name: 'globalThis', 133 | formats: ['iife'], 134 | }, 135 | rollupOptions: { 136 | output: { 137 | entryFileNames: 'code.js', 138 | extend: true, 139 | footer: (chunk) => 140 | chunk.exports 141 | .map((exportedFunction) => `function ${exportedFunction}() {};`) 142 | .join('\n'), 143 | }, 144 | }, 145 | }; 146 | 147 | const buildConfig = ({ mode }: { mode: string }) => { 148 | const targets = [{ src: copyAppscriptEntry, dest: './' }]; 149 | if (mode === 'development') { 150 | targets.push( 151 | ...clientEntrypoints.map((entrypoint) => ({ 152 | src: devServerWrapper, 153 | dest: './', 154 | rename: `${entrypoint.filename}.html`, 155 | transform: (contents: string) => 156 | contents 157 | .toString() 158 | .replace(/__PORT__/g, String(PORT)) 159 | .replace(/__FILE_NAME__/g, entrypoint.template), 160 | })) 161 | ); 162 | } 163 | return defineConfig({ 164 | plugins: [ 165 | viteStaticCopy({ 166 | targets, 167 | }), 168 | /** 169 | * This builds the client react app bundles for production, and writes them to disk. 170 | * Because multiple client entrypoints (dialogs) are built, we need to loop through 171 | * each entrypoint and build the client bundle for each. Vite doesn't have great tooling for 172 | * building multiple single-page apps in one project, so we have to do this manually with a 173 | * post-build closeBundle hook (https://rollupjs.org/guide/en/#closebundle). 174 | */ 175 | mode === 'production' && { 176 | name: 'build-client-production-bundles', 177 | closeBundle: async () => { 178 | console.log('Building client production bundles...'); 179 | // eslint-disable-next-line no-restricted-syntax 180 | for (const clientEntrypoint of clientEntrypoints) { 181 | console.log('Building client bundle for', clientEntrypoint.name); 182 | // eslint-disable-next-line no-await-in-loop 183 | const buildOutput = await build( 184 | clientBuildConfig({ 185 | clientEntrypointRoot: clientEntrypoint.filename, 186 | template: clientEntrypoint.template, 187 | }) 188 | ); 189 | // eslint-disable-next-line no-await-in-loop 190 | await writeFile( 191 | resolve(__dirname, outDir, `${clientEntrypoint.filename}.html`), 192 | // @ts-expect-error - output is an array of RollupOutput 193 | buildOutput.output[0].source 194 | ); 195 | } 196 | console.log('Finished building client bundles!'); 197 | }, 198 | }, 199 | ].filter(Boolean), 200 | build: serverBuildConfig, 201 | }); 202 | }; 203 | 204 | // https://vitejs.dev/config/ 205 | export default async ({ command, mode }: { command: string; mode: string }) => { 206 | if (command === 'serve') { 207 | // for 'serve' mode, we only want to serve the client bundle locally 208 | return clientServeConfig(); 209 | } 210 | if (command === 'build') { 211 | // for 'build' mode, we have two paths: build assets for local development, and build for production 212 | return buildConfig({ mode }); 213 | } 214 | return {}; 215 | }; 216 | --------------------------------------------------------------------------------