├── .clasp.json ├── .claspignore ├── .editorconfig ├── .gitignore ├── .tern-project ├── README.md ├── appsscript.json ├── dist ├── appsscript.json ├── code.js ├── dialog.html └── main.js ├── package.json ├── src ├── client │ ├── components │ │ ├── form-input.tsx │ │ ├── sheet-button.tsx │ │ └── sheet-editor.tsx │ ├── dialog-template.html │ ├── index.tsx │ └── styles.scss └── server │ ├── code.ts │ └── sheets-utilities.ts ├── tsconfig.json ├── tslint.json ├── webpack.config.js └── yarn.lock /.clasp.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": "dist", 3 | "scriptId": "...paste scriptId here..." 4 | } 5 | -------------------------------------------------------------------------------- /.claspignore: -------------------------------------------------------------------------------- 1 | dist/main.js 2 | src/import.js 3 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 2 7 | 8 | [{.babelrc, .stylelintrc, jest.config, .eslintrc, .prettierrc, *.json, *.jsb3, *.jsb2, *.bowerrc}] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [*.js] 13 | indent_style = tab 14 | tab_width = 2 15 | 16 | [{*.ats, *.ts}] 17 | indent_style = tab 18 | tab_width = 2 19 | 20 | [*.tsx] 21 | indent_style = tab 22 | tab_width = 2 23 | 24 | [*.js.flow] 25 | indent_style = tab 26 | tab_width = 2 27 | 28 | [{tsconfig.app.json, tsconfig.e2e.json, tsconfig.json, tsconfig.spec.json}] 29 | indent_style = space 30 | indent_size = 2 31 | 32 | [*.js.map] 33 | indent_style = space 34 | indent_size = 2 35 | 36 | [*.css] 37 | indent_style = space 38 | indent_size = 2 39 | 40 | [*.scss] 41 | indent_style = space 42 | indent_size = 2 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .clasp* 2 | 3 | .env 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # misc 14 | .DS_Store 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | .idea/ 24 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaVersion": 7, 3 | "libs": [ 4 | ], 5 | "plugins": { 6 | "complete_strings": {}, 7 | "googleappsscript": {}, 8 | "es_modules": {}, 9 | "node": {}, 10 | "doc_comment": { 11 | "fullDocs": true, 12 | "strong": true 13 | } 14 | } 15 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## TypeScript + React + Google Apps Script 3 | *Use this demo project as your boilerplate React app for HTML dialogs in Google Sheets, Docs and Forms.* 4 | 5 | This project is a fork of [React-Google-Apps-Script](https://github.com/enuchi/React-Google-Apps-Script) (which uses labnol's excellent [apps-script-starter](https://github.com/labnol/apps-script-starter) as a starting point), adding support for TypeScript and React. It demonstrates how easy it is to build React apps that interact with Google Apps server-side scripts. Simply clone this project and modify the source code to get started developing with React for Google Apps Script client-side dialogs. 6 | 7 | ![Google Apps Script / React development](https://i.imgur.com/0yYQoYj.jpg "Start a React project for Google Apps Script") 8 | *The demo app for Google Sheets shows insertion/deletion/activation of sheets through React-built HTML dialog.* 9 | 10 | ## Installation 11 | 12 | Clone the sample project and install dependencies: 13 | ``` 14 | git clone https://github.com/enuchi/React-Google-Apps-Script.git 15 | cd React-Google-Apps-Script 16 | yarn 17 | ``` 18 | Then [create a new Google Sheets spreadsheet](https://sheets.google.com). Open the Script Editor and copy the script's scriptId. [**Tools > Script Editor**, then **File > Project properties**]. 19 | 20 | Paste the **scriptId** into the .clasp.json file as below: 21 | ``` 22 | // .clasp.json 23 | {"rootDir": "dist", 24 | "scriptId":"...paste scriptId here..."} 25 | ``` 26 | If you have not enabled Google's Apps Script API, do so by visiting https://script.google.com/home/usersettings. 27 | Log into CLASP to push code to the server from the command line: 28 | ``` 29 | yarn login:clasp 30 | ``` 31 | Modify the server-side and client-side source code in the `src` folder using ES6/7 and React. Change the scopes in `appsscript.json` if needed. When you're ready, build the app and deploy! 32 | ``` 33 | yarn deploy:stage 34 | ``` 35 | 36 | or 37 | 38 | ```bash 39 | yarn deploy:prod 40 | ``` 41 | Webpack will display any linting errors, bundle your files in `dist`, and push your files to Google's servers using CLASP. You can run `yarn build` to just build. 42 | 43 | Using `deploy:stage` will give an easier to debug minified version, where `deploy:prod` will minify it to basic obscurity. 44 | 45 | ## The sample app 46 | Insert/activate/delete sheets through a simple HTML dialog, built with React. Access the dialog through the new menu item that appears. You may need to refresh the spreadsheet and approve the app's permissions the first time you use it. 47 | 48 | ## How it works 49 | "[Google Apps Script](https://en.wikipedia.org/wiki/Google_Apps_Script) is based on JavaScript 1.6 with some portions of 1.7 and 1.8 and provides subset of ECMAScript 5 API." 50 | 51 | That means many JavaScript tools used today in modern web development will not work in the Google Apps Script environment, including `let`/`const` declarations, arrow functions, spread operator, etc. 52 | 53 | This project circumvents those restrictions by transpiling newer code to older code that Google Apps Script understands using Babel, and also bundles separate files and modules using Webpack. 54 | 55 | On the client-side, there are restrictions on the way HTML dialogs are used in Google Apps (Sheets, Docs and Forms). In web development you can simply reference a separate css file: 56 | ``` 57 | 58 | ``` 59 | In the Google Apps Script environment you need to use [HTML templates](https://developers.google.com/apps-script/guides/html/templates), which can be cumbersome. With this project, all files are bundled together by inlining .css and .js files. Using a transpiler and bundling tool also allows us to use JSX syntax, and external libraries such as React. 60 | 61 | ## Features 62 | - Support for JSX syntax: 63 | ``` 64 | return
Name: {person.firstName}
65 | ``` 66 | - Support for external packages. Simply install with yarn or from a file and `import`: 67 | ``` 68 | $ yarn add react-addons-css-transition-group 69 | ``` 70 | ``` 71 | // index.jsx 72 | import ReactCSSTransitionGroup from 'react-addons-css-transition-group'; 73 | ``` 74 | - `import` CSS from another file: 75 | ``` 76 | import "./styles.css"; 77 | ``` 78 | - Make server calls in React with `google.script.run`: 79 | ``` 80 | componentDidMount() { 81 | google.script.run 82 | .withSuccessHandler((data) => this.setState({names: data})) 83 | .withFailureHandler((error) => alert(error)) 84 | .getSheetsData() 85 | } 86 | ``` 87 | - Use newer ES6/7 code, including arrow functions, spread operators, `const`/`let`, and more: 88 | ``` 89 | const getSheetsData = () => { 90 | let activeSheetName = getActiveSheetName(); 91 | return getSheets().map((sheet, index) => { 92 | let sheetName = sheet.getName(); 93 | return { 94 | text: sheetName, 95 | sheetIndex: index, 96 | isActive: sheetName === activeSheetName, 97 | }; 98 | }); 99 | }; 100 | ``` 101 | ## Tern support 102 | This project includes support for GAS definitions and autocomplete through a [Tern](http://ternjs.net/) plugin. Tern is a code-analysis engine for JavaScript, providing many useful tools for developing. See Tern's site for setup instructions for many popular code editors, such as Sublime, Vim and others. 103 | 104 | Tern provides many indispensable tools for working with Google Apps Script, such as autocompletion on variables and properties, function argument hints and querying the type of an expression. 105 | 106 | - Autocomplete example. Lists all available methods from the appropriate Google Apps Script API: 107 | ![tern support](https://i.imgur.com/s1OrQNr.png "autocomplete and intelligent type detection with Tern") 108 | 109 | - Full definitions with links to official documentation, plus information on argument and return type: 110 | ![tern support](https://i.imgur.com/yg5VwAC.png "definitions with links to official documentation make developing with Google Apps Script") 111 | 112 | 113 | 114 | ## Extending this app 115 | - You can split up server-side code into multiple files and folders using `import` and `export` statements. 116 | - Make sure to expose all public functions including any functions called from the client with `google.script.run` as well as onOpen. Example below shows assignment to `global` object: 117 | ``` 118 | const onOpen = () => { 119 | SpreadsheetApp.getUi() // Or DocumentApp or FormApp. 120 | .createMenu('Dialog') 121 | .addItem('Add sheets', 'openDialog') 122 | .addToUi(); 123 | } 124 | 125 | global.onOpen = onOpen 126 | ``` 127 | - You may wish to remove automatic linting when running Webpack. You can do so by editing the Webpack config file and commenting out the eslintConfig line in client or server settings: 128 | ``` 129 | // webpack.config.js 130 | 131 | const clientConfig = Object.assign({}, sharedConfigSettings, { 132 | ... 133 | module: { 134 | rules: [ 135 | // eslintConfig, 136 | { 137 | ``` 138 | ## Suggestions 139 | Open a pull request! 140 | -------------------------------------------------------------------------------- /appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "oauthScopes": [ 6 | "https://www.googleapis.com/auth/script.container.ui", 7 | "https://www.googleapis.com/auth/spreadsheets" 8 | ] 9 | } -------------------------------------------------------------------------------- /dist/appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/New_York", 3 | "dependencies": { 4 | }, 5 | "oauthScopes": [ 6 | "https://www.googleapis.com/auth/script.container.ui", 7 | "https://www.googleapis.com/auth/spreadsheets" 8 | ] 9 | } -------------------------------------------------------------------------------- /dist/code.js: -------------------------------------------------------------------------------- 1 | function onOpen() { 2 | } 3 | function openDialog() { 4 | } 5 | function getSheetsData() { 6 | } 7 | function addSheet() { 8 | } 9 | function deleteSheet() { 10 | } 11 | function setActiveSheet() { 12 | }!function(e, a) { 13 | for (var i in a) e[i] = a[i]; 14 | }(this, /******/ function(modules) { 15 | // webpackBootstrap 16 | /******/ // The module cache 17 | /******/ var installedModules = {}; 18 | /******/ 19 | /******/ // The require function 20 | /******/ function __webpack_require__(moduleId) { 21 | /******/ 22 | /******/ // Check if module is in cache 23 | /******/ if (installedModules[moduleId]) 24 | /******/ return installedModules[moduleId].exports; 25 | /******/ 26 | /******/ // Create a new module (and put it into the cache) 27 | /******/ var module = installedModules[moduleId] = { 28 | /******/ i: moduleId, 29 | /******/ l: !1, 30 | /******/ exports: {} 31 | /******/ }; 32 | /******/ 33 | /******/ // Execute the module function 34 | /******/ 35 | /******/ 36 | /******/ // Return the exports of the module 37 | /******/ return modules[moduleId].call(module.exports, module, module.exports, __webpack_require__), 38 | /******/ 39 | /******/ // Flag the module as loaded 40 | /******/ module.l = !0, module.exports; 41 | /******/ } 42 | /******/ 43 | /******/ 44 | /******/ // expose the modules object (__webpack_modules__) 45 | /******/ 46 | /******/ 47 | /******/ 48 | /******/ // Load entry module and return exports 49 | /******/ return __webpack_require__.m = modules, 50 | /******/ 51 | /******/ // expose the module cache 52 | /******/ __webpack_require__.c = installedModules, 53 | /******/ 54 | /******/ // define getter function for harmony exports 55 | /******/ __webpack_require__.d = function(exports, name, getter) { 56 | /******/ __webpack_require__.o(exports, name) || 57 | /******/ Object.defineProperty(exports, name, { 58 | enumerable: !0, 59 | get: getter 60 | }) 61 | /******/; 62 | }, 63 | /******/ 64 | /******/ // define __esModule on exports 65 | /******/ __webpack_require__.r = function(exports) { 66 | /******/ "undefined" != typeof Symbol && Symbol.toStringTag && 67 | /******/ Object.defineProperty(exports, Symbol.toStringTag, { 68 | value: "Module" 69 | }) 70 | /******/ , Object.defineProperty(exports, "__esModule", { 71 | value: !0 72 | }); 73 | }, 74 | /******/ 75 | /******/ // create a fake namespace object 76 | /******/ // mode & 1: value is a module id, require it 77 | /******/ // mode & 2: merge all properties of value into the ns 78 | /******/ // mode & 4: return value when already ns object 79 | /******/ // mode & 8|1: behave like require 80 | /******/ __webpack_require__.t = function(value, mode) { 81 | /******/ if ( 82 | /******/ 1 & mode && (value = __webpack_require__(value)), 8 & mode) return value; 83 | /******/ if (4 & mode && "object" == typeof value && value && value.__esModule) return value; 84 | /******/ var ns = Object.create(null); 85 | /******/ 86 | /******/ if (__webpack_require__.r(ns), 87 | /******/ Object.defineProperty(ns, "default", { 88 | enumerable: !0, 89 | value: value 90 | }), 2 & mode && "string" != typeof value) for (var key in value) __webpack_require__.d(ns, key, function(key) { 91 | return value[key]; 92 | }.bind(null, key)); 93 | /******/ return ns; 94 | /******/ }, 95 | /******/ 96 | /******/ // getDefaultExport function for compatibility with non-harmony modules 97 | /******/ __webpack_require__.n = function(module) { 98 | /******/ var getter = module && module.__esModule ? 99 | /******/ function getDefault() { 100 | return module["default"]; 101 | } : 102 | /******/ function getModuleExports() { 103 | return module; 104 | }; 105 | /******/ 106 | /******/ return __webpack_require__.d(getter, "a", getter), getter; 107 | /******/ }, 108 | /******/ 109 | /******/ // Object.prototype.hasOwnProperty.call 110 | /******/ __webpack_require__.o = function(object, property) { 111 | return Object.prototype.hasOwnProperty.call(object, property); 112 | }, 113 | /******/ 114 | /******/ // __webpack_public_path__ 115 | /******/ __webpack_require__.p = "", __webpack_require__(__webpack_require__.s = 1); 116 | /******/} 117 | /************************************************************************/ 118 | /******/ ([ 119 | /* 0 */ 120 | /***/ function(module, __webpack_exports__, __webpack_require__) { 121 | "use strict"; 122 | /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "d", function() { 123 | return onOpen; 124 | }), 125 | /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "e", function() { 126 | return openDialog; 127 | }), 128 | /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "c", function() { 129 | return getSheetsData; 130 | }), 131 | /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "a", function() { 132 | return addSheet; 133 | }), 134 | /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "b", function() { 135 | return deleteSheet; 136 | }), 137 | /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "f", function() { 138 | return setActiveSheet; 139 | }); 140 | var onOpen = function() { 141 | SpreadsheetApp.getUi().createMenu("Custom scripts").addItem("Edit sheets [sample React project]", "openDialog").addToUi(); 142 | }, openDialog = function() { 143 | var html = HtmlService.createHtmlOutputFromFile("dialog").setWidth(400).setHeight(600); 144 | SpreadsheetApp.getUi().showModalDialog(html, "Sheet Editor"); 145 | }, getSheets = function() { 146 | return SpreadsheetApp.getActive().getSheets(); 147 | }, getSheetsData = function() { 148 | var activeSheetName = SpreadsheetApp.getActive().getSheetName(); 149 | return getSheets().map(function(sheet, index) { 150 | var sheetName = sheet.getName(); 151 | return { 152 | text: sheetName, 153 | sheetIndex: index, 154 | isActive: sheetName === activeSheetName 155 | }; 156 | }); 157 | }, addSheet = function(sheetTitle) { 158 | return SpreadsheetApp.getActive().insertSheet(sheetTitle), getSheetsData(); 159 | }, deleteSheet = function(sheetIndex) { 160 | var sheets = getSheets(); 161 | return SpreadsheetApp.getActive().deleteSheet(sheets[sheetIndex]), getSheetsData(); 162 | }, setActiveSheet = function(sheetName) { 163 | return SpreadsheetApp.getActive().getSheetByName(sheetName).activate(), getSheetsData(); 164 | }; 165 | }, 166 | /* 1 */ 167 | /***/ function(module, __webpack_exports__, __webpack_require__) { 168 | "use strict"; 169 | __webpack_require__.r(__webpack_exports__), 170 | /* WEBPACK VAR INJECTION */ function(global) { 171 | /* harmony import */ var _sheets_utilities__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(0); 172 | global.onOpen = _sheets_utilities__WEBPACK_IMPORTED_MODULE_0__.d, global.openDialog = _sheets_utilities__WEBPACK_IMPORTED_MODULE_0__.e, 173 | global.getSheetsData = _sheets_utilities__WEBPACK_IMPORTED_MODULE_0__.c, global.addSheet = _sheets_utilities__WEBPACK_IMPORTED_MODULE_0__.a, 174 | global.deleteSheet = _sheets_utilities__WEBPACK_IMPORTED_MODULE_0__.b, global.setActiveSheet = _sheets_utilities__WEBPACK_IMPORTED_MODULE_0__.f; 175 | }.call(this, __webpack_require__(2)) 176 | /***/; 177 | }, 178 | /* 2 */ 179 | /***/ function(module, exports) { 180 | var g; 181 | // This works in non-strict mode 182 | g = function() { 183 | return this; 184 | }(); 185 | try { 186 | // This works if eval is allowed (see CSP) 187 | g = g || new Function("return this")(); 188 | } catch (e) { 189 | // This works if the window reference is available 190 | "object" == typeof window && (g = window); 191 | } 192 | // g can still be undefined, but nothing to do about it... 193 | // We return undefined, instead of nothing here, so it's 194 | // easier to handle this case. if(!global) { ...} 195 | module.exports = g; 196 | } 197 | /******/ ])); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-react-google-apps-script", 3 | "version": "1.0.0", 4 | "description": "Starter project for Google Apps Script using TypeScript and React", 5 | "scripts": { 6 | "test": "test", 7 | "login:clasp": "npx clasp login", 8 | "start": "webpack --mode development", 9 | "build": "webpack --mode production", 10 | "deploy:stage": "yarn run build && npx clasp push", 11 | "deploy:prod": "NODE_ENV=production yarn run build && npx clasp push" 12 | }, 13 | "keywords": [ 14 | "typescript", 15 | "react", 16 | "sass", 17 | "scss", 18 | "webpack", 19 | "google", 20 | "apps", 21 | "script", 22 | "sheets" 23 | ], 24 | "author": "Danny Hinshaw", 25 | "license": "MIT", 26 | "dependencies": {}, 27 | "devDependencies": { 28 | "@google/clasp": "^1.7.0", 29 | "@types/google-apps-script": "0.0.28", 30 | "@types/node": "^11.9.5", 31 | "@types/react": "^16.8.5", 32 | "@types/react-dom": "^16.8.2", 33 | "awesome-typescript-loader": "^5.2.1", 34 | "clean-webpack-plugin": "^0.1.19", 35 | "copy-webpack-plugin": "^5.0.0", 36 | "css-loader": "^2.1.0", 37 | "gas-lib": "^2.0.2", 38 | "gas-webpack-plugin": "^1.0.2", 39 | "html-webpack-inline-source-plugin": "^0.0.10", 40 | "html-webpack-plugin": "^3.2.0", 41 | "node-sass": "^4.11.0", 42 | "prettier": "^1.16.4", 43 | "prop-types": "^15.7.2", 44 | "react": "^16.8.3", 45 | "react-dom": "^16.8.3", 46 | "sass-loader": "^7.1.0", 47 | "source-map-loader": "^0.2.4", 48 | "style-loader": "^0.23.1", 49 | "tern": "^0.23.0", 50 | "tern-googleappsscript": "^1.0.2", 51 | "terser-webpack-plugin": "^1.2.3", 52 | "tslint": "^5.13.0", 53 | "tslint-config-prettier": "^1.18.0", 54 | "tslint-react": "^3.6.0", 55 | "tslint-webpack-plugin": "^2.0.2", 56 | "typescript": "^3.3.3333", 57 | "webpack": "^4.29.5", 58 | "webpack-cli": "^3.2.3" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/client/components/form-input.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../styles.scss"; // tslint:disable-line 3 | 4 | 5 | interface IFormInputProps { 6 | newSheetFormHandler: any 7 | } 8 | 9 | 10 | export default class FormInput extends React.Component { 11 | public state: { text: string }; 12 | 13 | constructor(props) { 14 | super(props); 15 | this.state = { text: "" }; 16 | this.handleChange = this.handleChange.bind(this); 17 | this.handleSubmit = this.handleSubmit.bind(this); 18 | } 19 | 20 | public handleChange(e) { 21 | this.setState({ text: e.target.value }); 22 | } 23 | 24 | public handleSubmit = (e) => { 25 | e.preventDefault(); 26 | if (this.state.text.length) { 27 | this.props.newSheetFormHandler(e, this.state.text); 28 | this.setState({ text: "" }); 29 | } 30 | }; 31 | 32 | public render() { 33 | return ( 34 |
35 | Add a sheet: 36 |
37 | 38 |
39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/client/components/sheet-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "../styles.scss"; // tslint:disable-line 3 | 4 | 5 | interface ISheetButtonProps { 6 | name: any, 7 | deleteButtonHandler: any, 8 | clickSheetNameHandler: any, 9 | } 10 | 11 | 12 | const SheetButton = (props: ISheetButtonProps) => { 13 | const sheetIndex = props.name.sheetIndex; 14 | const sheetName = props.name.text; 15 | const isActiveSheet = props.name.isActive; 16 | 17 | const deleteSheet = (e) => props.deleteButtonHandler(e, sheetIndex); 18 | const submitSheet = (e) => props.clickSheetNameHandler(e, sheetName); 19 | 20 | return ( 21 |
22 | 25 | 29 | {sheetName} 30 | 31 |
32 | ); 33 | }; 34 | 35 | export default SheetButton; 36 | -------------------------------------------------------------------------------- /src/client/components/sheet-editor.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import FormInput from "./form-input"; 3 | import SheetButton from "./sheet-button"; 4 | 5 | interface ISheetEditorProps { 6 | state?: { 7 | names: any 8 | } 9 | } 10 | 11 | export default class SheetEditor extends React.Component { 12 | public state: { names: any[]; }; 13 | 14 | constructor(props) { 15 | super(props); 16 | this.state = { names: [] }; 17 | this.deleteButtonHandler = this.deleteButtonHandler.bind(this); 18 | this.clickSheetNameHandler = this.clickSheetNameHandler.bind(this); 19 | this.newSheetFormHandler = this.newSheetFormHandler.bind(this); 20 | } 21 | 22 | public componentDidMount() { 23 | // @ts-ignore 24 | google.script.run 25 | .withSuccessHandler((data) => this.setState({ names: data })) 26 | .withFailureHandler((error) => alert(error)) 27 | .getSheetsData(); 28 | } 29 | 30 | public deleteButtonHandler(e, sheetIndex) { 31 | // @ts-ignore 32 | return google.script.run 33 | .withSuccessHandler((data) => this.setState({ names: data })) 34 | .withFailureHandler((error) => alert(error)) 35 | .deleteSheet(sheetIndex); 36 | } 37 | 38 | public clickSheetNameHandler(e, sheetName) { 39 | // @ts-ignore 40 | return google.script.run 41 | .withSuccessHandler((data) => this.setState({ names: data })) 42 | .withFailureHandler((error) => alert(error)) 43 | .setActiveSheet(sheetName); 44 | } 45 | 46 | public newSheetFormHandler(e, newSheetTitle) { 47 | // @ts-ignore 48 | return google.script.run 49 | .withSuccessHandler((data) => this.setState({ names: data })) 50 | .withFailureHandler((error) => alert(error)) 51 | .addSheet(newSheetTitle); 52 | } 53 | 54 | public render() { 55 | let names = this.state.names; 56 | return ( 57 |
58 | 59 | {names.length ? names.map((name: any) => { 60 | return ; 66 | }) 67 | : null} 68 |
69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/client/dialog-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Dialog Template 6 | 7 | 8 |
9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/client/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import SheetEditor from "./components/sheet-editor"; 4 | 5 | 6 | ReactDOM.render(, document.getElementById("index")); 7 | -------------------------------------------------------------------------------- /src/client/styles.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Lora|Mukta'); 2 | 3 | $theme-red: #f44336; 4 | 5 | 6 | input { 7 | width: 130px; 8 | margin-left: 8px; 9 | } 10 | 11 | button { 12 | -webkit-transition-duration: 0.4s; /* Safari */ 13 | background-color: $theme-red; 14 | border: 1px solid $theme-red; 15 | color: white; 16 | display: inline-block; 17 | font-family: 'Mukta', sans-serif; 18 | font-size: 11px; 19 | height: 20px; 20 | line-height: 15px; 21 | margin-left: 3px; 22 | margin-right: 11px; 23 | padding: 3px; 24 | text-transform: uppercase; 25 | transition-duration: 0.6s; 26 | vertical-align: middle; 27 | width: 20px; 28 | } 29 | 30 | button:hover { 31 | background-color: white; 32 | color: black; 33 | } 34 | 35 | button:active { 36 | background-color: #fafafa; 37 | transform: translateY(1px); 38 | transition-duration: 0.3s; 39 | } 40 | 41 | button:focus { 42 | outline: none; 43 | } 44 | 45 | .formBlock { 46 | display: -webkit-box; 47 | font-family: 'Lora', serif; 48 | font-weight: 700; 49 | } 50 | 51 | .sheetLine { 52 | cursor: pointer; 53 | line-height: 3; 54 | height: 30px; 55 | } 56 | 57 | .sheetNameText { 58 | font-size: 14px; 59 | font-family: 'Lora', serif; 60 | } 61 | 62 | .sheetNameText.active-sheet { 63 | border-bottom: 3px solid #338236; 64 | } 65 | 66 | /* 67 | ReactCSSTransitionGroup styling 68 | */ 69 | .sheetNames-enter { 70 | opacity: 0.01; 71 | } 72 | 73 | .sheetNames-enter.sheetNames-enter-active { 74 | opacity: 1; 75 | transition: opacity 800ms ease-in; 76 | } 77 | 78 | .sheetNames-leave { 79 | opacity: 1; 80 | } 81 | 82 | .sheetNames-leave.sheetNames-leave-active { 83 | opacity: 0.01; 84 | transition: opacity 100ms ease-in; 85 | } 86 | 87 | .sheetNames-appear { 88 | opacity: 0.01; 89 | } 90 | 91 | .sheetNames-appear.sheetNames-appear-active { 92 | opacity: 1; 93 | transition: opacity .5s ease-in; 94 | } 95 | -------------------------------------------------------------------------------- /src/server/code.ts: -------------------------------------------------------------------------------- 1 | import * as publicFunctions from "./sheets-utilities"; 2 | 3 | 4 | // Expose public functions 5 | 6 | // @ts-ignore 7 | global.onOpen = publicFunctions.onOpen; 8 | // @ts-ignore 9 | global.openDialog = publicFunctions.openDialog; 10 | // @ts-ignore 11 | global.getSheetsData = publicFunctions.getSheetsData; 12 | // @ts-ignore 13 | global.addSheet = publicFunctions.addSheet; 14 | // @ts-ignore 15 | global.deleteSheet = publicFunctions.deleteSheet; 16 | // @ts-ignore 17 | global.setActiveSheet = publicFunctions.setActiveSheet; 18 | 19 | // Maybe someday.... 20 | // https://github.com/Microsoft/TypeScript/issues/19573#issuecomment-447889066 21 | -------------------------------------------------------------------------------- /src/server/sheets-utilities.ts: -------------------------------------------------------------------------------- 1 | // Use ES6/7 code 2 | const onOpen = () => { 3 | SpreadsheetApp.getUi() // Or DocumentApp or FormApp. 4 | .createMenu("Custom scripts") 5 | .addItem("Edit sheets [sample React project]", "openDialog") 6 | .addToUi(); 7 | }; 8 | 9 | const openDialog = () => { 10 | const html = HtmlService.createHtmlOutputFromFile("dialog") 11 | .setWidth(400) 12 | .setHeight(600); 13 | SpreadsheetApp 14 | .getUi() // Or DocumentApp or FormApp. 15 | .showModalDialog(html, "Sheet Editor"); 16 | }; 17 | 18 | const getSheets = () => SpreadsheetApp 19 | .getActive() 20 | .getSheets(); 21 | 22 | const getActiveSheetName = () => SpreadsheetApp 23 | .getActive() 24 | .getSheetName(); 25 | 26 | const getSheetsData = () => { 27 | const activeSheetName = getActiveSheetName(); 28 | return getSheets().map((sheet, index) => { 29 | const sheetName = sheet.getName(); 30 | return { 31 | text: sheetName, 32 | sheetIndex: index, 33 | isActive: sheetName === activeSheetName 34 | }; 35 | }); 36 | }; 37 | 38 | const addSheet = (sheetTitle) => { 39 | SpreadsheetApp 40 | .getActive() 41 | .insertSheet(sheetTitle); 42 | return getSheetsData(); 43 | }; 44 | 45 | const deleteSheet = (sheetIndex) => { 46 | const sheets = getSheets(); 47 | SpreadsheetApp 48 | .getActive() 49 | .deleteSheet(sheets[sheetIndex]); 50 | return getSheetsData(); 51 | }; 52 | 53 | const setActiveSheet = (sheetName) => { 54 | SpreadsheetApp 55 | .getActive() 56 | .getSheetByName(sheetName) 57 | .activate(); 58 | return getSheetsData(); 59 | }; 60 | 61 | export { 62 | onOpen, 63 | openDialog, 64 | getSheetsData, 65 | addSheet, 66 | deleteSheet, 67 | setActiveSheet 68 | }; 69 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "allowJs": true, 5 | "checkJs": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "jsx": "react", 10 | "lib": [ 11 | "es6", 12 | "dom", 13 | "es2017", 14 | "esnext.asynciterable" 15 | ], 16 | "types": [ 17 | "google-apps-script", 18 | "node" 19 | ], 20 | "module": "esnext", 21 | "moduleResolution": "node", 22 | "noImplicitReturns": true, 23 | "noImplicitThis": true, 24 | "noImplicitAny": false, 25 | "noUnusedLocals": false, 26 | "removeComments": true, 27 | "sourceMap": true, 28 | "strictNullChecks": true, 29 | "suppressImplicitAnyIndexErrors": true, 30 | "target": "es5", 31 | "skipLibCheck": false, 32 | "esModuleInterop": true, 33 | "allowSyntheticDefaultImports": true, 34 | "strict": true, 35 | "resolveJsonModule": true, 36 | "isolatedModules": false, 37 | "noEmit": true 38 | }, 39 | "include": [ 40 | "src" 41 | ], 42 | "exclude": [ 43 | "node_modules", 44 | "./node_modules/*" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:recommended", 4 | "tslint-react", 5 | "tslint-config-prettier" 6 | ], 7 | "rules": { 8 | "indent": false, 9 | "quotemark": [ 10 | true, 11 | "jsx-double" 12 | ], 13 | "no-object-literal-type-assertion": false, 14 | "no-var-requires": false, 15 | "no-consecutive-blank-lines": false, 16 | "no-trailing-whitespace": false, 17 | "no-submodule-imports": false, 18 | "no-console": false, 19 | "object-literal-sort-keys": false, 20 | "object-literal-key-quotes": false, 21 | "arrow-parens": false, 22 | "jsx-no-multiline-js": false, 23 | "interface-name": false, 24 | "semicolon": false, 25 | "max-line-length": false, 26 | // -- Strict errors -- 27 | // These lint rules are likely always a good idea. 28 | 29 | // Force function overloads to be declared together. This ensures readers understand APIs. 30 | "adjacent-overload-signatures": true, 31 | // Do not allow the subtle/obscure comma operator. 32 | "ban-comma-operator": true, 33 | // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules. 34 | "no-namespace": true, 35 | // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars. 36 | "no-parameter-reassignment": true, 37 | // Force the use of ES6-style imports instead of /// imports. 38 | "no-reference": true, 39 | // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the 40 | // code currently being edited (they may be incorrectly handling a different type case that does not exist). 41 | "no-unnecessary-type-assertion": true, 42 | // Disallow nonsensical label usage. 43 | "label-position": true, 44 | // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }. 45 | "no-conditional-assignment": true, 46 | // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed). 47 | "no-construct": true, 48 | // Do not allow super() to be called twice in a constructor. 49 | "no-duplicate-super": true, 50 | // Do not allow the same case to appear more than once in a switch block. 51 | "no-duplicate-switch-case": true, 52 | // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this 53 | // rule. 54 | "no-duplicate-variable": [ 55 | true, 56 | "check-parameters" 57 | ], 58 | // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should 59 | // instead use a separate variable name. 60 | "no-shadowed-variable": true, 61 | // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks. 62 | "no-empty": [ 63 | true, 64 | "allow-empty-catch" 65 | ], 66 | // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function. 67 | // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on. 68 | "no-floating-promises": true, 69 | // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when 70 | // deployed. 71 | "no-implicit-dependencies": [ 72 | true, 73 | "dev" 74 | ], 75 | // The 'this' keyword can only be used inside of classes. 76 | "no-invalid-this": true, 77 | // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead. 78 | "no-string-throw": true, 79 | // Disallow control flow statements, such as return, continue, break, and throw in finally blocks. 80 | "no-unsafe-finally": true, 81 | // Do not allow variables to be used before they are declared. 82 | "no-use-before-declare": true, 83 | // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid(); 84 | "no-void-expression": [ 85 | false, 86 | "ignore-arrow-function-shorthand" 87 | ], 88 | // Disallow duplicate imports in the same file. 89 | "no-duplicate-imports": false, 90 | // -- Strong Warnings -- 91 | // These rules should almost never be needed, but may be included due to legacy code. 92 | // They are left as a warning to avoid frustration with blocked deploys when the developer 93 | // understand the warning and wants to deploy anyway. 94 | 95 | // Warn when an empty interface is defined. These are generally not useful. 96 | "no-empty-interface": { 97 | "severity": "warning" 98 | }, 99 | // Warn when an import will have side effects. 100 | "no-import-side-effect": { 101 | "severity": "warning" 102 | }, 103 | // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for 104 | // most values and let for values that will change. 105 | "no-var-keyword": { 106 | "severity": "warning" 107 | }, 108 | // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental. 109 | "triple-equals": { 110 | "severity": "warning" 111 | }, 112 | // Warn when using deprecated APIs. 113 | "deprecation": { 114 | "severity": "warning" 115 | }, 116 | // -- Light Warnings -- 117 | // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info" 118 | // if TSLint supported such a level. 119 | 120 | // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array. 121 | // (Even better: check out utils like .map if transforming an array!) 122 | "prefer-for-of": { 123 | "severity": "warning" 124 | }, 125 | // Warns if function overloads could be unified into a single function with optional or rest parameters. 126 | "unified-signatures": { 127 | "severity": "warning" 128 | }, 129 | // Prefer const for values that will not change. This better documents code. 130 | "prefer-const": { 131 | "severity": "warning" 132 | }, 133 | // Multi-line object literals and function calls should have a trailing comma. This helps avoid merge conflicts. 134 | "trailing-comma": { 135 | "severity": "warning" 136 | }, 137 | "defaultSeverity": "error", 138 | "variable-name": [ 139 | false 140 | ] 141 | }, 142 | "linterOptions": { 143 | "exclude": [ 144 | "config/**/*.js", 145 | "node_modules/**/*.ts", 146 | "node_modules/**/*.js", 147 | "/node_modules/**/*.ts", 148 | "/node_modules/**/*.js", 149 | "./node_modules/*", 150 | "/node_modules", 151 | "coverage/lcov-report/*.js" 152 | ] 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const CopyWebpackPlugin = require("copy-webpack-plugin"); 3 | const CleanWebpackPlugin = require("clean-webpack-plugin"); 4 | const GasPlugin = require("gas-webpack-plugin"); 5 | const TSLintPlugin = require("tslint-webpack-plugin"); 6 | const TerserPlugin = require("terser-webpack-plugin"); 7 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 8 | const HtmlWebpackInlineSourcePlugin = require("html-webpack-inline-source-plugin"); 9 | 10 | 11 | // Config 12 | const destination = "dist"; 13 | const isDev = process.env.NODE_ENV !== "production"; 14 | 15 | // Bundle Dialog Template HTML 16 | const htmlPlugin = new HtmlWebpackPlugin({ 17 | template: "./src/client/dialog-template.html", 18 | filename: "dialog.html", 19 | inlineSource: ".(js|css)$" // embed all javascript and css inline 20 | }); 21 | 22 | 23 | /* Shared Config 24 | ================================== */ 25 | 26 | const sharedConfigSettings = { 27 | optimization: { 28 | minimizer: [ 29 | new TerserPlugin({ 30 | test: /\.js(\?.*)?$/i, 31 | parallel: true, 32 | sourceMap: isDev, 33 | terserOptions: { 34 | ie8: true, // Necessary for GAS compatibility 35 | mangle: false, // Necessary for GAS compatibility 36 | ecma: undefined, 37 | module: false, 38 | toplevel: false, 39 | nameCache: null, 40 | keep_classnames: undefined, 41 | safari10: false, 42 | parse: {}, 43 | compress: {}, 44 | keep_fnames: isDev, 45 | warnings: isDev, 46 | output: { 47 | beautify: isDev, 48 | comments: isDev 49 | } 50 | } 51 | }) 52 | ] 53 | }, 54 | module: {} 55 | }; 56 | 57 | 58 | /* Google Apps Script Config 59 | ================================== */ 60 | 61 | const appsscriptConfig = { 62 | name: "COPY APPSSCRIPT.JSON", 63 | entry: "./appsscript.json", 64 | plugins: [ 65 | new CleanWebpackPlugin([destination]), 66 | new CopyWebpackPlugin([ 67 | { 68 | from: "./appsscript.json" 69 | } 70 | ]) 71 | ] 72 | }; 73 | 74 | 75 | /* Client Config 76 | ================================== */ 77 | 78 | const clientConfig = Object.assign({}, sharedConfigSettings, { 79 | name: "CLIENT", 80 | entry: "./src/client/index.tsx", 81 | output: { 82 | path: path.resolve(__dirname, destination) 83 | }, 84 | resolve: { 85 | extensions: [".js", ".jsx", ".json", ".ts", ".tsx"] 86 | }, 87 | module: { 88 | rules: [ 89 | { 90 | test: /\.js$/, 91 | use: "source-map-loader", 92 | enforce: "pre", 93 | exclude: /node_modules/ 94 | }, { 95 | test: /\.[jt]sx?$/, 96 | use: "awesome-typescript-loader", 97 | exclude: /node_modules/ 98 | }, { 99 | test: /\.css$/, 100 | use: [ 101 | "style-loader", 102 | "css-loader" 103 | ] 104 | }, { 105 | test: /\.scss?$/, 106 | use: [{ 107 | loader: "style-loader" 108 | }, { 109 | loader: "css-loader" 110 | }, { 111 | loader: "sass-loader", 112 | options: { 113 | sourceMap: isDev 114 | } 115 | }] 116 | } 117 | ] 118 | }, 119 | plugins: [ 120 | htmlPlugin, 121 | new HtmlWebpackInlineSourcePlugin(), 122 | new TSLintPlugin({ 123 | files: ["./src/**/*.ts"] 124 | }) 125 | ] 126 | }); 127 | 128 | 129 | /* Server Config 130 | ================================== */ 131 | 132 | const serverConfig = Object.assign({}, sharedConfigSettings, { 133 | name: "SERVER", 134 | entry: "./src/server/code.ts", 135 | output: { 136 | filename: "code.js", 137 | path: path.resolve(__dirname, destination), 138 | libraryTarget: "this" 139 | }, 140 | resolve: { 141 | extensions: [".js", ".jsx", ".json", ".ts", ".tsx"] 142 | }, 143 | module: { 144 | rules: [ 145 | { 146 | test: /\.[jt]sx?$/, 147 | use: "awesome-typescript-loader", 148 | exclude: /node_modules/ 149 | }, { 150 | test: /\.js$/, 151 | use: "source-map-loader", 152 | enforce: "pre", 153 | exclude: /node_modules/ 154 | } 155 | ] 156 | }, 157 | plugins: [ 158 | new GasPlugin(), 159 | new TSLintPlugin({ 160 | files: ["./src/**/*.ts"] 161 | }) 162 | ] 163 | }); 164 | 165 | 166 | /* Module Exports 167 | ================================== */ 168 | 169 | module.exports = [ 170 | appsscriptConfig, 171 | clientConfig, 172 | serverConfig 173 | ]; 174 | --------------------------------------------------------------------------------