Google Apps Script: WebApp, HtmlService, LockService; Google Sheets
In this tutorial, we are going to create an interactive story chain app that we can embed into a Google Site or your own site like WordPress.
What’s a chain story, Yagi?
Maybe you did this in school. Someone wrote the first part of a story. You then gave that story to someone else to continue writing. They then pass the story on to someone else to write the next part. And so on and so forth. In the end, the story is read out and everyone laughs at the direction the story went – except that one kid silently raging over their lack of control of the narrative.
Why are we making this? How’s it going to help me?
Well, for one, I thought it would be fun. More importantly, this will allow us to have a look at how Google Apps Scripts communicates client to server-side and vice versa in a little more advanced environment than our previous tutorial. It will also give us an opportunity to look at some more parts of Google Apps Script as they relate to creating a WebApp.
Our chain story WebApp tutorial will also give us an opportunity to look at some of the pitfalls of using WebaApp. Particularly when using the execute as me permissions. Finally, this will then launch us into our follow-up tutorial on updating the WebApp to execute as the user rather than me, the owner of the app.
This tutorial is the second part of the WebApp series. However, if you can read a bit of JS, CSS and HTML, you should be able to follow along and if you get stuck you can always go back to the first tutorial:
Google Apps Script: How to create a basic interactive interface with Web Apps
Table of Contents
Let’s get started…
The Example: An interactive chain story
Embedded below is our interactive Chain Story web app. If you are feeling creative, read the story so far and then add your part to the story. It has been written by readers just like you:
Go on! Read the story and give it a try!
What’s going on?
So what’s happening here? Let’s break it down into 3 main parts:
- When the WebApp HTML loads.
- User interaction.
- Sending away the data and returning it.
When the WebApp loads
When the WebApp loads either from an embedded file or from the direct URL, the app will immediately try and call server-side and get the current story data. It will retrieve how many contributions there are and display them in the secondary header(1). Then it will load the current story inside the grey box (2).
A text area will come up in blue (3) with some instructions above and a submit (4) button below that will be disabled until the user types in a minimum amount of text.
At the bottom, is some place-marker text where the user’s text will appear once they hit enter.
user interaction
The user’s sole task is to add to the current story, by inputting their text in the blue textarea.
We don’t want them to simply send us an empty string of story data, nor do we want them just to reply with something short. So we have disabled the submit button until they have reached our arbitrary minimum number of characters. We’ve also provided a helpful red colour to the text until the minimum character length has been met.
Once the limit is met, the submit button is enabled and the colour of the text returns to black.
On the other end, we have set a limit to how many characters we want them to write (kinda like Twitter). We help them keep track of their limit with a counter just beside the submit button. Again, if they haven’t reached their limit, the tracker count will be red and also if they are approaching their max limit the tracker will turn read.
Okay, this was just plain fun to code, but it does give you an idea about what to consider with UI design.
When the user is done, they hit the Submit button.
Take a look at the video below of the process. Make sure you keep an eye on all the guiding queues for the user.
Note! If you see a cannot access page, you may be signed into multiple Gmail accounts in one browser instance. Try an close down those accounts and reload the page.
Upon clicking Submit
When the user submits the code. The text they just entered is sent along with the number of contributions to the story so far – remember, back up at that subheader at the top.
Now if the number of contributors does not match the current number of rows in our Google Sheet that we have saved our data to, then it will return and display an unsuccessful error instead of the user’s input.
This is because someone already got to replying to this portion of the story first.
However, if they were successful, it will return their story and collapse the textarea, submit button and helper text.
Neat and tidy, hey?
When the user gets to enter their story before someone else, their story is added to the next empty row of the attached spreadsheet. Check out the attached spreadsheet below.
Note that this embedded display will take a refresh and maybe a few minutes to display your story.
Now that we know how it all works. Let’s take a look at the code…
The code
Code.gs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
//######GLOBALS####### const SS_ID = "1OjezgiLg5aaie-MMWAJmDEnd_p7SySg_CedUiF2wOfY"; //Your Spreadsheed ID const SHEET_ID = "Sheet1"; // The sheet you have your story on. const COL = 1; /**##################################### * Sets up client-side HTML environment */ function doGet() { return HtmlService.createHtmlOutputFromFile('Index') .addMetaTag('viewport', 'width=device-width, initial-scale=1') .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL); }; /**##################################### * Is run when index.html is called. * * Gets story from connected Google Sheet and joins it as * a HtML modified single string of text. * * Result will be returned server-side. * * @return {string} contains story. */ function getStory(){ const ss = SpreadsheetApp.openById(SS_ID); const sheet = ss.getSheetByName(SHEET_ID); let lastRow = sheet.getLastRow(); let values = sheet.getRange(1,1,lastRow).getValues(); //Iterates through each row of the sheet adding a line // and paragraph tags and then joins it all together. let story = values.map(row => { let text = `<p> ${row} </p> <hr style="border-top: 1px dotted #fff;">` return text }).join(""); return [lastRow,story]; }; /**##################################### * Gets a value from client-side input. * * Checks if contributions match the current row length. * If match, added text to new line of connected Google * Sheet. * * Result will be returned server-side. * * @param {array} element - [0] contribution #, [1] new text. * @return {boolean|string} false if too slow or the text. */ function grabText(elements){ //Gets the lock service to lock the Google script from being used // by others while running to prevent appending too wrong row. const lock = LockService.getScriptLock(); try{ lock.waitLock(1000);//Attempts to get a lock. }catch(e){ lock.releaseLock(); return false; } //Create variables for client-side input. const previousRowCheck = elements[0]; const storyTxt = elements[1]; const ss = SpreadsheetApp.openById(SS_ID); const sheet = ss.getSheetByName(SHEET_ID); const lastRow = sheet.getLastRow(); //Check if someone has already responded to this part of the story. if(previousRowCheck == lastRow){ sheet.getRange(lastRow + 1, COL) .setValue(storyTxt); SpreadsheetApp.flush(); //Ensures all current processes to sheet are complete. lock.releaseLock(); return storyTxt }else{ lock.releaseLock(); return false; }; }; |
Index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
<!DOCTYPE html> <html> <head> <base target="_top" /> </head> <style> body { max-width: 500px; margin: auto; } .main { font-family: "Times New Roman", Times, serif; color: darkslategrey; margin: 4px; padding: 4px; } .story { border-radius: 5px; padding: 4px; background-color: rgb(230, 230, 230); text-align: justify; } #textInput { background-color: rgb(213, 237, 241); font-family: "Trebuchet MS", Verdana, sans-serif; margin: 2px; padding: 4%; border-radius: 5px; border: 1px solid #ccc; box-shadow: 5px 5px 5px #999; width: 90%; height: 90%; } #textInput:focus { border: 2px solid darkslategrey; outline: none; } #textInput:invalid { color: firebrick; } #submitBtn:disabled { color: #999; } #submitBtn:hover:enabled { background: rgb(213, 237, 241); } </style> <body> <div id="title" class="main"> <h1>The Story so far...</h1> <h3><em>Current number of contributions: <span id="contributions">###...</span></em></h3> </div> <div id="story" class="main story"><em> loading story so far...</em></div> <div id="inputInfo"> <div id="instructions" class="main"> Continue along with the chain story. But be quick! <br /> Someone might beat you to the next part. </div> <label for="textInput" class="main"><em>Enter text below:</em></label> <br /> <textarea id="textInput" name="textInput" rows="15" placeholder="Continue the story..." minlength="10" maxlength="500" ></textarea> <br /> <input class="main" id="submitBtn" type="submit" value="Submit" disabled onclick="sendData()" /> <span class="main"><span id="charCount">500</span> remaining.</span> </div> <div class="main story" id="result"> <em> Your result will appear here!</em> </div> </body> <script> //General failure handler which will report to #result div function onFailure(error){ const result = document.querySelector("#result"); result.innerHTML = `<em style="color:red">The following error occurred: ${error}</em>` }; //##################################################################### // ADDS CURRENT STORY TO DOM /****************************************************************** * Add the story to day from the Google Sheet via Apps Script * upon load. * * This is run when the google.script.run command is run successfully * * @param {array} story - 2 elements, 0: # of contributions and 1: entire story. */ function addStory(currentStory){ let contributions = document.querySelector("#contributions"); const story = document.querySelector("#story"); story.innerHTML = currentStory[1]; //Inserts story contributions.innerHTML = currentStory[0]; //Adds the number of contributions. }; /** Call to GAS to get story data retrieved from Google Sheets * * First tests if GAS code is successful, * attempts to run GAS-side function. * on failure sends to onFailure function with nature of error. * on success sends to addStory function with returned result. */ google.script.run.withFailureHandler(onFailure) .withSuccessHandler(addStory) .getStory(); //##################################################################### // CHARACTER COUNTER /** * Gets textarea and the submit button for the textarea. */ const textInput = document.querySelector("#textInput"); const submitBtn = document.querySelector("#submitBtn"); //Listen for key up and paste events. ["keyup", "paste"].map((event) => textInput.addEventListener(event, () => { //Gets require min and max attributes to help calc outcomes. const maxLen = textInput.getAttribute("maxlength"); const minLen = textInput.getAttribute("minlength"); const dif = maxLen - minLen; //Get current character length of textarea let counter = document.querySelector("#charCount"); //Get remaining count of available characters. let count = maxLen - textInput.value.length; counter.innerHTML = count; //Updates count live. if (count < 15) { counter.style.color = "red"; } else if (count <= dif) { submitBtn.disabled = false; counter.style.color = "darkslategrey"; } else if (count > dif) { submitBtn.disabled = true; counter.style.color = "firebrick"; } }) ); //##################################################################### // SEND OFF TEXTAREA DATA FOR SAVING /** * Send Text area data to sheet and updates submit notifications. * Hides textarea and helper text. * Then returns submissions and adds to DOM. */ function sendData() { //Acquire relevant tags. const text = document.querySelector("#textInput"); let contributions = document.querySelector("#contributions"); const charCount = document.querySelector("#charCount"); const btn = document.querySelector("#submitBtn"); const updateLocation = document.querySelector("#result"); btn.disabled = true; //Disable button so no more clicking. let storyInput = text.value.trim(); //Get textarea text. let lastRow = contributions.innerText; // Get current number of contributions. let valsToGAS = [lastRow, storyInput]; // Input to be sent to server-side apps script. /** * Returned the users input if successful or a warning of failure. * * @param { boolean|string } element - false if another entry was made by another user * or the users textarea test if successful. */ function onSuccess(element) { //Hide all the input info so that we only have the story text and titles. document.querySelector("#inputInfo").style.display = "none" //An error warning if the users beaten to writing the next part in the story. const tooSlowText = `<em style="color:red">Oh no! It looks like someone published the next part in the story before you!</em>`; // If element is false then the result is the tooSlowText otherwise it is the 'element' let result = element ? `<strong> ${element} </strong>` : tooSlowText; updateLocation.innerHTML = result; //Update the result div with the text. } /** Call to GAS taking with it the users entered text and current contribution number. * * First tests if GAS code is successful, * attempts to run GAS-side function. * on failure sends to onFailure function with nature of error. * on success sends to onSuccess function with returned result. */ google.script.run .withFailureHandler(onFailure) .withSuccessHandler(onSuccess) .grabText(valsToGAS); } </script> </html> |
Code breakdown
We’ll presume that you got some rudimentary JS, HTML and CSS knowledge or have been following along since the previous tutorial here. So that we can keep this short…ish. But don’t worry, we’ll highlight some of our changes and some interesting bits of code.
Code.gs
Global variables
This time around we need to set up some global variables that will provide the file ID and sheet ID to our Google Sheet where we are going to store our story data.
We’ll be keeping everything in a single column. So we will also reference column 1.
1 2 3 4 |
//######GLOBALS####### const SS_ID = "1FHFKRzfE74965mOwJfxG8B8dSOVMSUFa7ajDOzHVHiY"; const SHEET_ID = "Sheet1"; const COL = 1; |
do get()
We mentioned in our previous tutorial, that all WebApps require a doGet()
or doPost()
function to initialise the WebApp.
Everything is pretty much the same here as in our previous tutorial. However this time around we want to be able to make our Chain Story WebApp more responsive to different devices. So on a PC, we want our app to look like this:
But on a mobile device, you might want it to look like this:
Now normally, you can achieve this with modern HTML by using the metatag viewport tag in your Index.html file with something like this:
1 |
<meta name="viewport" content="width=device-width, initial-scale=1"> |
However, I soon discovered that HtmlService does not handle meta tags within the assigned HMTL file.
Instead, it has Google Apps Script side addMetaTag method. This method takes two arguments, the name of the metatag and its contents.
1 |
.addMetaTag('viewport', 'width=device-width, initial-scale=1') |
getStory()
This function is run when the Index.html first loads. Its job is to collect all the story text we stored in our Google Sheet and send it back client-side for the user to see.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/**##################################### * Is run when index.html is called. * * Gets story from connected Google Sheet and joins it as * a HtML modified single string of text. * * Result will be returned server-side. * * @return {string} contains story. */ function getStory(){ const ss = SpreadsheetApp.openById(SS_ID); const sheet = ss.getSheetByName(SHEET_ID); let lastRow = sheet.getLastRow(); let values = sheet.getRange(1,1,lastRow).getValues(); //Iterates through each row of the sheet adding a line // and paragraph tags and then joins it all together. let story = values.map(row => { let text = `<p> ${row} </p> <hr style="border-top: 1px dotted #fff;">` return text }).join(""); return [lastRow,story]; }; |
First, we use SpreadsheetApp to get our sheet by ID and then get the Google Sheet tab our story is on. We reference our global variables to do this. Lines 12-13
Next, we grab the last row with getLastRow() on our sheet object. This will get the number of the last row containing text. We’ll display this later in our HTML as contributions. Line 15
Our range of text starts at the very top of our sheet tab and runs all the way down column A. We can use getRange() to select this range. getRange()
can take a number of combinations of arguments, but for us, we are adding these three:
- row start: start of the row. In our case, row 1.
- column start: the first column in the matrix. In our case, column 1.
- number of rows: number of rows to select. Here we grab our
lastRow
variable to do this dynamically.
Once we have the range, we can then get the values with getValues. This will give us a 2d array like this:
1 2 3 4 5 6 |
values = [ ['First row of story data'], ['Second row of story data'], ['Third row of story data'], [...] ] |
Next, we use the Javascript map method to iterate through each row of story data. Line 21
While we are at it, we will add some HTML. We want to ensure that each row is on a new line so we use the <p>
for paragraph tags and then we separate each row of story data with a horizontal line with our <hr>
tag. Lines 22-24
Once all our story parts are set up, we join them together (line 25) and send them back client-side to be displayed for the user (Line 27).
grabText()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
/**##################################### * Gets a value from client-side input. * * Checks if contributions match the current row length. * If match, added text to new line of connected Google * Sheet. * * Result will be returned server-side. * * @param {array} element - [0] contribution #, [1] new text. * @return {boolean|string} false if too slow or the text. */ function grabText(elements){ //Gets the lock service to lock the Google script from being used // by others while running to prevent appending too wrong row. const lock = LockService.getScriptLock(); try{ lock.waitLock(1000);//Attempts to get a lock. }catch(e){ lock.releaseLock(); return false; } //Create variables for client-side input. const previousRowCheck = elements[0]; const storyTxt = elements[1]; const ss = SpreadsheetApp.openById(SS_ID); const sheet = ss.getSheetByName(SHEET_ID); const lastRow = sheet.getLastRow(); //Check if someone has already responded to this part of the story. if(previousRowCheck == lastRow){ sheet.getRange(lastRow + 1, COL) .setValue(storyTxt); SpreadsheetApp.flush(); //Ensures all current processes to sheet are complete. lock.releaseLock(); return storyTxt }else{ lock.releaseLock(); return false; }; }; |
The grabTex()
function is called client-side when the user enters their data and hits the submit button. It takes one parameter that is an array for two items of data:
element[0]
: Contribution – This is the displayed value of current contributors to the story. We will use this value to compare against the current row length of our spreadsheet.element[1]
: Story – The current user’s contribution to the story.
The function attempts to append the current user’s part of the story to the Google Sheet. However, if it can’t it will return a false condition back client-side which will trigger a custom error warning. On a success, though, the function will return the string of story text the user added.
Protecting from shenanigans: LockService
Our chain story app is incredibly popular. So there might be a chance that our users are trying to submit their story and add it to our Google Sheet all at the same time. This is going to make our story really silly. Also, there is a good chance that because processes are so close, some of the new rows might be edited over because the script is getting called too quickly.
We can block or lockout users from running parts of the script with GoogleAppsScript’s LockService.
There are three types of Lock Service locks:
- Document Lock – Prevents a user on the active Google document like a Sheet, Slide, Doc from being able to concurrently use the selected section of code.
- Script Lock – Prevents another user from the user the same section of code at the same time.
- User Lock – Prevents the current user from running the same section of code in, say, a callback.
We’ll be using Script Lock in our case because our users are not on an active document, they are just adding to that document (Google Sheet). Line 16
Next, we need to see if anyone else has engaged the lock on the script. This is done with the waitLock()
method. This method will attempt to create a lock for the selected amount of milliseconds in the argument. Line 18
Let’s wrap this up in a Try…Catch statement so that if we can’t get a lock in our desired time, then we will catch the issue as an error and then return a false
condition back to the client side. Lines 20-22
Setting up our variables for checking and adding data
Once we have our lock, we can then set up our variables to interact with our Google Sheet.
First, we assign our parameters to some readable variables marking the zeroeth element as our previousRowCheck
and our next element as our story text. Lines 26-27
Then, just like in our getStory()
function, we call our spreadsheet and navigate to the Google Sheet tab where we are storing our story. Lines 29-31
The highlander of the narrative
As we mentioned earlier, it would be a little silly to have more than one entry for the next part of the story. We can check to see if someone has already made a contribution to this part of the story by getting the last row again (while we have our lock on) and comparing the last row value with the previousRowCheck
. Keep in mind that the previousRowCheck
variable is the current number of contributions when the user first opened our WebApp.
We do all this with our if
statement. Line 34
Finally adding the story to the Google Sheet
If we get a match between the previousRowCheck
and the lastRow
, then we grab the range of the first empty cell. We then use the setValue method to add the user’s text to the story.
We then return the story back to our Index.html
to be displayed for the user.
Flush and release program
Before we return everything back to the client side, we should probably ensure that all of our Spreadsheet App executions have been completed before we hand things over to another user. Otherwise, we might get some more errors and overlaps again.
We can use the SpreadsheetApp.flush() method to complete any pending executions that need to be applied to the spreadsheet. Line 39
Once this is done, we can then release the lock from our LockService for another user to submit their data. Line 41
Too slow!
If the user was too slow and someone beat them to the story, we release the lock straight away and return false
back to the client side so that a warning for the user will appear.
Index.html
In the breakdown of the Index.html, we will just highlight the parts relevant to interacting with Google Apps Script along with a few interesting tidbits of Javascript in our script tags.
We’ve put all our script down the bottom here because it is not really necessary to run the visual CSS and HTML aspects of the Index.html page and the load for the initial story may take a few seconds.
onFailure()
1 2 3 4 5 |
//General failure handler which will report to #result div function onFailure(error){ const result = document.querySelector("#result"); result.innerHTML = `<em style="color:red">The following error occurred: ${error}</em>` }; |
The onFailure
function is ripped pretty much directly from the previous tutorial.
To recap, the function will run if there is an error when communicating with the server-side Code.gs code and is initialised from the withFailureHandler function.
This will give us an error notification that we have, for this tutorial returned to the results
div in the Index.html. You might just want to report a generic error to your user so they don’t see your working parts too much.
addStory()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
//##################################################################### // ADDS CURRENT STORY TO DOM /****************************************************************** * Add the story to day from the Google Sheet via Apps Script * upon load. * * This is run when the google.script.run command is run successfully * * @param {array} story - 2 elements, 0: # of contributions and 1: entire story. */ function addStory(currentStory){ let contributions = document.querySelector("#contributions"); const story = document.querySelector("#story"); story.innerHTML = currentStory[1]; //Inserts story contributions.innerHTML = currentStory[0]; //Adds the number of contributions. }; /** Call to GAS to get story data retrieved from Google Sheets * * First tests if GAS code is successful, * attempts to run GAS-side function. * on failure sends to onFailure function with nature of error. * on success sends to addStory function with returned result. */ google.script.run.withFailureHandler(onFailure) .withSuccessHandler(addStory) .getStory(); |
The addStory()
function runs when the index.html file loads for the user. Down the bottom of the code snippet, you will notice the google.script.run class which we have set up to first run a failure handler and, if all goes well, we run our success handler. Lines 28-30
The google.script.run
class will execute our Google.gs server-side getStory()
function. This will collect the story to date from our Google Sheets file and also get the total number of contributors by counting the current number of rows.
The story and contributor count will be returned to the addStory()
function as an array of two elements as the currentStory
parameter.
addStory()
first gets the contributions header and the story div (Lines 13-14). These have placeholder values in them so that the users know data will be loaded into these spots:
1 2 3 4 5 |
...html <h3><em>Current number of contributions: <span id="contributions">###...</span></em></h3> </div> <div id="story" class="main story"><em> loading story so far...</em></div> ... |
It then simply inserts the contribution number and the story into their assigned locations. Lines 17-18
Character Counter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
//##################################################################### // CHARACTER COUNTER /** * Gets textarea and the submit button for the textarea. */ const textInput = document.querySelector("#textInput"); const submitBtn = document.querySelector("#submitBtn"); //Listen for key up and paste events. ["keyup", "paste"].map((event) => textInput.addEventListener(event, () => { //Gets require min and max attributes to help calc outcomes. const maxLen = textInput.getAttribute("maxlength"); const minLen = textInput.getAttribute("minlength"); const dif = maxLen - minLen; //Get current character length of textarea let counter = document.querySelector("#charCount"); //Get remaining count of available characters. let count = maxLen - textInput.value.length; counter.innerHTML = count; //Updates count live. if (count < 15) { counter.style.color = "red"; } else if (count <= dif) { submitBtn.disabled = false; counter.style.color = "darkslategrey"; } else if (count > dif) { submitBtn.disabled = true; counter.style.color = "firebrick"; } }) ); |
The character counter is just a fun little bit of code that helps with user experience (UX) and guidance. It monitors the textarea (ID: textInput
) and records the length of the string after each button press or paste.
First, we grab the textInput
text area and also the submit button. Lines 7-8
Then we need to add an event listener for and key up or past event inside the textInput
. We do this quickly by mapping through each event and assigning it to textInput
.
For each event inside our map, we want to do the same thing.
In our textarea element, we have set the maxlength
and minlength
attribute. We want to grab these values with getAttribue()
so that we can find the difference between them and assign them to the dif
variable. We’ll use this in a moment. Lines 13-16
Next, we want to get the number of available characters remaining by subtracting the max length by the current length of values in the textInput
. (lines 18-21)
The counter is then updated with the current count remaining. Line 23
A few guiding rules
Our submit button starts its life off as disabled:
1 2 3 4 5 6 7 8 |
<input class="main" id="submitBtn" type="submit" value="Submit" disabled onclick="sendData()" /> |
We don’t want the user to just return a blank string of texts or a few short words. We want some fire, some substance, some energy! As, such, we set our minimum work count to 15.
To give your story writer a bit of a heads-up that their characters limit is drawing to a close, we check to see if the remaining number of characters is less than 15. If this is true, then we set the colour of our counter to red. Lines 25-26
Alternatively, if the count is greater than the difference between the max and min length of the textInput
, we want to enable the submit button for the user and set the text in the textarea to our regular colour. Lines 27-29
Finally, to take into account that our user might remove the text or delete it out altogether, we check if the character count is greater than our difference we want to disable the submit button again and turn our text back to our warning colour. Lines 30-32
sendData()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
//##################################################################### // SEND OFF TEXTAREA DATA FOR SAVING /** * Send Text area data to sheet and updates submit notifications. * Hides textarea and helper text. * Then returns submissions and adds to DOM. */ function sendData() { //Acquire relevant tags. const text = document.querySelector("#textInput"); let contributions = document.querySelector("#contributions"); const charCount = document.querySelector("#charCount"); const btn = document.querySelector("#submitBtn"); const updateLocation = document.querySelector("#result"); btn.disabled = true; //Disable button so no more clicking. let storyInput = text.value.trim(); //Get textarea text. let lastRow = contributions.innerText; // Get current number of contributions. let valsToGAS = [lastRow, storyInput]; // Input to be sent to server-side apps script. /** * Returned the users input if successful or a warning of failure. * * @param { boolean|string } element - false if another entry was made by another user * or the users textarea test if successful. */ function onSuccess(element) { //Hide all the input info so that we only have the story text and titles. document.querySelector("#inputInfo").style.display = "none" //An error warning if the users beaten to writing the next part in the story. const tooSlowText = `<em style="color:red">Oh no! It looks like someone published the next part in the story before you!</em>`; // If element is false then the result is the tooSlowText otherwise it is the 'element' let result = element ? `<strong> ${element} </strong>` : tooSlowText; updateLocation.innerHTML = result; //Update the result div with the text. } /** Call to GAS taking with it the users entered text and current contribution number. * * First tests if GAS code is successful, * attempts to run GAS-side function. * on failure sends to onFailure function with nature of error. * on success sends to onSuccess function with returned result. */ google.script.run .withFailureHandler(onFailure) .withSuccessHandler(onSuccess) .grabText(valsToGAS); } |
When the user has written their portion of the story and hit “Submit”, the sendData()
function is run.
First, we grab all the relevant elements of the Index.html file by their IDs. Lines 11-16
Next, we want to pretty quickly hide the “Submit” button, so that our users can’t send multiple times. We do this by updating the button disabled attribute to true
.
Preparing and sending our data server-side
Our main aim at this stage is to quickly get our user’s text and the contribution count server-side to our Code.gs script to work its magic.
On line 20, we grab the user’s story text. We quickly trim out the white space on either side of the text to tidy it up before storing it in our storyInput
variable to send.
On the next line, we grab the contributor count to compare with the current row count to ensure that no one has beaten our user to the punch.
We then put our story input and row count into the valsToGas
array to be sent server-side. Line 23
Then we head all the way down to the bottom of the function and attempt to send our data off to our server-side grabText()
function in our Code.gs file. Lines 50-53
onSuccess()
By now, you should know that if we get a failure, it will be reported with the onFailure()
function. So let’s move on to our onSuccess()
function.
The server-side grabText()
function will return the element
parameter to the onSuccess
function as ether the user’s story in a string or the false
condition if the user was too slow and someone else posted before them. Line 31
The first task that the onSuccess()
function does is hides the submit button, text input and all guiding text that is wrapped up in the #inputInfo
div. It does this using the style.display
property. Line 33
Then we set up our error warning text if the false
condition is returned from the server. Line 36
Next, we use a ternary operator to determine that if there is an element (a.k.a not false
), then return the element otherwise return the error text. We pack this into a result variable and send it to the result div. Lines 39-40
Running your WebApp
In this WebApp we have deployed our script with the following settings:
Execute as
All users that access the app are running as me. This stops the user from getting OAuth requests. It also opens up the app for use for any user even if they don’t have a Gmail account.
There are some drawbacks to this approach that we will cover in our conclusion.
Who has access
Here we have chosen anyone including anonymous users. We want everybody to have a crack at our little app, so why not?
You can limit this how you like.
If you want to learn more about deploying your WebApp, check out the previous tutorial.
Conclusion
In this tutorial, we expanded on our basic WebApp and created a Chain Story app that allowed us to look more closely into how to manage your script with LockService and make our Index.html more device friendly by adding meta tags into our HtmlService.
We also looked at a sneaky way to compare the current Google Sheet row with the initial sheet row loaded. You might find this useful when designing something like a bookings sheet ? .
Nevertheless, WebApps is not all fun and games there are some drawbacks…
Some pitfalls
Executing as me
When I published my first post on WebApp selecting the Execute the app as: Me option set off an amazing Twitter discussion of the pros and cons, and performance in general of WebApp. You can dive into the discussion here:
The discussion along with the Google App Script group advice really helped me to understand what is going on and hopefully better consolidate the advice I received here.
The main issue that was raised is that Google limits the number of simultaneous executions to 30. This isn’t just for 30 simultaneous execution within one Google Apps Script. Rather it is for all your scripts.
So if you are running a bunch of time-triggered scripts, and half a dozen web apps all on the one account or your WebApp simply gets hugely popular – because, let’s face it, you’re a star – then you are going to get the following warning:
There are too many scripts running simultaneously for this Google user account.
And your scripts are going to fail to run.
This will make your clients sad.
Which scripts, Yagi? Which script?
Simply, the thirty-first script spun up to run concurrently. It could be anyone. It could be your web app, but it could equally be another app you are attempting to run at the time.
Will my WebApp users see me for the failure I am?
Yes.
You won’t be able to hide it. Your user is going to end up with a connection error.
How likely is this going to occur for little old me?
Hmm. Well, the very unsatisfying answer is that it depends.
However, if you don’t have much going on with other scripts in your account and you are not expecting a massive onslaught of users to your WebApp all at once, then I think you will be good.
If you are using it for a small sign-up form or something that reacher a couple of 100 to 1000 users that you don’t expect to sign up all at once, then you will probably be fine. Just remember it will eat into your simultaneous executions count.
What’s the alternative?
I’m glad you asked.
You could change execute as “Me” to “Users accessing the web app”. This means that users will be running the codes as themselves and you will be drawing from the user’s set of runtimes so it will be inconsequential for them to run the code.
As you may have guessed from my previous comment, this does have its own set of issues, with authentication warnings and the demand for the user to log into their Google Account. We’ll talk about this in the next chapter of this WebApp series.
Not being able to capture user information
It might be a good idea for us to be able to capture the user’s information. For example, in our current WebApp we use the current contributor number that we gain from our initial upload and compare it with the row length when the user submits. Because this is client-side the users might be able to manipulate this count to ensure that their story gets in first. If we can get the user’s email or even a user-specific session code (I wonder if Goggle Apps Script has something like this? ? ), then we can register the start row to the user server-side and compare that with the current row once they send their story in.
What if we didn’t want users to be greedy, so we limit their access to adding to the story once a day? Or what if they have been using naughty words and we need to ban the cheeky little blighters? Well, we need some sort of record of their progress.
What if we are adding our web app to a Google Workplace paid domain account? We could use the user’s id to grab their personal profile information or something personalised.
Please, Please send me down this rabbit hole
There are a whole bunch of Google Apps Script developers much smarter than me who have explored and tested the performance of Web Apps. Here are a couple of links from the communities recent discussion on the topic:
- Concurrent processing in App Scripts: by Adam Brainysmurf Morris
- Adam Well’s response to How do I use LockService properly every time in Google Apps Script?
- Taking advantage of Web Apps with Google Apps Script: Dr. Kanshi TANAIKE
Also a big thanks to the following on their help with the discussion on this:
- Martin Hawksey – for getting the ball rolling.
- Pablo Felip for the valuable insight.
Using Google Sheets as a Database
Another problem is with using Google Sheets for a database. While our simple little chain story will never get the traffic required to really cause us problems we do need to acknowledge that at some point, Google Sheets is not a database. There are storage limits and it is a pain to try and shape it into a SQL or even a document-based NoSQL database.
It’s not designed to quickly render results from it nor carry our complex queries on the fly. There are hard cell limits to.
Just no! Bad! Don’t do it or the Google Apps Script-inati will set Eric Koleda on you to persuade you with utter logic and absolute kindness. You will melt. Trust me.
In the meantime check out Eric’s article on the subject:
So what is this WebApp stuff good for then?
Thanks, Yagi! You’ve set me up for such a great fall. I spent all this time with you learning WebApps only to be told it’s useless!
Wow! Wow! Wow! Easy there, tiger. It’s far from useless.
First, it is a fantastic tool for prototyping the front end of a project or testing the waters of how a new project may get traction.
Also, if you know you are doing a small project, this is still for you. You can even use the “Execute as me” functionality with little concern.
One recent example I had from a reader was to do a custom sign-up form for free tennis club training for seniors at a small club. Google Form’s functionality was just not right for the task. Great. This is perfect. you shouldn’t have to wind up a server and create a special database for something like this. Just build the WebApp.
If you are running projects inside your Google Workspace. You could run an administration-paid account that you don’t have any code running in (or very little active code). You could then use this for a whole bunch of users before it quits on you.
You could even build up your Google Sheets as a mini database and then make a transition to something like Firestore down the track so you have less learning overhead at the start.
The Final Bit
In our next tutorial, we will take this chain story script and modify it for use as a user accessing the WebApp. This means we will also make some tweaks to improve collections of data and create some restrictions to help give everyone a contribution to the story.
My main hope for this tutorial is to help inspire you to create a WebApp and learn a few new skills through examples. I’d love to hear what you created after this tutorial so don’t forget to comment below.
If you enjoyed the tutorial please like the article so I know that I am doing a good job and if you want to buy me a cuppa there is a donate button on the top right of the sidebar.
If you are really digging these tutorials you can also subscribe – top right of the sidebar again.
Need help with Google Workspace development?
My team of experts can help you with all of your needs, from custom app development to integrations and security. We have a proven track record of success in helping businesses of all sizes get the most out of Google Workspace.
Schedule a free consultation today to discuss your needs and get started or learn more about our services here.
~Yagi
Thank you~! I made it as a guestbook for my blog!
https://script.google.com/macros/s/AKfycbw4QH7jdMjuv6KWXfajYSZiu_fzBG-Ot2jBI2KM/exec
That’s awesome, James!
I combined this with the next blog post on getting the token / key / anonymous id and have deployed as a link in my Google Classroom for my adult English Language Learners. The link I am using (from Publish / Deploy as Web App) ends with /exec not /dev (if that makes a difference). It works for the users but the link displays with the title ‘Sign in – Google Accounts’. How do you add a display title? If I made more web apps would they all have this boring / off-putting title?
When you have a web app you like (if you are like me) there are gobs of versions in the Publish | Deploy as web app drop-down. Is there a way to clean this up and just have the good version. I read about head versions but the documentation did not match what I saw. The help was no help. Help me OB1Goat!
Yeah. That is a bit of a pain at the moment. You can’t remove versions. I think the general theory of keep versions is to apply a new update and save or deploy that update as a version, just in case it breaks and you can revert back to the previous one. For deploying slightly different web apps, you are better off creating new projects for each one.
To keep those projects together, I usually open up a directory in drive and create them in there rather than go to script.google.com . You can still have different projects reference the same document, but some of the triggers, like onEdit onOpen will not work.
Your sample app for chain story uses HtmlOutputFromFile rather than from Template. I’m trying to understand the tradeoffs between these options. Why did you not use Template; what are advantages vs disadvantages of these two options? I’m particularly looking now at security of an application. For example, does use of Template enable more functions to be in the backend and out of site of users? Thank you for the excellent tutorials. Doug
Hi Doug,
The HtmlOutputFromFile allows you to generate your HTML from the file. The only way here for you to interact with your apps script in this example is through google.script.run or one of its variants. So you can bring in data from an event listener or send data Apps Script (Server-Side) when you complete the action.
Templated HTML is used in more dynamic web-apps, sidebars and dialog windows. You can apply printing scriplets
... ?>
that can run script inline and calls Apps Script functions directly.I can’t see a security issue with either approach, but I think you would take a bit of a performance hit particularly on initial load when using the Template HTML.
For the tutorial, I really didn’t need any dynamic changes that I couldn’t do with HtmlOutputFromFile, so that was the direction I chose. Though I did consider it and in an earlier version, I did use the template.
~Yagi
Thank you for responding (May 4) about use of template vs html file. If you don’t mind, I have a couple of other questions regarding the “chain story” app.
You use sheet.getLast.Row() to insert the new story text in the spreadsheet. You’ve written about issues with this method where sheets have pre-fill columns that make the “last row” method incorrect, and you’ve presented a little function to use instead. You did not use that function in this app–I suppose because you know that the sheet will not have pre-filled columns. Others have recommended using the “append” method to “ensure’ that you are working with the last row. Does “append” ignore rows where a column is pre-filled, say with a vlookup formula that dynamically fills that column cell with reference data as new row data are added? Or is your “special last row” function needed?
My other question concerns the use of forms/form fields vs separate inputs. In your app, if instead of a single input box for story additions there were many separate inputs from the user, would you use a form and work with that object to save the inputs to a sheet, or would you use separate inputs and loop through them to save the inputs to a sheet (like you do for the single textbox input)?
Again, thank you for any response you have time to give.
Doug
Doug – I am using the story in an online class. When a student submits I save their text in an invisible area at the bottom of the page. If they experience a collision on save I retrieve the text and put it back in place. The message says someone else submitted first so check what they wrote, if you need to adjust your comment, and resubmit. Either way I clear the invisible area. I only have a small number of ESL students. They type slowly. Since I have added this code nothing has been lost. I also tried to save the email sent to me saying there was a submission as a draft in order to extract the contributor email and add it to the entry. This has worked at times but is not now. I usually have to go through the emails which contain the text of the contribution to update the spreadsheet with who said what.
Hope you are not sick of me but I do not understand the slanted quotes versus regular single quotes.
let story = values.map(row => { // do not change the slanted quotes
// let text =
<p> ${row} </p><hr style="border-top: 1px dotted #fff;">
;let text = ‘
${row}
‘;
return text;
}).join(“”);
The slanty quotes allow substitution but if I change to single quotes I see ${row} on the displayed HTML?????
L.Klein. Not sure you’ll see this as recent comments are not displayed on the Yagisanatode blog page. I just saw your latest comment come via email.
But the backquote (
) must be used when mixing literal text with variables, where the variables are enclosed in this way: ${variable}. Thus:
My literal text ${variable} and some more literal text.` The result is the text string with the value of the variable inserted.