├── .eslintrc ├── .gitignore ├── CHANGELOG.md ├── i18n ├── sk.json ├── en.json ├── de.json ├── pt-BR.json ├── it.json ├── es.json └── fr.json ├── package.json ├── .github └── workflows │ └── test.yml ├── LICENSE.md ├── README.md └── index.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ "apostrophe" ] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore MacOS X metadata forks (fusefs) 2 | ._* 3 | package-lock.json 4 | *.DS_Store 5 | node_modules 6 | 7 | # Never commit a CSS map file, anywhere 8 | *.css.map 9 | 10 | # vim swp files 11 | .*.sw* 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.0.2 (2024-10-03) 4 | 5 | - Edits README and package description. No code changes. 6 | - Adds translation strings 7 | 8 | ## 1.0.1 - 2023-03-06 9 | 10 | * Removes `apostrophe` as a peer dependency. 11 | 12 | ## 1.0.0 - 2022-01-21 13 | 14 | * Initial release. 15 | -------------------------------------------------------------------------------- /i18n/sk.json: -------------------------------------------------------------------------------- 1 | { 2 | "activateGoogleSubmit": "Odoslať do Google Tabuliek", 3 | "googleSheetId": "ID Google Tabuliek", 4 | "googleSheetIdHtmlHelp": "ID sa nachádza v URL tabuliek: https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0", 5 | "googleSheetName": "Názov hárku Google Tabuliek", 6 | "googleSheetNameHelp": "Názov hárku v vašich Google tabulkách, kam chcete pridať odoslanie. Ak nie je zadaný, bude sa používať prvý hárok tabuliek.", 7 | "googleSheetRetrievalError": "Chyba pri získavaní informácií o Google tabuľkách. Skontrolujte prosím ID tabuliek a nastavenia zdieľania tabuliek.", 8 | "googleSheetSubmissionError": "Pri odosielaní do Google Tabuliek došlo k chybe." 9 | } 10 | -------------------------------------------------------------------------------- /i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "activateGoogleSubmit": "Submit to Google Spreadsheets", 3 | "googleSheetId": "Google Spreadsheet ID", 4 | "googleSheetIdHtmlHelp": "The ID is found in the spreadsheet URL: https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0", 5 | "googleSheetName": "Google Spreadsheet Sheet Name", 6 | "googleSheetNameHelp": "The name of the sheet tab in your Google spreadsheet where you want the submission appended. If not provided, the first sheet of the spreadsheet will be used.", 7 | "googleSheetRetrievalError": "Error retrieving Google Sheet information. Please check the spreadsheet ID and and spreadsheet sharing settings.", 8 | "googleSheetSubmissionError": "There was an error submitting to Google Sheets." 9 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@apostrophecms/form-submission-google", 3 | "version": "1.0.2", 4 | "description": "Google spreadsheet submission for the ApostropheCMS form builder.", 5 | "main": "index.js", 6 | "scripts": { 7 | "lint": "npm run eslint", 8 | "eslint": "eslint .", 9 | "test": "npm run lint" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/apostrophecms/form-submission-google.git" 14 | }, 15 | "homepage": "https://github.com/apostrophecms/form-submission-google#readme", 16 | "author": "Apostrophe Technologies", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "eslint-config-apostrophe": "^5.0.0" 20 | }, 21 | "dependencies": { 22 | "googleapis": "^88.2.0", 23 | "klona": "^2.0.4", 24 | "lodash.has": "^4.5.2" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /i18n/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "activateGoogleSubmit": "Einreichen an Google Tabellen", 3 | "googleSheetId": "Google Tabellen-ID", 4 | "googleSheetIdHtmlHelp": "Die ID ist in der URL der Tabelle zu finden: https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0", 5 | "googleSheetName": "Google Tabellen Blattname", 6 | "googleSheetNameHelp": "Der Name des Blatt-Tabs in Ihrer Google Tabelle, wo Sie die Einreichung hinzufügen möchten. Wenn nicht angegeben, wird das erste Blatt der Tabelle verwendet.", 7 | "googleSheetRetrievalError": "Fehler beim Abrufen der Google Tabelleninformationen. Bitte überprüfen Sie die Tabellen-ID und die Freigabeeinstellungen der Tabelle.", 8 | "googleSheetSubmissionError": "Es gab einen Fehler beim Einreichen an Google Tabellen." 9 | } 10 | -------------------------------------------------------------------------------- /i18n/pt-BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "activateGoogleSubmit": "Enviar para o Google Planilhas", 3 | "googleSheetId": "ID da Planilha do Google", 4 | "googleSheetIdHtmlHelp": "O ID pode ser encontrado na URL da planilha: https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0", 5 | "googleSheetName": "Nome da Aba da Planilha do Google", 6 | "googleSheetNameHelp": "O nome da aba na sua planilha do Google onde você deseja que a submissão seja adicionada. Se não fornecido, a primeira folha da planilha será utilizada.", 7 | "googleSheetRetrievalError": "Erro ao recuperar informações da Planilha do Google. Por favor, verifique o ID da planilha e as configurações de compartilhamento da planilha.", 8 | "googleSheetSubmissionError": "Ocorreu um erro ao enviar para o Google Planilhas." 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | branches: ["*"] 8 | 9 | workflow_dispatch: 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: [18, 20] 17 | mongodb-version: [6.0, 7.0] 18 | 19 | steps: 20 | - name: Git checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | 28 | - name: Start MongoDB 29 | uses: supercharge/mongodb-github-action@1.11.0 30 | with: 31 | mongodb-version: ${{ matrix.mongodb-version }} 32 | 33 | - run: npm install 34 | 35 | - run: npm test 36 | env: 37 | CI: true 38 | -------------------------------------------------------------------------------- /i18n/it.json: -------------------------------------------------------------------------------- 1 | { 2 | "activateGoogleSubmit": "Invia a Google Fogli", 3 | "googleSheetId": "ID del Foglio Google", 4 | "googleSheetIdHtmlHelp": "L'ID si trova nell'URL del foglio di calcolo: https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0", 5 | "googleSheetName": "Nome del Foglio Google", 6 | "googleSheetNameHelp": "Il nome della scheda del foglio nel tuo foglio di calcolo Google in cui desideri che la sottomissione venga aggiunta. Se non fornito, verrà utilizzato il primo foglio del foglio di calcolo.", 7 | "googleSheetRetrievalError": "Errore nel recupero delle informazioni del Foglio Google. Si prega di controllare l'ID del foglio di calcolo e le impostazioni di condivisione del foglio di calcolo.", 8 | "googleSheetSubmissionError": "Si è verificato un errore durante l'invio a Google Fogli." 9 | } 10 | -------------------------------------------------------------------------------- /i18n/es.json: -------------------------------------------------------------------------------- 1 | { 2 | "activateGoogleSubmit": "Enviar a Google Hojas de Cálculo", 3 | "googleSheetId": "ID de Hojas de Cálculo de Google", 4 | "googleSheetIdHtmlHelp": "El ID se encuentra en la URL de la hoja de cálculo: https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0", 5 | "googleSheetName": "Nombre de la Hoja de Cálculo de Google", 6 | "googleSheetNameHelp": "El nombre de la pestaña de la hoja en tu hoja de cálculo de Google donde deseas que se añadan las envíos. Si no se proporciona, se usará la primera hoja de la hoja de cálculo.", 7 | "googleSheetRetrievalError": "Error al recuperar la información de Google Sheet. Por favor verifica el ID de la hoja de cálculo y la configuración de compartición de la hoja de cálculo.", 8 | "googleSheetSubmissionError": "Hubo un error al enviar a Google Sheets." 9 | } 10 | -------------------------------------------------------------------------------- /i18n/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "activateGoogleSubmit": "Soumettre à Google Spreadsheets", 3 | "googleSheetId": "ID de la feuille de calcul Google", 4 | "googleSheetIdHtmlHelp": "L'ID se trouve dans l'URL de la feuille de calcul: https://docs.google.com/spreadsheets/d/spreadsheetId/edit#gid=0", 5 | "googleSheetName": "Nom de la feuille de calcul Google", 6 | "googleSheetNameHelp": "Le nom de l'onglet de la feuille dans votre feuille de calcul Google où vous souhaitez que la soumission soit ajoutée. Si non fourni, la première feuille de la feuille de calcul sera utilisée.", 7 | "googleSheetRetrievalError": "Erreur lors de la récupération des informations de la feuille de calcul Google. Veuillez vérifier l'ID de la feuille de calcul et les paramètres de partage de la feuille de calcul.", 8 | "googleSheetSubmissionError": "Une erreur s'est produite lors de la soumission aux Google Sheets." 9 | } 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Apostrophe Technologies 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
17 | 18 | This module adds an additional form submission option to the Apostrophe Forms extension. It allows website managers to configure individual forms to submit to a specific Google Docs spreadsheet. 19 | 20 | ## Installation 21 | 22 | ```bash 23 | npm install @apostrophecms/form-submission-google 24 | ``` 25 | 26 | ## Usage 27 | 28 | ### Initialization 29 | 30 | Add this module along with the other Apostrophe Form modules in `app.js` to instantiate it. 31 | 32 | ```javascript 33 | require('apostrophe')({ 34 | shortName: 'my-project', 35 | modules: { 36 | // The main form module 37 | '@apostrophecms/form': {}, 38 | '@apostrophecms/form-submission-google': {}, 39 | // ... Other form widgets 40 | } 41 | }); 42 | ``` 43 | 44 | ### Set up the Google Sheets API project 45 | 46 | 1. **Create a project in [the Google Cloud Platform API console](https://console.developers.google.com/apis/dashboard).** Enable the Google Sheets API on the project in the API Library. 47 | 2. **Create an [API service account](https://cloud.google.com/iam/docs/service-accounts)** in the Google API Console service. 48 | 3. **Save the credentials JSON file** provided with the new service account. *You may not be able to download this again.* 49 | 4. **Add the credentials file to the `modules/@apostrophecms/form` directory in your Apostrophe project as `credentials.json`.** 50 | - Note: We do not recommend committing this file to version control if your code is public. You should add it to the `.gitignore` file (for Git) and put it directly on your production server. *Alternately* you can provide the credentials as JSON in an environment variable named `GOOGLE_APPLICATION_CREDENTIALS`. 51 | 5. **Copy the service account email address.** You will need to add this as an "Edit"-level user on your Google spreadsheet as you would a human editor. 52 | 6. **Plan for the service account credentials to expire in 10 years.** The service account credentials have a long life span, but it is not infinite. 53 | 54 | ### Create your spreadsheet. 55 | 56 | The sheet must exist, but does not necessarily need to be set up with column headings before use. This can be done later by CMS users as well. There is help text in the UI directing them to make note of the spreadsheet ID and sheet name. 57 | 58 | Column headers in the Google spreadsheet must match the form field *names* (not the field labels), or else the module will add new columns to the spreadsheet. 59 | 60 | #### A warning about editing the spreadsheet 61 | 62 | Please note that you must not add any empty, unlabeled columns to the spreadsheet once submissions begin. Due to the [rules of Google's spreadsheet API](https://developers.google.com/sheets/api/guides/values#appending_values) the gap will be considered as the start of a "new table" and newly appended rows will start at that column, which is probably not what you want. If this does happen, move the data over and add a header to the empty column. 63 | 64 | ### Note on dates and times 65 | 66 | "Date Submitted" and "Time Submitted" columns are included in the Google spreadsheet automatically. These are always in [UTC (Coordinated Universal Time)](https://en.wikipedia.org/wiki/Coordinated_Universal_Time). For best results, format the Google spreadsheet columns as plain text. 67 | 68 | You can rename these by setting options on the `@apostrophecms/form` module. The column label should be set before the form is in use to keep all date/time data in the same column. 69 | 70 | ```javascript 71 | // modules/@apostrophecms/form/index.js 72 | module.exports = { 73 | options: { 74 | dateColumnLabel: 'Submission date' 75 | timeColumnLabel: 'Submission time' 76 | } 77 | }; 78 | ``` 79 | 80 | 81 | ### Modifying the submission before it is sent to Google 82 | 83 | If you wish to modify the submitted data just before it goes to Google, for instance to add a new property, you can add a handler to the `@apostrophecms/form:beforeGoogleSheetSubmit` event. This event handler may be asynchronous. 84 | 85 | The example below demonstrates adding a "Unique Key" property to the data based on the date submitted, time submitted, and an email field in the submission: 86 | 87 | ```javascript 88 | // modules/@apostrophecms/form/index.js 89 | module.exports = { 90 | handlers (self) { 91 | return { 92 | beforeGoogleSheetSubmit: { 93 | addUniqueKey (req, form, data) { 94 | data['Unique Key'] = `${data['Date Submitted']}_${data['Time Submitted']}_${data.email}`; 95 | } 96 | } 97 | }; 98 | } 99 | }; 100 | ``` 101 | 102 | The submitted spreadsheet rows will now include the additional column. 103 | 104 | ### Issues with column formatting 105 | 106 | This module sends data to Google Sheets "as entered," i.e. as if the it were typed by the user in Google Sheets. In most cases this does good things: dates are detected as dates, times as times, numbers as numbers, etc. 107 | 108 | However in certain cases, the results may be surprising. For instance, a phone number with a leading `0` and no spaces or punctuation will lose its leading `0` because this is the standard behavior of Google Sheets when it believes it has detected a number. Google does not store the zero in this situation, it is truly gone. 109 | 110 | Fortunately you can correct this by formatting the column correctly in Google Sheets. Open the sheet, select the column that will contain phone numbers, and select "Format -> Number -> Plain text". Leading zeroes will not be removed from future submissions. 111 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { google } = require('googleapis'); 2 | const fs = require('fs'); 3 | const has = require('lodash.has'); 4 | const { klona } = require('klona'); 5 | 6 | module.exports = { 7 | improve: '@apostrophecms/form', 8 | fields: { 9 | add: { 10 | googleSheetSubmissions: { 11 | label: 'aposForm:activateGoogleSubmit', 12 | type: 'boolean' 13 | }, 14 | googleSpreadsheetId: { 15 | label: 'aposForm:googleSheetId', 16 | type: 'string', 17 | required: true, 18 | htmlHelp: 'aposForm:googleSheetIdHtmlHelp', 19 | if: { 20 | googleSheetSubmissions: true 21 | } 22 | }, 23 | googleSheetName: { 24 | label: 'aposForm:googleSheetName', 25 | type: 'string', 26 | help: 'aposForm:googleSheetNameHelp', 27 | if: { 28 | googleSheetSubmissions: true 29 | } 30 | } 31 | }, 32 | group: { 33 | afterSubmit: { 34 | fields: [ 35 | 'googleSheetSubmissions', 36 | 'googleSpreadsheetId', 37 | 'googleSheetName' 38 | ] 39 | } 40 | } 41 | }, 42 | methods (self) { 43 | return { 44 | async sendToGoogle(req, form, formData) { 45 | if (form.googleSheetSubmissions === true) { 46 | // Don't modify the original, this would frustrate other custom 47 | // submission handlers 48 | const data = klona(formData); 49 | const timeRegex = /^(.*?)T(.*?)(\..*)$/; 50 | const timeFields = (new Date()).toISOString().match(timeRegex); 51 | 52 | const dateColumn = self.options.dateColumnLabel || 'Date Submitted'; 53 | const timeColumn = self.options.timeColumnLabel || 'Time Submitted'; 54 | 55 | data[dateColumn] = timeFields[1]; 56 | data[timeColumn] = timeFields[2]; 57 | 58 | await self.emit('beforeGoogleSheetSubmit', req, form, data); 59 | 60 | if (!form.googleSheetName) { 61 | try { 62 | form.googleSheetName = await self.getFirstSheet(form.googleSpreadsheetId); 63 | } catch (error) { 64 | form.googleSheetName = null; 65 | self.apos.util.error('⚠️ Google sheet info request error: ', error); 66 | } 67 | 68 | if (!form.googleSheetName) { 69 | if (req.user) { 70 | self.apos.notify(req, 'aposForm:googleSheetRetrievalError', { 71 | type: 'error', 72 | dismiss: true 73 | }); 74 | } 75 | 76 | return null; 77 | } 78 | } 79 | 80 | const target = { 81 | spreadsheetId: form.googleSpreadsheetId, 82 | sheetName: form.googleSheetName 83 | }; 84 | 85 | // Get the header row titles. 86 | let header; 87 | 88 | try { 89 | header = await self.getHeaderRow(target); 90 | } catch (err) { 91 | self.apos.util.error('⚠️ @apostrophecms/form Google Sheets submission error: ', err); 92 | 93 | if (req.user) { 94 | self.apos.notify(req, 'aposForms:googleSheetSubmissionError', { 95 | type: 'error', 96 | dismiss: true 97 | }); 98 | } 99 | return null; 100 | } 101 | 102 | // Rework form submission data to match headers. If no column exists 103 | // for a form value, add it. 104 | const liveColumns = [ ...header ]; 105 | const newRow = []; 106 | 107 | header.forEach(column => { 108 | self.formatData(data, column); 109 | 110 | newRow.push(data[column] || ''); 111 | 112 | delete data[column]; 113 | }); 114 | 115 | // Add a column header for any data properties left-over. 116 | for (const key in data) { 117 | self.formatData(data, key); 118 | 119 | header.push(key); 120 | newRow.push(data[key]); 121 | } 122 | 123 | // Update the spreadsheet header if necessary. 124 | if (liveColumns.length !== header.length) { 125 | await self.updateHeader(header, target); 126 | } 127 | // Make post request to the google sheet. 128 | await self.appendSubmission(newRow, target); 129 | } 130 | }, 131 | formatData (data, key) { 132 | if (Array.isArray(data[key])) { 133 | data[key] = data[key].join(','); 134 | } 135 | 136 | data[key] = typeof data[key] === 'string' 137 | ? data[key] 138 | : JSON.stringify(data[key]); 139 | }, 140 | async getFirstSheet (spreadsheetId) { 141 | const spreadsheet = await self.sheets.spreadsheets.get({ spreadsheetId }); 142 | if (!spreadsheet || !has(spreadsheet, [ 143 | 'data', 'sheets', 0, 'properties', 'title' 144 | ])) { 145 | return null; 146 | } 147 | return spreadsheet.data.sheets[0].properties.title; 148 | }, 149 | async getHeaderRow (target) { 150 | const headerRes = await self.sheets.spreadsheets.values.get({ 151 | spreadsheetId: target.spreadsheetId, 152 | majorDimension: 'ROWS', 153 | range: `${target.sheetName}!1:1` 154 | }); 155 | 156 | return headerRes.data.values ? headerRes.data.values[0] : []; 157 | }, 158 | async updateHeader (newHeader, target) { 159 | return self.sheets.spreadsheets.values.update({ 160 | spreadsheetId: target.spreadsheetId, 161 | range: `${target.sheetName}!1:1`, 162 | valueInputOption: 'RAW', 163 | responseDateTimeRenderOption: 'FORMATTED_STRING', 164 | resource: { 165 | range: `${target.sheetName}!1:1`, 166 | majorDimension: 'ROWS', 167 | values: [ 168 | newHeader 169 | ] 170 | } 171 | }); 172 | }, 173 | async appendSubmission(newRow, target) { 174 | await self.sheets.spreadsheets.values.append({ 175 | spreadsheetId: target.spreadsheetId, 176 | range: `${target.sheetName}`, 177 | valueInputOption: 'USER_ENTERED', 178 | insertDataOption: 'INSERT_ROWS', 179 | responseDateTimeRenderOption: 'FORMATTED_STRING', 180 | resource: { 181 | values: [ 182 | newRow 183 | ] 184 | } 185 | }); 186 | } 187 | }; 188 | }, 189 | async init (self) { 190 | // Set the environment variable for API auth. 191 | const confFolder = self.__meta.chain[self.__meta.chain.length - 1].dirname; 192 | let credentialsFile; 193 | 194 | if (fs.existsSync(`${confFolder}/credentials.json`)) { 195 | credentialsFile = `${confFolder}/credentials.json`; 196 | } 197 | 198 | process.env.GOOGLE_APPLICATION_CREDENTIALS ||= credentialsFile; 199 | 200 | if ( 201 | process.env.GOOGLE_APPLICATION_CREDENTIALS && 202 | process.env.GOOGLE_APPLICATION_CREDENTIALS !== 'undefined' 203 | ) { 204 | try { 205 | // Make google auth connection. 206 | self.googleSheetAuth = await google.auth.getClient({ 207 | scopes: [ 'https://www.googleapis.com/auth/spreadsheets' ] 208 | }); 209 | } catch (error) { 210 | self.apos.util.error('⚠️ Google Authentication Error: ', error); 211 | return; 212 | } 213 | 214 | self.sheets = google.sheets({ 215 | version: 'v4', 216 | auth: self.googleSheetAuth 217 | }); 218 | } else { 219 | self.apos.util.warnDev('No credentials found for @apostrophecms/form-submission-google.'); 220 | } 221 | }, 222 | handlers (self) { 223 | return { 224 | submission: { 225 | googleSheetSubmission (req, form, data) { 226 | if (self.googleSheetAuth && self.sheets) { 227 | self.sendToGoogle(req, form, data); 228 | } 229 | } 230 | } 231 | }; 232 | } 233 | }; 234 | --------------------------------------------------------------------------------