├── Code.gs ├── LICENSE ├── NoV8.gs ├── README.md └── appsscript.json /Code.gs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Laura Taylor 3 | * (https://github.com/techstreams/TSWorkflow) 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 | */ 23 | 24 | /* 25 | * This function adds a 'Purchase Request Workflow' menu to the workflow Sheet when opened 26 | */ 27 | function onOpen() { 28 | const ui = SpreadsheetApp.getUi(); // Sheet UI 29 | ui.createMenu('Purchase Request Workflow') 30 | .addSubMenu(ui.createMenu('⚙️ Configure') 31 | .addItem('⚙️ 1) Setup Workflow Config', 'Workflow.configure') 32 | .addSeparator() 33 | .addItem('⚙️ 2) Setup Request Sheet', 'Workflow.initialize')) 34 | .addSeparator() 35 | .addItem('✏️ Update Request', 'Workflow.update') 36 | .addToUi(); 37 | } 38 | 39 | /* 40 | * Workflow Class - Purchase Requests 41 | */ 42 | class Workflow { 43 | 44 | /* 45 | * Constructor function 46 | */ 47 | constructor() { 48 | const self = this; 49 | self.ss = SpreadsheetApp.getActiveSpreadsheet(); 50 | self.configSheet = self.ss.getSheetByName('Config'); 51 | self.employeeSheet = self.ss.getSheetByName('Employees'); 52 | } 53 | 54 | /* 55 | * This static method populates the workflow Sheet's 'Config' tab with workflow 56 | * asset URLs and associates the workflow Form destination with the workflow Sheet 57 | */ 58 | static configure() { 59 | const workflow = new Workflow(); 60 | workflow.setupConfig_(); 61 | } 62 | 63 | /* 64 | * This static method generates a new purchase request document from a form submission, 65 | * replaces template markers, shares document with requester/supervisor and sends email notification 66 | * @param {Object} e - event object passed to the form submit function 67 | */ 68 | static generate(e) { 69 | const workflow = new Workflow(); 70 | let date, doc, email, requestFile, submitDate, viewers; 71 | // Create and format submit date object from form submission timestamp 72 | date = new Date(e.namedValues['Timestamp'][0]); 73 | submitDate = workflow.getFormattedDate_(date, "MM/dd/yyyy hh:mm:ss a (z)"); 74 | // Copy the purchase request template document and move copy to generated requests Drive folder 75 | requestFile = workflow.copyRequestTemplate_('B2', e.namedValues['Requester Name'][0]); 76 | workflow.moveRequestFile_('B3', requestFile); 77 | // Retrieve requester and requester supervisor information for request document sharing and email notifications 78 | viewers = workflow.getViewers_(e.namedValues['Requester Name'][0]); 79 | // Open generated request document, replace template markers, update request status and save/close document 80 | doc = DocumentApp.openById(requestFile.getId()); 81 | workflow.replaceTemplateMarkers_(doc, e.namedValues, viewers, submitDate); 82 | workflow.updateStatus_(doc, 'New', submitDate); 83 | // Add requester and supervisor (if exists) to generated request document and set 'VIEW' sharing 84 | if (viewers.emails.length > 0) { 85 | requestFile.addViewers(viewers.emails).setSharing(DriveApp.Access.PRIVATE, DriveApp.Permission.VIEW); 86 | } 87 | // Update workflow request range in form submission tab 88 | workflow.updateWorkflowFields_(e.range.getRow(), [[requestFile.getUrl(), 'New', '', workflow.getFormattedDate_(date, "M/d/yyyy k:mm:ss")]]); 89 | // Generate notification email body and send to requester, supervisor and Sheet owner 90 | email = `New Purchase Request from: ${viewers.requester.name}<\/strong>

91 | See request document here<\/a>`; 92 | viewers.emails.push(Session.getEffectiveUser().getEmail()); 93 | workflow.sendNotification_(viewers.emails, `New ${doc.getName()}`, email); 94 | } 95 | 96 | /* 97 | * This static method adds additional fields and formatting to the form submission tab 98 | * and sets up the form submit trigger 99 | * @param {string} triggerFunction - name of trigger function to execute on form submission 100 | */ 101 | static initialize(triggerFunction = 'Workflow.generate') { 102 | const workflow = new Workflow(); 103 | workflow.initializeRequestSheet_(triggerFunction); 104 | } 105 | 106 | 107 | /* 108 | * This static method updates the purchase request document with status updates 109 | * from form submission tab highlighted row and sends email notification 110 | */ 111 | static update() { 112 | const workflow = new Workflow(); 113 | let activeRowRange, activeRowValues, email, date, doc, lastupdate, recipients; 114 | // Create and format date object for 'last update' timestamp 115 | date = new Date(); 116 | lastupdate = workflow.getFormattedDate_(date, "MM/dd/yyyy hh:mm:ss a (z)"); 117 | // Get updated workflow request range and process if valid 118 | activeRowRange = workflow.getWorkflowFields_(); 119 | if (activeRowRange) { 120 | // Get valid workflow request range values 121 | activeRowValues = activeRowRange.getValues(); 122 | // Get and open associated purchase request document 123 | doc = DocumentApp.openByUrl(activeRowValues[0][0]); 124 | // Get emails of document editors and viewers for email notification recipients 125 | recipients = doc.getEditors() 126 | .map(editor => editor.getEmail()) 127 | .concat(doc.getViewers().map(viewer => viewer.getEmail())); 128 | // Get request document status table (last table), populate and save/close 129 | workflow.updateStatus_(doc, activeRowValues[0][1], lastupdate, activeRowValues[0][2]); 130 | // Update workflow request range 'Last Update' cell with formatted timestamp 131 | activeRowValues[0][3] = workflow.getFormattedDate_(date, "M/d/yyyy k:mm:ss"); 132 | workflow.updateWorkflowFields_(activeRowRange.getRow(), activeRowValues); 133 | // Generate notification email body and send to requester, supervisor and Sheet owner 134 | email = `Purchase Request Status Update: ${activeRowValues[0][1]}<\/strong>

135 | See request document
here<\/a>`; 136 | workflow.sendNotification_(recipients.join(','), `Updated Status: ${doc.getName()}`, email); 137 | // Display request update message in Sheet 138 | workflow.sendSSMsg_('Request has been updated.', 'Request Updated!'); 139 | } 140 | } 141 | 142 | /* 143 | * This method make a copy of the purchase request template and updates the file name 144 | * @param {string} configRange - config range for purchase request URL in A1 notation 145 | * @param {string} requesterName - name of requester from form submission 146 | * @return {File} Google Drive file 147 | */ 148 | copyRequestTemplate_(configRange, requesterName) { 149 | const self = this; 150 | let urlParts, templateFile, requestFile; 151 | // Retrieve purchase request template from Drive 152 | urlParts = self.configSheet.getRange(configRange).getValue().split('/'); 153 | templateFile = DriveApp.getFileById(urlParts[urlParts.length - 2]); 154 | // Make a copy of the request template file and update new file name 155 | requestFile = templateFile.makeCopy(); 156 | requestFile.setName(`Purchase Request - ${requesterName}`); 157 | return requestFile; 158 | } 159 | 160 | /* 161 | * This method adds additional fields and formatting to the form submission tab and sets up the submit trigger 162 | * @param {string} triggerFunction - name of trigger function to execute on form submission 163 | * @return {Workflow} this object for chaining 164 | */ 165 | initializeRequestSheet_(triggerFunction) { 166 | const self = this, // active spreadsheet 167 | formSheet = self.ss.getSheets()[0]; // form submission tab - assumes first location 168 | formSheet.activate(); 169 | // Get form submission tab header row, update background color (yellow) and bold font 170 | formSheet.getRange(1, 1, 1, formSheet.getLastColumn()) 171 | .setBackground('#fff2cc') 172 | .setFontWeight('bold'); 173 | // Insert four workflow columns, set header values and update background color (green) 174 | formSheet.insertColumns(1, 4); 175 | formSheet.getRange(1, 1, 1, 4) 176 | .setValues([['Purchase Request Doc', 'Status', 'Status Comments', 'Last Update']]) 177 | .setBackground('#A8D7BB'); 178 | // Set data validation on status column to get dropdown on every form submit entry 179 | formSheet.getRange('B2:B') 180 | .setDataValidation(SpreadsheetApp.newDataValidation() 181 | .requireValueInList(['New', 'Pending', 'Approved', 'Declined'], true) 182 | .setHelpText('Please select a status') 183 | .build()); 184 | // Set date format on 'Last Update' column 185 | formSheet.getRange('D2:D').setNumberFormat("M/d/yyyy hh:mm:ss"); 186 | // Remove any existing form submit triggers and create new 187 | ScriptApp.getProjectTriggers() 188 | .filter(trigger => trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT && trigger.getHandlerFunction() === triggerFunction) 189 | .forEach(trigger => ScriptApp.deleteTrigger(trigger)); 190 | ScriptApp.newTrigger(triggerFunction) 191 | .forSpreadsheet(self.ss) 192 | .onFormSubmit() 193 | .create(); 194 | return self; 195 | } 196 | 197 | /* 198 | * This method formats a date using the Google Sheet timezone 199 | * @param {Date} date - Javascript Date object 200 | * @param {string} format - string representing the desired date format 201 | * @return {string} formatted date string 202 | */ 203 | getFormattedDate_(date, format) { 204 | const self = this; 205 | return Utilities.formatDate(date, self.ss.getSpreadsheetTimeZone(), format); 206 | } 207 | 208 | /* 209 | * This method iterates over employee data to get requester and supervisor information 210 | * @param {string} requesterName - name of requester from form submission 211 | * @return {Object} requester and supervisor information for request sharing and notifications 212 | */ 213 | getViewers_(requesterName) { 214 | const self = this, 215 | employees = self.employeeSheet.getDataRange().getValues(), 216 | viewers = {}; 217 | let supervisor; 218 | // Shift off header row 219 | employees.shift(); 220 | // Find form submit requester 221 | viewers.requester = employees.filter(row => row[0] === requesterName) 222 | .map((row) => ({ name: row[0], email: row[1], phone: row[2], supervisor: row[3] }))[0]; 223 | viewers.emails = viewers.requester.email !== '' ? [viewers.requester.email] : []; 224 | // Find requester's supervisor 225 | supervisor = employees.filter(row => row[0] === viewers.requester.supervisor) 226 | .map((row) => ({ name: row[0], email: row[1], phone: row[2] })); 227 | if (supervisor.length > 0) { 228 | viewers.supervisor = { name: supervisor[0].name, email: supervisor[0].email, phone: supervisor[0].phone }; 229 | if (supervisor[0].email !== '') { 230 | viewers.emails.push(supervisor[0].email); 231 | } 232 | } else { 233 | viewers.supervisor = { name: 'N/a', email: 'N/a', phone: 'N/a' }; 234 | } 235 | return viewers; 236 | } 237 | 238 | /* 239 | * This method retrieves the workflow request range for selected row (if selection is valid) 240 | * If selection is invalid display a Sheet message 241 | * @return {Range} workflow fields range from active selection 242 | */ 243 | getWorkflowFields_() { 244 | const self = this, 245 | activeSheet = self.ss.getActiveSheet(); 246 | let activeRowRange = null, activeRange, activeRowNum; 247 | // Ensure user is on form submission tab - if not show an error and exit 248 | if (activeSheet.getIndex() !== 1) { 249 | self.sendSSMsg_('Select sheet containing purchase requests.', 'Operation Not Valid on Sheet!'); 250 | return activeRowRange; 251 | } 252 | // Get the active range (selected row) 253 | activeRange = activeSheet.getActiveRange(); 254 | // Ensure there is an active row selected - if not show an error and exit 255 | if (!activeRange) { 256 | self.sendSSMsg_('Select a valid row to process.', 'No Row Selected!'); 257 | return activeRowRange; 258 | } 259 | // Get the index of first row in the active range 260 | activeRowNum = activeRange.getRowIndex(); 261 | // Ensure the active row is within the form submission range - if not show an error 262 | if (activeRowNum === 1 || activeRowNum > activeSheet.getLastRow()) { 263 | self.sendSSMsg_('Select a valid row.', 'Selected Row Out Of Range!'); 264 | return activeRowRange; 265 | } 266 | // Get the first 4 column range from active row 267 | activeRowRange = activeSheet.getRange(activeRowNum, 1, 1, 4); 268 | return activeRowRange; 269 | } 270 | 271 | /* 272 | * This method moves the generated purchase request document to the generated requests folder in Google Drive 273 | * @param {string} configRange - config range for generated requests folder URL in A1 notation 274 | * @param {File} requestFile - purchase request file 275 | * @return {Workflow} this object for chaining 276 | */ 277 | moveRequestFile_(configRange, requestFile) { 278 | const self = this; 279 | let urlParts, parentFolders, requestFolder; 280 | // Retrieve purchase requests folder from Drive 281 | urlParts = self.configSheet.getRange(configRange).getValue().split('/'); 282 | requestFolder = DriveApp.getFolderById(urlParts[urlParts.length - 1]); 283 | // Add copied request file to generated requests folder 284 | requestFolder.addFile(requestFile); 285 | // Iterate through request file parent folders and remove file 286 | // from folders which don't match generated requests folder 287 | parentFolders = requestFile.getParents(); 288 | while (parentFolders.hasNext()) { 289 | let f = parentFolders.next(); 290 | if (f.getId() !== requestFolder.getId()) { 291 | f.removeFile(requestFile); 292 | } 293 | } 294 | return self; 295 | } 296 | 297 | /* 298 | * This method replaces request document template markers with values passed from form submission and other data 299 | * @param {Document} doc - generated request document 300 | * @param {Object} requestVals - form submission fields 301 | * @param {Object} viewers - requester and supervisor information 302 | * @param {string} submitDate - formatted date string 303 | * @return {Workflow} this object for chaining 304 | */ 305 | replaceTemplateMarkers_(doc, requestVals, viewers, submitDate) { 306 | const self = this, 307 | docBody = doc.getBody(); 308 | // Replace request document template markers with values passed from form submission 309 | Object.keys(requestVals).forEach(key => docBody.replaceText(Utilities.formatString("{{%s}}", key), requestVals[key][0])); 310 | // Replace submit date, requester and supervisor data 311 | // NOTE: Requester name replaced by requestVals 312 | docBody.replaceText("{{Submit Date}}", submitDate); 313 | docBody.replaceText("{{Requester Email}}", viewers.requester.email); 314 | docBody.replaceText("{{Requester Phone}}", viewers.requester.phone); 315 | docBody.replaceText("{{Supervisor Name}}", viewers.supervisor.name); 316 | docBody.replaceText("{{Supervisor Email}}", viewers.supervisor.email); 317 | docBody.replaceText("{{Supervisor Phone}}", viewers.supervisor.phone); 318 | return self; 319 | } 320 | 321 | /* 322 | * This method sends email notifications 323 | * @param {string} emails - comma separated list of recipient emails 324 | * @param {string} subject - email subject 325 | * @param {string} emailBody - email message body 326 | * @return {Workflow} this object for chaining 327 | */ 328 | sendNotification_(emails, subject, emailBody) { 329 | const self = this; 330 | GmailApp.sendEmail(emails, subject, '', { htmlBody: emailBody }); 331 | return self; 332 | } 333 | 334 | /* 335 | * This method displays Sheet messages with toast() 336 | * @param {string} message - message content 337 | * @param {string} title - message title 338 | * @return {Workflow} this object for chaining 339 | */ 340 | sendSSMsg_(msg, title) { 341 | const self = this; 342 | self.ss.toast(msg, title); 343 | return self; 344 | } 345 | 346 | /* 347 | * This method populates the 'Config' tab with workflow asset URLs 348 | * and associates the workflow Form destination with the workflow Sheet 349 | * @return {Workflow} this object for chaining 350 | */ 351 | setupConfig_() { 352 | const self = this; 353 | let requestsFolder, requestForm, ssFolder, templateDoc; 354 | self.configSheet.activate(); 355 | // Get spreadsheet parent folder - assumes all workflow documents in folder 356 | ssFolder = DriveApp.getFileById(self.ss.getId()).getParents().next(); 357 | // Get workflow assets 358 | templateDoc = ssFolder.getFilesByType(MimeType.GOOGLE_DOCS).next(); 359 | requestForm = ssFolder.getFilesByType(MimeType.GOOGLE_FORMS).next(); 360 | requestsFolder = ssFolder.getFolders().next(); 361 | // Add workflow asset URLs to ‘Config’ tab 362 | self.configSheet.getRange(1, 2, 3).setValues([[requestForm.getUrl()], [templateDoc.getUrl()], [requestsFolder.getUrl()]]); 363 | // Set the workflow Form destination to the workflow Sheet 364 | FormApp.openById(requestForm.getId()).setDestination(FormApp.DestinationType.SPREADSHEET, self.ss.getId()); 365 | return self; 366 | } 367 | 368 | /* 369 | * This method populates the request document status table and saves/closes document 370 | * @param {Document} doc - generated request document 371 | * @param {string} status - request status ('New','Pending','Approved','Declined') 372 | * @param {string} statusDate - formatted date string 373 | * @param {string} submitComments - request status comments 374 | * @return {Workflow} this object for chaining 375 | */ 376 | updateStatus_(doc, status, statusDate, statusComments = '') { 377 | const self = this, 378 | docBody = doc.getBody(), 379 | statusTable = docBody.getTables()[2]; 380 | statusTable.getRow(0).getCell(1).editAsText().setText(status); 381 | statusTable.getRow(1).getCell(1).editAsText().setText(statusDate); 382 | statusTable.getRow(2).getCell(1).editAsText().setText(statusComments); 383 | doc.saveAndClose(); 384 | return self; 385 | } 386 | 387 | /* 388 | * This method updates the selected request workflow range in the form submission tab 389 | * @param {number} row - selected request row number 390 | * @param {string[][]} vals - two-dimensional array of workflow field values to be written to selected row 391 | * @return {Workflow} this object for chaining 392 | */ 393 | updateWorkflowFields_(row, vals) { 394 | const self = this, 395 | formSheet = self.ss.getSheets()[0]; 396 | formSheet.getRange(row, 1, 1, 4).setValues(vals); 397 | return self; 398 | } 399 | 400 | } 401 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Laura Taylor 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 | -------------------------------------------------------------------------------- /NoV8.gs: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright Laura Taylor 3 | * (https://github.com/techstreams/TSWorkflow) 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 | */ 23 | 24 | /* 25 | * This function adds a 'Purchase Request Workflow' menu to the workflow Sheet when opened 26 | */ 27 | function onOpen() { 28 | var ui = SpreadsheetApp.getUi(); // Sheet UI 29 | ui.createMenu('Purchase Request Workflow') 30 | .addSubMenu(ui.createMenu('⚙️ Configure') 31 | .addItem('⚙️ 1) Setup Workflow Config', 'configure') 32 | .addSeparator() 33 | .addItem('⚙️ 2) Setup Request Sheet', 'initialize')) 34 | .addSeparator() 35 | .addItem('✏️ Update Request', 'update') 36 | .addToUi(); 37 | } 38 | 39 | 40 | /* 41 | * This function populates the workflow Sheet 'Config' tab with workflow 42 | * asset URLs and associates the workflow Form destination with the workflow Sheet 43 | */ 44 | function configure(){ 45 | var ss = SpreadsheetApp.getActiveSpreadsheet(), // active spreadsheet 46 | configSheet = ss.getSheetByName('Config'), // config tab 47 | requestsFolder, requestForm, ssFolder, templateDoc; 48 | configSheet.activate(); 49 | // Get spreadsheet parent folder - assumes all workflow asset documents in same folder 50 | ssFolder = DriveApp.getFileById(ss.getId()).getParents().next(); 51 | // Get workflow assets 52 | templateDoc = ssFolder.getFilesByType(MimeType.GOOGLE_DOCS).next(); 53 | requestForm = ssFolder.getFilesByType(MimeType.GOOGLE_FORMS).next(); 54 | requestsFolder = ssFolder.getFolders().next(); 55 | // Add workflow asset URLs to ‘Config’ tab 56 | configSheet.getRange(1, 2, 3).setValues([[requestForm.getUrl()], [templateDoc.getUrl()], [requestsFolder.getUrl()]]); 57 | // Set the workflow Form destination to the workflow Sheet 58 | FormApp.openById(requestForm.getId()).setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId()); 59 | } 60 | 61 | /* 62 | * This function adds additional fields and formatting to the form submission tab 63 | * and sets up the form submit trigger 64 | */ 65 | function initialize() { 66 | var ss = SpreadsheetApp.getActiveSpreadsheet(), // active spreadsheet 67 | formSheet = ss.getSheets()[0], // form submission tab - assumes first location 68 | triggerFunction = 'generate'; // name of function for submit trigger 69 | formSheet.activate(); 70 | // Get form submission tab header row, update background color (yellow) and bold font 71 | formSheet.getRange(1, 1, 1, formSheet.getLastColumn()) 72 | .setBackground('#fff2cc') 73 | .setFontWeight('bold'); 74 | // Insert four workflow columns, set header values and update background color (green) 75 | formSheet.insertColumns(1, 4); 76 | formSheet.getRange(1, 1, 1, 4) 77 | .setValues([['Purchase Request Doc', 'Status', 'Status Comments', 'Last Update']]) 78 | .setBackground('#A8D7BB'); 79 | // Set data validation on status column to get dropdown on every form submit entry 80 | formSheet.getRange('B2:B') 81 | .setDataValidation(SpreadsheetApp.newDataValidation() 82 | .requireValueInList(['New', 'Pending', 'Approved', 'Declined'], true) 83 | .setHelpText('Please select a status') 84 | .build()); 85 | // Set date format on 'Last Update' column 86 | formSheet.getRange('D2:D').setNumberFormat("M/d/yyyy hh:mm:ss"); 87 | // Remove any existing form submit triggers and create new 88 | ScriptApp.getProjectTriggers().filter(function(trigger) { 89 | return trigger.getEventType() === ScriptApp.EventType.ON_FORM_SUBMIT && trigger.getHandlerFunction() === triggerFunction; 90 | }).forEach(function(trigger){ ScriptApp.deleteTrigger(trigger) }); 91 | ScriptApp.newTrigger(triggerFunction).forSpreadsheet(ss).onFormSubmit().create(); 92 | } 93 | 94 | /* 95 | * This function generates a new purchase request document from a form submission, 96 | * replaces template markers, shares document with requester/supervisor and sends email notification 97 | * @param {Object} e - event object passed to form submit function 98 | */ 99 | function generate(e) { 100 | var ss = SpreadsheetApp.getActiveSpreadsheet(), // active spreadsheet 101 | configSheet = ss.getSheetByName('Config'), // config tab 102 | employeeSheet = ss.getSheetByName('Employees'), // employees stabheet 103 | formSheet = ss.getSheets()[0], // form submission tab - assumes first tabsh 104 | date, doc, email, lastupdate, requestFile, submitDate, viewers; 105 | // Create and format submit date object from form submission timestamp 106 | date = new Date(e.namedValues['Timestamp'][0]); 107 | submitDate = Utilities.formatDate(date, ss.getSpreadsheetTimeZone(), "MM/dd/yyyy hh:mm:ss a (z)"); 108 | // Copy the purchase request template document and move copy to generated requests Drive folder 109 | requestFile = copyRequestTemplate_(configSheet, 'B2', e.namedValues['Requester Name'][0]); 110 | moveRequestFile_(configSheet, 'B3', requestFile); 111 | // Retrieve requester and requester supervisor information for request document sharing and email notifications 112 | viewers = getViewers_(employeeSheet, e.namedValues['Requester Name'][0]); 113 | // Open generated request document, replace template markers, update request status and save/close document 114 | doc = DocumentApp.openById(requestFile.getId()); 115 | replaceTemplateMarkers_(doc, e.namedValues, viewers, submitDate); 116 | updateStatus_(doc, 'New', submitDate, ''); 117 | // Add requester and supervisor (if exists) to generated request document and set 'VIEW' sharing 118 | if (viewers.emails.length > 0) { 119 | requestFile.addViewers(viewers.emails).setSharing(DriveApp.Access.PRIVATE, DriveApp.Permission.VIEW); 120 | } 121 | // Update workflow request range in form submission tab 122 | lastupdate = Utilities.formatDate(date, ss.getSpreadsheetTimeZone(), "M/d/yyyy k:mm:ss"); 123 | formSheet.getRange(e.range.getRow(), 1, 1, 4).setValues([[requestFile.getUrl(), 'New', '', lastupdate]]); 124 | // Generate notification email body and send to requester, supervisor and Sheet owner 125 | email = Utilities.formatString('New Purchase Request from: %s

See request document
here<\/a>', viewers.requester.name, doc.getUrl()); 126 | viewers.emails.push(Session.getEffectiveUser().getEmail()); 127 | GmailApp.sendEmail(viewers.emails, Utilities.formatString('New %s', doc.getName()), '', { htmlBody: email }); 128 | } 129 | 130 | 131 | /* 132 | * This function updates the purchase request document with status updates 133 | * from form submission tab highlighted row and sends email notification 134 | */ 135 | function update() { 136 | var ss = SpreadsheetApp.getActiveSpreadsheet(), // active spreadsheet 137 | configSheet = ss.getSheetByName('Config'), // config tab 138 | employeeSheet = ss.getSheetByName('Employees'), // employees tab 139 | formSheet = ss.getSheets()[0], // form submission tab - assumes first location 140 | activeRowRange, activeRowValues, email, date, doc, lastupdate, recipients; 141 | // Create and format date object for 'last update' timestamp 142 | date = new Date(); 143 | lastupdate = Utilities.formatDate(date, ss.getSpreadsheetTimeZone(), "MM/dd/yyyy hh:mm:ss a (z)"); 144 | // Get updated workflow request range and process if valid 145 | activeRowRange = getWorkflowFields_(); 146 | if (activeRowRange) { 147 | // Get valid workflow request range values 148 | activeRowValues = activeRowRange.getValues(); 149 | // Get and open associated purchase request document 150 | doc = DocumentApp.openByUrl(activeRowValues[0][0]); 151 | // Get emails of document editors and viewers for email notification recipients 152 | recipients = doc.getEditors().map(function(editor) { return editor.getEmail() }) 153 | .concat(doc.getViewers().map(function(viewer) { return viewer.getEmail() })); 154 | // Get request document status table (last table), populate and save/close 155 | updateStatus_(doc, activeRowValues[0][1], lastupdate, activeRowValues[0][2]); 156 | // Update workflow request range 'Last Update' cell with formatted timestamp 157 | activeRowValues[0][3] = Utilities.formatDate(date, ss.getSpreadsheetTimeZone(), "M/d/yyyy k:mm:ss"); 158 | formSheet.getRange(activeRowRange.getRow(), 1, 1, 4).setValues(activeRowValues); 159 | // Generate notification email body and send to requester, supervisor and Sheet owner 160 | email = Utilities.formatString('Purchase Request Status Update: %s

See request document
here<\/a>', activeRowValues[0][1], doc.getUrl()); 161 | GmailApp.sendEmail(recipients.join(','), Utilities.formatString('Updated Status: %s', doc.getName()), '', { htmlBody: email }); 162 | // Display request update message in Sheet 163 | ss.toast('Request has been updated.', 'Request Updated!'); 164 | } 165 | } 166 | 167 | 168 | /* 169 | * This function make a copy of the purchase request template and updates the file name 170 | * @param {Sheet} configSheet - config tab 171 | * @param {string} configRange - config range for purchase request URL in A1 notation 172 | * @param {string} requesterName - name of requester from form submission 173 | * @return {File} Google Drive file 174 | */ 175 | function copyRequestTemplate_(configSheet, configRange, requesterName) { 176 | var urlParts, templateFile, requestFile; 177 | // Retrieve purchase request template from Drive 178 | urlParts = configSheet.getRange(configRange).getValue().split('/'); 179 | templateFile = DriveApp.getFileById(urlParts[urlParts.length - 2]); 180 | // Make a copy of the request template file and update new file name 181 | requestFile = templateFile.makeCopy(); 182 | requestFile.setName(Utilities.formatString("Purchase Request - %s", requesterName)); 183 | return requestFile; 184 | } 185 | 186 | 187 | /* 188 | * This function moves the generated purchase request document to the generated requests folder in Google Drive 189 | * @param {Sheet} configSheet - config tab 190 | * @param {string} configRange - config range for generated requests folder URL in A1 notation 191 | * @param {File} requestFile - purchase request file 192 | */ 193 | function moveRequestFile_(configSheet, configRange, requestFile) { 194 | var urlParts, parentFolders, requestFolder; 195 | // Retrieve purchase requests folder from Drive 196 | urlParts = configSheet.getRange(configRange).getValue().split('/'); 197 | requestFolder = DriveApp.getFolderById(urlParts[urlParts.length - 1]); 198 | // Add copied request file to generated requests folder 199 | requestFolder.addFile(requestFile); 200 | // Iterate through request file parent folders and remove file 201 | // from folders which don't match generated requests folder 202 | parentFolders = requestFile.getParents(); 203 | while (parentFolders.hasNext()) { 204 | var f = parentFolders.next(); 205 | if (f.getId() !== requestFolder.getId()) { 206 | f.removeFile(requestFile); 207 | } 208 | } 209 | } 210 | 211 | 212 | /* 213 | * This function iterates over employee data to get requester and supervisor information 214 | * @param {Sheet} employeeSheet - employee tab 215 | * @param {string} requesterName - name of requester from form submission 216 | * @return {Object} requester and supervisor information for request sharing and notifications 217 | */ 218 | function getViewers_(employeeSheet, requesterName) { 219 | var employees = employeeSheet.getDataRange().getValues(), 220 | viewers = {}, 221 | supervisor; 222 | // Shift off header row 223 | employees.shift(); 224 | // Find form submit requester 225 | viewers.requester = employees.filter(function(row) { return row[0] === requesterName}) 226 | .map(function(row) { return { name:row[0], email:row[1], phone:row[2], supervisor:row[3] }})[0]; 227 | viewers.emails = viewers.requester.email !== '' ? [viewers.requester.email] : []; 228 | // Find requester's supervisor 229 | supervisor = employees.filter(function(row) { return row[0] === viewers.requester.supervisor} ) 230 | .map(function(row) { return { name:row[0], email:row[1], phone:row[2] };}); 231 | if (supervisor.length > 0) { 232 | viewers.supervisor = { name:supervisor[0].name, email:supervisor[0].email, phone:supervisor[0].phone }; 233 | if (supervisor[0].email !== '') { 234 | viewers.emails.push(supervisor[0].email); 235 | } 236 | } else { 237 | viewers.supervisor = { name: 'N/a', email: 'N/a', phone: 'N/a' }; 238 | } 239 | return viewers; 240 | } 241 | 242 | 243 | /* 244 | * This function retrieves the workflow request range for selected row (if selection is valid) 245 | * If selection is invalid display a Sheet message 246 | * @return {Range} workflow fields range from active selection 247 | */ 248 | function getWorkflowFields_() { 249 | var ss = SpreadsheetApp.getActiveSpreadsheet(), // active spreadsheet 250 | activeSheet = ss.getActiveSheet(), // active tab 251 | activeRowRange = null, // active range 252 | activeRange, activeRowNum; 253 | // Ensure user is on form submission rab - if not show an error and exit 254 | if (activeSheet.getIndex() !== 1) { 255 | ss.toast('Select sheet containing purchase requests.', 'Operation Not Valid on Sheet!'); 256 | return activeRowRange; 257 | } 258 | // Get the active range (selected row) 259 | activeRange = activeSheet.getActiveRange(); 260 | // Ensure there is an active row selected - if not show an error and exit 261 | if (!activeRange) { 262 | ss.toast('Select a valid row to process.', 'No Row Selected!'); 263 | return activeRowRange; 264 | } 265 | // Get the index of first row in the active range 266 | activeRowNum = activeRange.getRowIndex(); 267 | // Ensure the active row is within the form submission range - if not show an error 268 | if (activeRowNum === 1 || activeRowNum > activeSheet.getLastRow()) { 269 | ss.toast('Select a valid row.', 'Selected Row Out Of Range!'); 270 | return activeRowRange; 271 | } 272 | // Get the first 4 column range from active row 273 | activeRowRange = activeSheet.getRange(activeRowNum, 1, 1, 4); 274 | return activeRowRange; 275 | } 276 | 277 | /* 278 | * This function replaces request document template markers with values passed from form submission and other data 279 | * @param {Document} doc - generated request document 280 | * @param {Object} requestVals - form submission fields 281 | * @param {Object} viewers - requester and supervisor information 282 | * @param {string} submitDate - formatted date string 283 | */ 284 | function replaceTemplateMarkers_(doc, requestVals, viewers, submitDate) { 285 | var docBody = doc.getBody(); 286 | // Replace request document template markers with values passed from form submission 287 | Object.keys(requestVals).forEach(function(key) { 288 | docBody.replaceText(Utilities.formatString("{{%s}}", key), requestVals[key][0]); 289 | }); 290 | // Replace submit date, requester and supervisor data 291 | // NOTE: Requester name replaced by requestVals 292 | docBody.replaceText("{{Submit Date}}", submitDate); 293 | docBody.replaceText("{{Requester Email}}", viewers.requester.email); 294 | docBody.replaceText("{{Requester Phone}}", viewers.requester.phone); 295 | docBody.replaceText("{{Supervisor Name}}", viewers.supervisor.name); 296 | docBody.replaceText("{{Supervisor Email}}", viewers.supervisor.email); 297 | docBody.replaceText("{{Supervisor Phone}}", viewers.supervisor.phone); 298 | } 299 | 300 | 301 | /* 302 | * This function populates the request document status table and saves/closes document 303 | * @param {Document} doc - generated request document 304 | * @param {string} status - request status ('New','Pending','Approved','Declined') 305 | * @param {string} statusDate - formatted date string 306 | * @param {string} submitComments - request status comments 307 | */ 308 | function updateStatus_(doc, status, statusDate, statusComments) { 309 | var docBody = doc.getBody(), 310 | statusTable = docBody.getTables()[2]; 311 | statusTable.getRow(0).getCell(1).editAsText().setText(status); 312 | statusTable.getRow(1).getCell(1).editAsText().setText(statusDate); 313 | statusTable.getRow(2).getCell(1).editAsText().setText(statusComments); 314 | doc.saveAndClose(); 315 | } 316 | 317 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TSWorkflow 2 | 3 | *If you enjoy my [Google Workspace Apps Script](https://developers.google.com/apps-script) work, please consider buying me a cup of coffee!* 4 | 5 | 6 | [![](https://techstreams.github.io/images/bmac.svg)](https://www.buymeacoffee.com/techstreams) 7 | 8 | --- 9 | 10 | 11 | **TSWorkflow** contains the **[code](Code.gs)** for the [G Suite](https://gsuite.google.com/) workflow automation highlighted in my presentation given at **[SheetsCon-2020](https://sheetscon.com/)** - **"Automation with Apps Script"**. 12 | 13 |
14 | 15 | 16 | **TSWorkflow** showcases the power of **[Apps Script](https://developers.google.com/apps-script)** to automate [G Suite](https://gsuite.google.com/) workflow using: 17 | 18 | | GOOGLE FORMS | GOOGLE SHEETS | GOOGLE DOCS | GOOGLE DRIVE | GMAIL | 19 | | :-----------: | :---------: | :----------: | :----------: | :---: | 20 | | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | 21 | 22 |
23 | 24 | > :point_right: See this **[blog post](https://medium.com/@techstreams/g-suite-business-solutions-apps-script-powered-workflow-automation-4cb715ea5d0b)** for the **TSWorkflow overview** and **getting started guide**. 25 | 26 |
27 | 28 | > :point_right: TSWorkflow demonstrates modern [ECMAScript](https://en.wikipedia.org/wiki/ECMAScript) features supported by the new [Apps Script V8 Javascript runtime](https://developers.google.com/apps-script/guides/v8-runtime). See this **[code](NoV8.gs)** if you're looking for the non-V8 version of TSWorkflow. 29 | 30 |
31 | 32 | 33 | ## Important Notes 34 | 35 | * TSWorkflow is meant to *demonstate* [G Suite](https://gsuite.google.com/) workflow automation with [Apps Script](https://developers.google.com/apps-script) and **should not be deployed in a production environment** without further development and testing. 36 | 37 | * TSWorkflow uses __Google Drive > My Drive__. Code modifications are needed to enable it to work with [Shared drives](https://support.google.com/a/answer/7212025?hl=en). 38 | 39 | * Check the [Apps Script Dashboard](https://script.google.com) for execution errors if TSWorkflow does not work as expected. 40 | 41 | --- 42 | 43 | ## License 44 | 45 | **TSWorkflow License** 46 | 47 | © Laura Taylor ([github.com/techstreams](https://github.com/techstreams)). Licensed under an MIT license. 48 | 49 | 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: 50 | 51 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 52 | 53 | 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. 54 | 55 | -------------------------------------------------------------------------------- /appsscript.json: -------------------------------------------------------------------------------- 1 | { 2 | "timeZone": "America/Denver", 3 | "dependencies": { 4 | }, 5 | "exceptionLogging": "STACKDRIVER", 6 | "runtimeVersion": "V8" 7 | } 8 | --------------------------------------------------------------------------------