Google Apps Script: Card Service, Google Workspace Add-on, Google Picker
So you have this awesome idea for a Google Workspace Add-on (GWAO), but you need to be able to select Google Drive files and folders as a part of your process.
Sure, you could just open up another tab and find the link and paste it in a text input or paragraph input in your Card Service build, but that is time-consuming and kinda defeats the purpose of the convenience of the sidebar.
Ideally, you would want a built-in File Picker class that would select the files and folders from the directories you need. Whelp… unfortunately, we don’t have that right now for Google Apps Script’s Card Service.
One approach might be to build out a file picker card selecting each parent’s files and folders and navigate through it like, say, a linked list. Now, I haven’t tried this approach, but looking at how slow and memory expensive it is to call your Google Drive through either Google Apps Script’s Drive App class or the Advanced Service Drive Class, I gave this a pass… for now… .
Instead, I decided to incorporate Googles File Picker API as a popup window from the sidebar, because, it’s kinda what it is designed for. Also, not gonna lie, the example at the bottom of the docs was a huge help (cough … copy and paste … cough)
Let’s take a look at what we are going to build.
Table of Contents
Google Workplace Add-on File Picker
The homepage Card
In this tutorial, we will build a Google Workspace Add-on (GWAO) that opens a basic file picker card like this:
As you can see, there is not much going on here. This card contains an information paragraph and then a placeholder where the Google Drive doc links will go. When you first load the card you get the italicised, ‘Please select your file and folder’. Once you have selected some files and/or folders, this paragraph section will update to a hyperlinked list of files/folders in your Google Drive.
Finally, we have the “Select Docs” and “Clear All” buttons. All pretty self-explanatory.
The Picker
Clicking “Select Docs” opens a window overlay of a Google Apps Script WebApp with our file picker inside (1).
You can see the delightfully helpful warning that this web app was created by not the Google.
Now you can go ahead and navigate through the directory and select one or many files and folders in a parent folder. When you are done, click Select and those files and/or folders will be recorded and added to your homepage as hyperlinks and the web app window will close.
Note, that while you are picking your documents the homepage is locked with a loading button to prevent any other tasks to occur (2). It’s for your safety…promise.
The homepage now displays your selection
Back to your homepage, you can now see that it is displaying your selection of files and folders as hyperlinks.
In the background, your files and folders are being stored in a PropertiesService.getUserProperty()
“files” object property, for you to actually do something with them. The object looks a little like this:
[{id, name, url},{id, name, url},...]
Setup
The Setup comes with a big bad warning. If you are thinking about adding the File Picker to your project it is a very good idea to manually pair your Google Apps Script project to a Standard Google Cloud Platform project before your start building your project.
Why? Because sometimes, depending on what other APIs and Services you have added to your project you may not be able to convert it afterwards. Also if you were trying to manage your Google Apps Scripts in your Google Drive directory for, neatness then you may just find – like I did – that your project won’t convert to a Standard Google Cloud Project from the default one (A glitch perhaps).
Now if you are sitting here wondering what on earth I am yammering on about with default and standard Google Cloud Platform (GCP) projects, don’t worry. I will walk you through it in a moment.
Let’s get started.
Create your project
The first thing we need to do is to set up our Google Cloud Project. Yeah. I know we have to enter that scary work of a million buttons and options that go down a myriad of rabbit holes.
This is generally the part where most tutorials gloss over because it is time consuming and boring to produce. Being one of the most boring goats I know, this is right down my alley.
Let’s get cracking…
Create your Google Apps Script Project
Create your script from the Google Apps Script homepage ( https://script.google.com/home ). Click the New Project button, top-right. Then rename your project to whatever you would like to name it.
Navigate to your Project Settings:
Scroll to the bottom and you will see the Google Cloud Platform (GCP) Project header.
Out of the box, your Google Apps Script project comes with a Default Google Cloud Project setup that is essentially hidden. It does all the setup and heavy lifting for you when you create a project and you can just work on writing your code. However, this Default setup doesn’t always work with every API and library, and sometimes you need to use the similarly named Standard GCP instead. Like when we need to use the File Picker.
Click on the Change project button and you will get some instructions.
Click on the link where it says, here. Or… well … here, to get to https://console.cloud.google.com/home/ .
This will open the big bad scary Google Cloud Platform console.
Creating a Google Cloud Console Project
In the blue bar at the top, select the project dropdown.
Weirdly, you will get a popup window with a list of any projects that you have instead of a dropdown menu. In the top-right, select ‘NEW PROJECT’.
This will send you to a new project builder. Here, you can enter the name of your project (1). It might be a good idea to name it the same as your Apps Script one for easy reference.
Once you are done, hit, Create (2)
This will build your project and return you to your last project. You will notice in the top right that a notification will appear to let you know your project is built.
If you don’t see this, select from the project ‘dropdown’ again and select your new project from the list.
You will be navigated to the dashboard of your newly created project.
Now your sharp eyes might have found the Project ID and you are eager to get this job done. Unfortunately, we are not quite there yet and if you input the project id into your Apps Script Project IDE right now you are likely to get an error like this:
How do I know? Well, let’s just say I thoroughly research my tutorials 🤣.
We’ll need to configure our OAuth consent screen
Open up the side-bar menu of your project and select Apis and services > OAuth consent screen.
User Type
Your first task is to choose between your user type.
Internal will be available if you are running a Google Workspace (paid) account. This will give your app access to users within your domain.
The External option will launch you into the test environment as part of the process of publishing an external app.
I usually click External (1) here and stay within the test environment and then either change my user type to Internal when I am ready to publish within a Google Workspace environment or if I really want to punish myself, work my way through an external publishing process to publish it on the Google Marketplace.
Once you have made your decision, click Create (2).
OAuth Consent Screen Info
Here you are setting up your consent screen information that your users will see the first time they run the app. You can see an example of this on the right-hand side of the screen:
First, you will need to enter:
- The app name
- Support Email (This will likely be you)
- The app logo – Yeah. It is a bit much if you are just building this internally, but it is what it is. If you want to do it quickly, just add the example image below.
This is what your screen should look like:
You should be able to skip the App Domain info for now, but if you intend on publishing the app you will need to update this later.
Finally, add your email in the Developer contact information and select Save and continue.
Scopes
You will now be directed to the Scopes page. Here you add the scopes for your project.
Normally these are added for you when you run your project for the first time or when you update your project. You can find your scopes in two places in your Google Apps Script editor:
- At the bottom of the Overview screen of your project.
- In your appsscript.json manifest file.
Unlike a normal project, your scopes will also need to be registered in your Google Cloud Console.
For our project, we will need the following scopes.
1 2 3 4 |
https://www.googleapis.com/auth/gmail.addons.execute https://www.googleapis.com/auth/script.locale https://www.googleapis.com/auth/drive.readonly https://www.googleapis.com/auth/drive |
It’s often hard to know what scopes you are going to use for your project. So it is likely that you will need to come back to this point and add them again.
Click on ADD OR REMOVE SCOPES. A right-sidebar will appear. You can search through and find the scopes from the list above or simply copy them and paste them in under the Manually Add Scopes header (1). Make sure each scope is on a new line.
Then click Add to Table (2).
Your main Scopes panel will update identifying what type of scopes you have.
Click SAVE AND CONTINUE (2).
Test Users
Next, you can add in any test users to your project. Simply add a Gmail or Google Workspace add-on here to give the user permissions to test your GWAO.
Note! While you are testing, you will also need to add these users as editors to your project. More on this later.
Finally, you will get a summary of your Authentication inputs. Scroll to the bottom and click > BACK TO DASHBOARD.
Add the APIs you will need
Next in the sidebar of APIs and Services select Library.
You will be navigated to the API library.
Here, search for “Google Picker”.
Connecting your Google Apps Script project to your Google Cloud project
Back in your home screen, find the Project Info card and grab the Project number. Select > Copy.
Note! Don’t worry. I’ve long since deleted the project so all the IDs and Keys won’t work anymore.
Next, head back to your Google Apps Script project tab. Keep your cloud project open in a separate tab. You will be accessing it again (I know, I know. I can feel the groans).
Anyway, back to the Safety of our Google Apps Script editor and our project. In the menu bar select, Project settings. Then scroll to the bottom.
Click Change Project. And then paste in the Google Cloud project ID. Then click the Set project button.
Your project number will be added.
Now we can go make a coffee, come back and finally write some code.
The Code
We’re going to set up our code into three parts:
Code.gs – The backend handler for the Card Service for the Google Workspace Add-on (GWAO) and storage of file data.
WebApp.gs – Initialisation of the web app and client-server call functions.
FilePicker.html – Essentially the basic index page layout with links to CSS, our Javascript for our file picker and some simple HTML to create a loading page.
JS.html – This page drives the Google Picker loading and closing process. It accesses the developer API key and the OAuth token.
appsscript.json – Google Workspace Add-ons require some manual grunt work inside this JSON file that Google Apps Script usually handles for you in other circumstances.
I’ll provide the code below and then discuss how to test and run it. If you are interested in the code breakdown, there will be a large section after that for you to follow along with.
appsscript.json
Your appsscript.json file handles the configuration of your Google Apps Script project. Card Service relies heavily on this file to set it up.
To access this file go to Project Settings and check the Show “appsscript.json” manifest file in editor box. When you return to the editor, you will see the file in your file manager side panel.
Click on the appsscript.json file and then paste in this data:
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 |
{ "timeZone": "Australia/Sydney", "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Drive", "version": "v2", "serviceId": "drive" } ] }, "exceptionLogging": "STACKDRIVER", "oauthScopes": [ "https://www.googleapis.com/auth/gmail.addons.execute", "https://www.googleapis.com/auth/script.locale", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/drive" ], "runtimeVersion": "V8", "addOns": { "common": { "name": "File Picker", "logoUrl": "https://yagisanatode.com/wp-content/uploads/2021/06/yagifolder.jpg", "useLocaleFromApp": true, "homepageTrigger": { "runFunction": "onhomepage", "enabled": true } }, "gmail": {} } } |
You will need to change the timezone details to your own timezone.
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 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 |
// ################################## CARD SERVICE FILE PICKER ################################## // Creates a file picker card that opens a Google Picker in an overlay window. // User can select as many files and folders as they wish from the picker, opening // and closing at will. They can also choose to clear their choices at any time. // The developer can then add a next button to process the selection. // // To change: // * timezone to your timezone: appsscript.json // * web-app link: Code.gs buildSection() // * File Picker API key: // // @author Yagisanatode // Check out the tutorial: //[Deploying Google Picker for a Card Service Google Workspace Add-on]{@link namepathOrURL} // ############################################################################################## // ################################## Card builder page ################################## /** * ## Initialises the card service homepage. ## * This is referenced in teh appsscript.json * @param {object} e - details of environment and user activating the GWAO (Google Workspace Add-on) * @return build homepage card. */ function onhomepage(e) { return createSelectionCard(e); }; /** * ## Initial Card Builder ## * Main function to generate the initial card on load. * @param {Object} e : Event object. * @return {CardService.Card} The card to show the user. */ function createSelectionCard(e){ const builder = CardService.newCardBuilder(); builder.addSection(buildSection()) return builder.build() }; /** * ## Rebuild Card Builder ## * This is activated after first initialisation of the homepage. * * Main function to generate the layout of the homepage card when button clicked. * @param {Object} e : Event object. * @return {CardService.Card} The card to show the user. */ function rebuildSelectionCard(e){ PropertiesService.getUserProperties().setProperty("filePick",JSON.stringify(false)); const builder = CardService.newCardBuilder(); builder.addSection(buildSection()) return CardService.newNavigation().updateCard(builder.build()) } /** * Builds a section for the card service card. * * @return {CardService.CardSection} */ function buildSection(){ // ## Widgets ## let textWidget =()=>{ return CardService .newTextParagraph() .setText("Select a file or folder using the Google Picker dialog window.") } let buttonPickerWidget =()=> { let button = CardService.newTextButton() .setText("Select Docs") .setOpenLink(CardService.newOpenLink() //Change this to the deployed URL once you are ready to deploy it. (e.g. ending in '/exec') .setUrl("<< YOUR WEB APP URL EITHER TEST OR DEPLOYED>>") //Create a window dialog containing the Google Picker. .setOpenAs(CardService.OpenAs.OVERLAY) //Reload the page once selections have been made. .setOnClose(CardService.OnClose.RELOAD)) return button; } // Must be done on a rebuild. let buttonFileRemoveWidget =()=> { let button = CardService.newTextButton() .setText("Clear all") .setOnClickAction(CardService.newAction().setFunctionName("rebuildSelectionCard")) return button } //Creates the set of buttons var buttonSet =() =>{ let bSet = CardService.newButtonSet() .addButton(buttonPickerWidget()) .addButton(buttonFileRemoveWidget()) return bSet; }; // ## End Widgets ## //Create the details section. const detailsSection = CardService.newCardSection() .setHeader("Front Page") .addWidget(textWidget()) .addWidget(getFilesAndFoldersDataWidget()) .addWidget(buttonSet()) return detailsSection; }; /** * Calls the stored files and folders if the file picker is selected * otherwise returns a request to select docs and removes files from Property Service. * * Doc links are added with their title and url as a hyperlink in each page. * * @return {CardService.TextParagraph} text widget string either request to select docs or a list of doc links. */ function getFilesAndFoldersDataWidget(){ let filePick = JSON.parse(PropertiesService.getUserProperties().getProperty("filePick")); let prop = PropertiesService.getUserProperties().getProperty("files") let paragraph = ""; if(prop == null || !filePick){ paragraph = `<i>Please select your file and folders.</i>`; clearFilesFromPropServ() // Ensures there are no files in the Properties Service; }else{ let docs = JSON.parse(prop); docs.forEach( doc =>{ paragraph += `- <a href="${doc.url}">${doc.name}</a><br>` }) } PropertiesService.getUserProperties().setProperty("filePick",JSON.stringify(false)); return CardService.newTextParagraph() .setText(paragraph) } |
You will need to change the pickerButtonWidget.setURL()
value to your own web app URL in the buildSection()
function. More on this in the Quick Use Guide below.
WebApp.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 |
// ################################## Web App page ################################## /** * Initialises the HTML webapp services providing a title and metatags. * -Enabled for Template so I can insert: <?!= include("JS"); ?> * * return {object} Html Serive object that generates the webapp. */ function doGet() { let html = HtmlService .createTemplateFromFile("FilePicker") .evaluate() .setTitle("Link Seletor") return html; }; /** * Imports or includes other files for the main html document. * * */ function include(filename){ return HtmlService.createHtmlOutputFromFile(filename) .getContent(); }; // #### Server - client connectors. #### /** * An object of picker configurations. * Called when picker is initialised. * * @return {ojbect} configurations for auth */ function pickerConfig(){ DriveApp.getRootFolder() // Try this return { oauthToken: ScriptApp.getOAuthToken(), // Add developer key from Google Cloud Project > APIs and services > Credentials developerKey: "<<ADD YOUR DEVELOPER KEY>>" } }; /** * Retrieves a list of files and folders from the File Picker popup. * Stores the files in the user's Properties Service. * @example * [{id, name, url},{id, name, url},...] * * @param {array} fileId - Array of file/folder objects. */ function storeDriveSelections(fileId){ // Append current list of files and folders. let storedDocs = JSON.parse(PropertiesService.getUserProperties() .getProperty("files")); let updateArray = () => { //Combine current list with incoming and remove duplicates. return [...new Map([...fileId,...storedDocs].map(item => [item.id, item])).values()] }; // IF not stored ids just input the fileId otherwise add both to array. let docsAll = (storedDocs === null)? fileId : updateArray(); //Add storedDocs to selected docs; PropertiesService.getUserProperties() .setProperty("files", JSON.stringify(docsAll)) // Allows us to only keep these properties when using is working on saved properties. PropertiesService.getUserProperties() .setProperty("filePick",JSON.stringify(true)); }; /** * Removes "files" data from Property service. * * Called when: * 1. Card is refreshed and is not part of the file selection process. * 2. When the "CLEAR ALL" button is selected. */ function clearFilesFromPropServ(){ // Allows us to only keep these properties when using is working on saved properties. PropertiesService.getUserProperties() .deleteProperty("files"); }; |
Again, you will update your project API key here. More on this soon.
FilePicker.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 |
<!DOCTYPE html> <html> <head> <base target="_top"> <link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons1.css" /> <?!= include("JS"); ?> <style> /* Center the message text and create black background. */ body{ background-color: black; } #message{ display: flex; justify-content: center; align-items: center; } </style> </head> <body> <!-- Provide either a processing messag or any relevant error messages. --> <div id="message" class="current">Processing...</div> <!-- Load the Google API Loader script. --> <script type="text/javascript" src="https://apis.google.com/js/api.js?onload=onApiLoad"></script> </body> </html> |
JS.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 |
<script> var pickerApiLoaded = false; /** * Use the API Loader script to dynamically load google.picker * * Initialises the Google Picker loading */ function onApiLoad() { gapi.load('picker', { 'callback': function() { pickerApiLoaded = true; } }); //Draw OAuth token and Developer key from the server. //If success runs createPicker() google.script.run .withFailureHandler(errorMessage) .withSuccessHandler(createPicker) .pickerConfig(); }; /** * Sets up and runs the Google Picker * * @param {object} config {oauthToken: ,developerKey: } * @callback pickerCallback */ function createPicker(config){ if(pickerApiLoaded && config.oauthToken){ var DIALOG_DIMENSIONS = {width: 750, height: 580}; //Picker UI layout preferences. const view = new google.picker.DocsView() .setIncludeFolders(true) .setSelectFolderEnabled(true) .setLabel('My Drive') .setParent('root'); //Builds the picker const picker = new google.picker.PickerBuilder() .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) .hideTitleBar() .setOAuthToken(config.oauthToken) .addView(view) .setDeveloperKey(config.developerKey) .setOrigin(google.script.host.origin) .setSize(DIALOG_DIMENSIONS.width, DIALOG_DIMENSIONS.height) .setCallback(pickerCallback) .build(); picker.setVisible(true); }; // Clear the config variable to hide it from users. config = null; }; // A simple callback implementation. /** * Callback that collects selected docs. * @param {object} data - Picker data. * @param {object} data.picked - Information of each picked doc. * @return {object} files and folders {url: , name: , id: , type:} */ function pickerCallback(data) { if (data.action == google.picker.Action.PICKED) { // Just grab the url, name, id and type let filesAndFolders = data.docs.map(doc => { return { url: doc.url, name: doc.name, id: doc.id, type: doc.type } }) // Sends filesAndFolders server-side to be stored. // Then runs the function ot close the Picker and WebApp window. google.script.run .withFailureHandler(errorMessage) .withSuccessHandler(closeWebAppWindow) .storeDriveSelections(filesAndFolders); } else if (data.action == google.picker.Action.CANCEL){ // Do nothing and close the webapp. closeWebAppWindow() } } /** * Closes webapp. * * @return command to close the web-app window. */ function closeWebAppWindow(){ return window.top.close(); }; /** * Sends error message to Webapp window. */ function errorMessage(e){ document.getElementById("message").innerHTML = e; }; </script> |
Quick use guide
There are a number of steps to get you up and running. Let’s go through each one now.
Add the Developer API Key
In your Google Apps Script editor, navigate to the WebApp.gs page under the pickerConfig()
function on line 46 you will need to get the Developer API Key.
Where do you find that? Back in your Google Cloud Project.
If you have your Google Cloud project open in a separate tab, make sure you are still in the connected project.
Next in the sidebar head to Apis and services > Credentials.
Then click on + Create Credentials > API Key.
Your API key will be generated in a popup window.
Keep this API secret. Anyone can use this key to run Google processes on your dime.
Fortunately, there are a few ways to restrict your API key. For us, we only want the key to be used for the Google Picker and when using our Web App URL. We can do this by clicking the RESTRICT KEY button on the bottom right of the window.
API restrictions
The restrictions window also give us an opportunity to rename our API key should we have more for our project. We’ll call ours, Picker API.
Under the Applications Restrictions, we can limit the URLs that the API can be called from. Click on HTTP referrers (websites) (1) radio button. You will notice that your options have expanded to allow you to add in your own URL or URL pattern. You will also find instructions on creating these patterns on the right side of the screen.
For us, our pattern will be the URL of the Web App that we generate from either our test or development deployment. Well… kinda. The URL pattern is a little different. For now, let’s just restrict it to this path (2):
*script.googleusercontent.com/*
Then select, Done (3).
We can further restrict the use of the API key by deciding what it will be used for. In the next section, select Restrict key and then use the selection menu to find the Google Picker API and check the box.
Finally hit Save.
You will then be returned to the Credentials page. Now, go ahead and copy the API key you created.
Head back over to your Google Apps Script editor and paste the key into line 46 of the WebApp.gs page.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
... // #### Server - client connectors. #### /** * An object of picker configurations. * Called when picker is initialised. * * @return {ojbect} configurations for auth */ function pickerConfig(){ DriveApp.getRootFolder() // Try this return { oauthToken: ScriptApp.getOAuthToken(), // Add developer key from Google Cloud Project > APIs and services > Credentials developerKey: "AIzaSyB7rDx__JzLvmnCRv4hqtKawh656pRYr38" } }; ... |
Deploy the Card Service and Web App
Our next task is to deploy the Card Service. We will do this in a test deployment environment for this tutorial.
In your Google Apps Script editor select the Deploy button (Top right) > New Deployment.
You will get a popup window. In the top-left select the settings cog (1) and ensure that both Web app and Add-on have been checked (2).
Next, we need to update our details. First, add a description (1). Then change the Execute as to User accessing the web app (2). Next change the Who has access to either Only myself if you are testing alone or Anyone with a Google Account if you have others testing (3). Finally, hit, Deploy.
You will then be navigated to a summary page. Go ahead and close the window.
We will run our script using the test deployment. To do this, click on the Deploy button again > Test deployments.
Here, we want to copy the Web App test deployment URL (1) and install a test GWAO sidebar in Gmail (2). Then you can click done (3).
Head to your Code.gs file and update line 87 by pasting in the test URL for the web app.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
... let buttonPickerWidget =()=> { let button = CardService.newTextButton() .setText("Select Docs") .setOpenLink(CardService.newOpenLink() //Change this to the deployed URL once you are ready to deploy it. (e.g. ending in '/exec') .setUrl("https://script.google.com/macros/s/AKfycbzHpuAHV4kU_gsUrJEsUUhzqkwkh9n4kri0wz7i5vZu/dev") //Create a window dialog containing the Google Picker. .setOpenAs(CardService.OpenAs.OVERLAY) //Reload the page once selections have been made. .setOnClose(CardService.OnClose.RELOAD)) return button; } ... |
Test the GWAO
Head over to your Gmail account and refresh your page. You should notice that your new Google Workspace Add-on is now available. Give it a click. On the first run through you will need to Authorise Access for your add on.
Go ahead and follow the prompts.
Up and Running
You should, by now have your very own file picker up and running for testing and playing around with. Go ahead and give it a try.
The script is pretty well documented but if you want a more detailed discussion on how everything works or just want to skim to points of interest, read on.
Need help with Google Workspace development?
Go something to solve bigger than Chat GPT?
I can help you with all of your Google Workspace development needs, from custom app development to integrations and security. I 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.
Code Breakdown
There is a lot of interconnectivity going on in this project. So I thought it would be a good opportunity to build my first developer flow chart. I hope it makes things easier to understand where things go (If you are a pro at building dev flowcharts, I would love some feedback).
I have spit the flowchart into 3 sections:
- Add-on UI client-side – This is basically all the sidebar interactions that the user has.
- Apps Script Server-side – This is all the backend construction of the GWAO cards, the web app and storages of data.
- Web-app UI client-side – These are web-app interactions from the user. Essentially the web app is just a container for the file picker so all interactions occur in the File Picker, while sending the data to the server-side occurs in the JS.html file.
I’ll go into greater detail on this as we explore each page and function, but first, we need to set up our appsscript.json file.
appsscript.json
Timezone
Line 2
"timeZone": "Australia/Sydney",
This is part of the standard template for this file. You can change the timezone to your own country. I have a discussion on changing the timezone here.
Dependencies
Lines 3-11
1 2 3 4 5 6 7 8 9 10 11 |
... "dependencies": { "enabledAdvancedServices": [ { "userSymbol": "Drive", "version": "v2", "serviceId": "drive" } ] }, ... |
When you enable advanced services, you will find that this part of your JSON file is updated with the appropriate dependency. Because you copied and pasted in my appsscript.json file, the Drive API advanced service has been added.
Have a go a cutting this out of the JSON file and saving it. You will notice that the Drive item in the file manager sidebar disappears.
Make sure you paste it back and save it again.
Note that because you are now connected to the Standard Google Cloud Project, you have also enabled the Drive API manually inside the GCP console.
oAuth Scopes
Lines 13-18
1 2 3 4 5 6 7 8 9 10 |
... "oauthScopes": [ "https://www.googleapis.com/auth/gmail.addons.execute", "https://www.googleapis.com/auth/script.locale", "https://www.googleapis.com/auth/drive.readonly", "https://www.googleapis.com/auth/drive" ], ... |
Normally, you can just run your code and accept all the permissions when the warning comes up and your scopes will be added to your appsscript.json file.
However, for this project, we have two circumstances that prevent us from doing this:
- Google Workspace Add-on Card Service: It is a good idea to explicitly add the add-on scopes to this file. This gives you greater control over the minimum scopes that you need to effectively run your project and when it comes to publishing your project to the marketplace Google will be expecting this.
- Standard Google Cloud Project: Your project is connected more explicitly to your Google Cloud project. Remember we added in our scopes into our GCP console when we set up. If you need to add more scopes as your project progresses, then you will need to add them in on your GCP console too.
Add-ons
Lines 20-31
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
... "addOns": { "common": { "name": "File Picker", "logoUrl": "https://yagisanatode.com/wp-content/uploads/2021/06/yagifolder.jpg", "useLocaleFromApp": true, "homepageTrigger": { "runFunction": "onhomepage", "enabled": true } }, "gmail": {} }, ... |
As I have mentioned in the tutorial previously, there is a lot of formatting and setting up you can do to create your Google Workspace Add-on (GWAO) project from the appsscript.json file.
I have kept things very bare-bones here, so we can focus on our File Picker implementation. You can find out more here.
First, we have our ‘common’ key parent object. This is all the information for the add-on that is shared in all accessible locations like, Gmail, Drive, Slides, Docs, Sheets etc.
For our project, we chose the following details, which are essentially the minimum requirements for a GWAO. Let’s take a look:
- name: This is the name of your project. It will appear at the top of your project when you open it or when you hover over your project’s icon.
- logoUrl: This is the link to our little icon I shared earlier. You can add your own icon here. It is usually good practice to use the same logo in your Authorization page.
- useLocaleFromApp: If you set this to true you will get a bunch of the user’s locale information that might be helpful for your project. This will be returned as an event (e) object for your homepage function. Incidentally, this is why we needed the
script.locale
scope. Here is what my event info looks like:
1 2 3 4 5 6 7 8 9 10 |
{ userTimezone: { id: 'Australia/Hobart', offSet: '36000000' }, userLocale: 'en', clientPlatform: 'web', hostApp: 'gmail', userCountry: 'GB', commonEventObject: { timeZone: { offset: 36000000, id: 'Australia/Hobart' }, platform: 'WEB', userLocale: 'en-GB', hostApp: 'GMAIL' } } |
- homepageTrigger: This object assigns the function you will use to run when your GWAO is selected by your user. For us this the
onhomepage
function.
In addition to our ‘common’ property, we need to assign all locations that you want the app to be displayed in. For us, this is just ‘gmail’. If you have location-specific information or initialisation functions that you need to add, you can also add this information here as you did with the ‘common’ object.
Code.gs
onhomepage(e)
1 2 3 4 5 6 7 8 9 10 11 |
/** * ## Initialises the card service homepage. ## * This is referenced in the appsscript.json * @param {object} e - details of environment and user activating the GWAO (Google Workspace Add-on) * @return build homepage card. */ function onhomepage(e) { return createSelectionCard(e); }; |
This function is initialised when the Google Workspace Add-on (GWAO) is selected from the sidebar. We enabled this function to be triggered back in the appsscript.json file addOns > common > homepageTrigger > runFunction. This function takes an event (e
) parameter of locale information, that we don’t use in this project, but you might find useful in your own project.
Finally, the function returns the createSelectionCard(e)
function.
You will need to return this function so that the Card Service build returns to the onHompage
function for it to be read properly by Apps Script. All Card Service build scripts need to be returned to the first level function.
createSelectionCard(e)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
/** * ## Initial Card Builder ## * Main function to generate the initial card on load. * @param {Object} e : Event object. * @return {CardService.Card} The card to show the user. */ function createSelectionCard(e){ const builder = CardService.newCardBuilder(); builder.addSection(buildSection()) return builder.build() }; |
The createSelectionCard(e)
function is called from the onHomepage()
function. It creates a newCardBuilder
environment and once all elements of that card are added, it builds the card and returns it to onHomepage()
.
We add one section to this card which is compiled in the buildSection()
function. Line 11
rebuildSelectionCard(e)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/** * ## Rebuild Card Builder ## * This is activated after first initialisation of the homepage. * * Main function to generate the layout of the homepage card when button clicked. * @param {Object} e : Event object. * @return {CardService.Card} The card to show the user. */ function rebuildSelectionCard(e){ PropertiesService.getUserProperties().setProperty("filePick",JSON.stringify(false)); const builder = CardService.newCardBuilder(); builder.addSection(buildSection()) return CardService.newNavigation().updateCard(builder.build()) } |
The rebuildSelectionCard(e)
function is similar to the createSelectionCard(e)
function, however, it is used when the homepage card needs to display new data or be refreshed.
The function’s first act is to set a PropertiesService
property, “filePick” to false
. This will be used in the getFilesAndFoldersDataWidget()
function to inform it that the text should be just instructions on what to do and clear out the list of files and folders from the PropertiesService.
Note on line 17, we invoke the newNavigation()
method that allows us to call updateCard()
to change the nature of the homepage card.
buildSection()
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 |
/** * Builds a section for the card service card. * * @return {CardService.CardSection} */ function buildSection(){ // ## Widgets ## let textWidget =()=>{ return CardService .newTextParagraph() .setText("Select a file or folder using the Google Picker dialog window.") } let buttonPickerWidget =()=> { let button = CardService.newTextButton() .setText("Select Docs") .setOpenLink(CardService.newOpenLink() //Change this to the deployed URL once you are ready to deploy it. (e.g. ending in '/exec') .setUrl("https://script.google.com/macros/s/AKfycbzHpuAHV4kU_gsUrJEsUUhzqkwkh9n4kri0wz7i5vZu/dev") //Create a window dialog containing the Google Picker. .setOpenAs(CardService.OpenAs.OVERLAY) //Reload the page once selections have been made. .setOnClose(CardService.OnClose.RELOAD)) return button; } // Must be done on a rebuild. let buttonFileRemoveWidget =()=> { let button = CardService.newTextButton() .setText("Clear all") .setOnClickAction(CardService.newAction().setFunctionName("rebuildSelectionCard")) return button } //Creates the set of buttons var buttonSet =() =>{ let bSet = CardService.newButtonSet() .addButton(buttonPickerWidget()) .addButton(buttonFileRemoveWidget()) return bSet; }; // ## End Widgets ## //Create the details section. const detailsSection = CardService.newCardSection() .setHeader("Front Page") .addWidget(textWidget()) .addWidget(getFilesAndFoldersDataWidget()) .addWidget(buttonSet()) return detailsSection; }; |
ThebuildSection()
function builds the section of the main card. In our little example, we only have one section that we call the detailsSection
(Line 49).
Here we set our header to “Front Page” and then add three widgets that are contained in the method above inside this function:
textWidet()
(lines 10-14): This is apararaphWidget
with instructions on what the user needs to do.getFilesAndFoldersDataWidget()
(separate Function): This is another paragraph widget. However, this one is updated dynamically. On initial load, the widget provides further instructions to the user, but when we start to add files and folders, it will populate with a list of hyperlinked files and folders. More on this in a bit.buttonSet()
(Lines 39-44): This function calls theButtonSet
class which is essentially a placeholder for you to add a bunch of buttons. This class allows us to generate our two buttons:buttonPickerWidget()
(Lines 16-27): This function will call thebutton
class. First, we will give it a title of “Select Docs” and then use thesetOpenLink()
method to first direct our button click to open our web app withsetUrl()
. Then we can determine how we want to open our window; either as a separate tab in your browser or as an overlay. We don’t want to navigate our user away from their current tab so an overlay, or pop-up window, is preferable. Also selecting this option allows us to set a loading image that locks the sidebar until the overlay window is closed. Here we need to use theonClose()
method set to ‘reload’ when the overlay is closed. This will also allow us to update the card with our new data.buttonFileRemoveWidget()
(Lines 31-36): This button handler sets the “Clear All” title and then runs therebuildSelectionCard(e)
function.
Once the details section has been built, it will be returned back to either createSelectionCard(e)
or rebuildSelectionCard(e)
functions from which it was called.
getFilesAndFoldersDataWidget()
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 |
/** * Calls the stored files and folders if the file picker is selected * otherwise returns a request to select docs and removes files from Property Service. * * Doc links are added with their title and url as a hyperlink in each page. * * @return {CardService.TextParagraph} text widget string either request to select docs or a list of doc links. */ function getFilesAndFoldersDataWidget(){ let filePick = JSON.parse(PropertiesService.getUserProperties().getProperty("filePick")); let prop = PropertiesService.getUserProperties().getProperty("files") let paragraph = ""; if(prop == null || !filePick){ paragraph = `<i>Please select your file and folders.</i>`; clearFilesFromPropServ() // Ensures there are no files in the Properties Service; }else{ let docs = JSON.parse(prop); docs.forEach( doc =>{ paragraph += `- <a href="${doc.url}">${doc.name}</a><br>` }) } PropertiesService.getUserProperties().setProperty("filePick",JSON.stringify(false)); return CardService.newTextParagraph() .setText(paragraph) } |
The getFilesAndFoldersDataWidget()
function is called from the buildSection()
function.
Set Variables
In our user Properties Service, we have stored a property called ‘filePick’ that is set to either true or false (Line 10). When it is set to false it means that the user has clicked the CLEAR ALL button and rebuildSelectionCard(e)
has been run. If it is set to true it means that files have been stored and need to be displayed in the GWAO side-bar.
On line 11, we reach into the user’s Property Service again and check the ‘files’ property. These files are stored as a stringified array of objects containing two properties:
[{url: , name: }, {url: , name: }, ...]
Our final variable is the paragraph
placeholder (Line 12) this will be returned with either a list of hyperlinks or the preset instructions.
No files or clear all files
If there are no files or the user has selected CLEAR ALL as indicated by a false filePick
variable, then the initial instructions text is assigned to the paragraph.
Next, the clearFilesFromPropServ()
function is called which deletes the ‘files’ property in the Properties Service (Line 17). I’ve separated this out to another function because I often run some other processes as a part of clearing files, like changing some indicators. For example, perhaps you want to reset the ‘filePick’ property to true.
Else
If we have files in our ‘files’ property and ‘filePick’ is set to true, then we want to assign our links that we selected from the Google Picker to our paragraph
variable.
We use a forEach loop to iterate through each stored file or folder and generate a hyperlink and a line break that we will concatenate into one large string.
Last bit
Finally, we reset our ‘filePick’ to false and then add our paragraph to our paragraph widget that will be returned to buildSection()
.
WebApp.gs
This server-side page initialises the web-app with a mandatory doGet()
function and then handles calls from the clientside Javascript.
doGet()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
/** * Initialises the HTML webapp services providing a title and metatags. * -Enabled for Template so I can insert: <?!= include("JS"); ?> * * return {object} Html Serive object that generates the webapp. */ function doGet() { let html = HtmlService .createTemplateFromFile("FilePicker") .evaluate() .setTitle("Link Seletor") return html; }; |
For a Google Apps Script to run, you need to either have a doGet()
or doPost()
function within your project.
In this function, we call on the HtmlService to generate our HTML file for our file picker window. We’ll use the createTemplateFromFile()
method so that we can use scriptlets inside our main FilePicker.html file to include our JS.html file. You can find out more about this here:
https://yagisanatode.com/2018/04/15/google-apps-script-how-to-create-javascript-and-css-files-for-a-sidebar-project-in-google-apps-script/
We reference our FilePicker.html as our main run file and then evaluate or let HtmlService interpret all the scriptlets. Finally, we will set the title for the page (Line 11) before returning the built Html Service.
include(filename)
1 2 3 4 5 6 7 8 9 |
/** * Imports or includes other files for the main html document. * * */ function include(filename){ return HtmlService.createHtmlOutputFromFile(filename) .getContent(); }; |
The include
function ( I believe I acquired from one of the Google Docs many years ago), in conjunction with the use of the createTemplateFromFile
method in the previous function, allows us to insert or import other files into each other. For us, we want to import our JS.html file into our FilePicker.html. Keep an eye out for this scriptlet in the FilePicker.html code:
<?!= include("JS"); ?>
pickerConfig()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// #### Server - client connectors. #### /** * An object of picker configurations. * Called when picker is initialised. * * @return {ojbect} configurations for auth */ function pickerConfig(){ DriveApp.getRootFolder() // Try this return { oauthToken: ScriptApp.getOAuthToken(), // Add developer key from Google Cloud Project > APIs and services > Credentials developerKey: "AIzaSyB7rDx__JzLvmnCRv4hqtKawh656pRYr38" } }; |
The pickerConfig()
function is called from the onApiLoad()
function in the JS.html file as the File Picker is being initialised. It collects our oauthToken
which we glean from the ScriptApp.getOAuthToken()
call. It also collects the developer key that we pasted in from our Google Cloud project during setup.
Client-side this object will be stored as the config
variable.
Keeping this key server-side is a safer way of storing the key so that a nefarious user can’t grab it and go on a Google Services spending spree on your API key. Just remember to set the config
variable to null
once you have used it so it isn’t stored client-side. Also, don’t forget that we also set some URL and type restrictions to the developer API key back in the Google Cloud console as an extra line of defence so that it can’t be used for other things.
storeDriveSelections(fileId)
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 |
/** * Retrieves a list of files and folders from the File Picker popup. * Stores the files in the user's Properties Service. * @example * [{id, name, url},{id, name, url},...] * * @param {array} fileId - Array of file/folder objects. */ function storeDriveSelections(fileId){ // Append current list of files and folders. let storedDocs = JSON.parse(PropertiesService.getUserProperties() .getProperty("files")); let updateArray = () => { //Combine current list with incoming and remove duplicates. return [...new Map([...fileId,...storedDocs].map(item => [item.id, item])).values()] }; // IF not stored ids just input the fileId otherwise add both to array. let docsAll = (storedDocs === null)? fileId : updateArray(); //Add storedDocs to selected docs; PropertiesService.getUserProperties() .setProperty("files", JSON.stringify(docsAll)) // Allows us to only keep these properties when using is working on saved properties. PropertiesService.getUserProperties() .setProperty("filePick",JSON.stringify(true)); }; |
Inputs and variables
The storeDriveSelections(filed)
function is called from the client-side pickerCallback
function on google.script.run
. It will store a list of objects containing the doc or folder ID, the name of the doc or folder and the associated URL of each selected doc.
This is an important function for when you want to convert this tutorial to your own project.
The returned results look like this:
[{id, name, url},{id, name, url},...]
Our main aim here is to combine any currently stored link values with the ones currently being added from the user’s selection.
First, we will grab our current list of links from the ‘files’ property in Properties Service. We’ll store this in the storedDocs
variable. (Lines 12-13).
Combining our links with our stored links
Next, we want to combine our parameter input fileId
with the storedDocs
. However, we also want to ensure that there are no duplicates. I found a neat way of doing this from V.Sambor over in StackOverflow.
An error has occurred. Please try again later. |
Inside our updateArray
arrow function, we return a new array of objects. Inside this array, we use the spread syntax and new Map() constructor to create an object of key-value pairs.
To create these pairs we will create our array using the spread syntax in an array again to join our fileId
and storedDocs
. Next, we map this array creating an array with a key which will be our item.id
and our item. Because all items in this quasi object need to be unique, it will only display each object with the same item.id only once, capturing the last iteration of that id. This then returns only unique items based on the id of each object of the combined data.
Checking we have stored links
let docsAll = (storedDocs === null)? fileId : updateArray();
Here we use a ternary operator to check if ‘files’ in Properties Service does not exist. If this is the case the storedDocs
variable will report null
and we just have to add the fileId
to our docsAll
variable. However, if ‘files’ exists we will call the updateArray()
method we created above. Line 22
Storing our link data
Next, we need to store our link data back into our Properties Service ‘files’ property. (Lines 25-26)
We should also set our ‘filePick’ property to true to indicate to the getFilesAndFoldersDataWidget()
function in Code.gs that we have documents or folders that we want to display. (Lines 29-30)
clearFilesFromPropServ()
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * Removes "files" data from Property service. * * Called when: * 1. Card is refreshed and is not part of the file selection process. * 2. When the "CLEAR ALL" button is selected. */ function clearFilesFromPropServ(){ // Allows us to only keep these properties when using is working on saved properties. PropertiesService.getUserProperties() .deleteProperty("files"); }; |
In this final function, we clear the ‘files’ property of the properties service.
This is invoked from getFilesAndFoldersDataWidget()
if the ‘filePick’ property is set to false
.
FilePicker.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 |
<!DOCTYPE html> <html> <head> <base target="_top"> <link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons1.css" /> <?!= include("JS"); ?> <style> /* Center the message text and create black background. */ body{ background-color: black; } #message{ display: flex; justify-content: center; align-items: center; } </style> </head> <body> <!-- Provide either a processing messag or any relevant error messages. --> <div id="message" class="current">Processing...</div> <!-- Load the Google API Loader script. --> <script type="text/javascript" src="https://apis.google.com/js/api.js?onload=onApiLoad"></script> </body> </html> |
The FilePicker.html page is essentially the index for our web app. Visually, all it does is create a black background with a red text indicating that the file picker is loading or an error message should an error occur.
Two important things to note here are:
- The HTML Sevice template scriptlet that I used to call the
include
function in the WebApp.gs file to import the JS.html file. (Line 8) - The script to load our file picker. Note the
onload=onApiLoad
function call that will be called in our JS.html file. This will run our code as soon as the page is loaded and have the Google Picker come up straight away.
JS.html
This file is the main driving force that builds and interacts with the Google Picker. I have taken much of this from the Docs and modified what I needed. No need to reinvent the wheel, right 😉?
Let’s go through it to give you a bit more clarity and hopefully help you avoid the gotchas that I got stuck with when I first tried to build this.
onApiLoad()
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 |
<script> var pickerApiLoaded = false; /** * Use the API Loader script to dynamically load google.picker * * Initialises the Google Picker loading */ function onApiLoad() { gapi.load('picker', { 'callback': function() { pickerApiLoaded = true; } }); //Draw OAuth token and Developer key from the server. //If success runs createPicker() google.script.run .withFailureHandler(errorMessage) .withSuccessHandler(createPicker) .pickerConfig(); }; ... |
gapi.load()
The var pickerApiLoaded
variable indicates the state of Picker API service indicating whether it has loaded or not. This initially set to false.
When the FilePicker.html file loads this Google API loader script is called:
1 |
<script type="text/javascript" src="https://apis.google.com/js/api.js?onload=onApiLoad"></script> |
As you can see the onApiLoad()
function is called (calledback?) from this script allowing us to access the gapi
class. Here we call the load
method on gapi
to get the library that we need (Line 12) . The library name is put in the first argument of this method. For us, this is ‘picker’ (Line 12).
The second argument is any actions or calls that we want to make to the library. From what I understand here, we call the ‘callback’ method of the picker library to run our callback function and reset our pickerApiLoaded
to true to acknowledge that we are now running the Google Picker. Lines 13-14
Grabbing our OAuth token and Developer Key.
Getting this out of the way straight up if you are having trouble with the developer key you might also try the keywords ‘API key’ when searching for help. It got me stuck for a little while.
On lines 20-23, we use the Client-side access API google.script.run to access our OAuth token and developer key from the pickerConfig()
function inside WebApp.gs.
If there is an error in our server-side code it will throw an error to our errorMessage()
function, alternatively, if all goes well createPicker()
will be run with our OAuth token and developer API key as the config
parameter.
More on the client-side access API here:
https://yagisanatode.com/2020/11/11/google-apps-script-how-to-create-a-basic-interactive-interface-with-web-apps/
CreatePicker(config)
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 |
/** * Sets up and runs the Google Picker * * @param {object} config {oauthToken: ,developerKey: } * @callback pickerCallback */ function createPicker(config){ if(pickerApiLoaded && config.oauthToken){ var DIALOG_DIMENSIONS = {width: 750, height: 580}; //Picker UI layout preferences. const view = new google.picker.DocsView() .setIncludeFolders(true) .setSelectFolderEnabled(true) .setLabel('My Drive') .setParent('root'); //Builds the picker const picker = new google.picker.PickerBuilder() .enableFeature(google.picker.Feature.MULTISELECT_ENABLED) .hideTitleBar() .setOAuthToken(config.oauthToken) .addView(view) .setDeveloperKey(config.developerKey) .setOrigin(google.script.host.origin) .setSize(DIALOG_DIMENSIONS.width, DIALOG_DIMENSIONS.height) .setCallback(pickerCallback) .build(); picker.setVisible(true); }; // Clear the config variable to hide it from users. config = null; }; |
The createPicker(config)
function is a callback function that is run after the server-side pickerConfig()
function from the WebApp.gs file is successfully executed. The config parameter is the returned results of the pickerConfig()
file and contains the oauthToken
and developerKey
objects.
Our first task is to check to see that we have everything we need to run the Google Picker. On line 9, we confirm that pickerApiLoaded
is set to true and we have a value in config.oauthToken
.
Setting up how Google Picker looks
We will need to set up how our Google Picker is displayed and then call on these items when we build the picker.
First, we set the dimensions of the File Picker window. I tried to match these dimensions to fit neatly inside our web-app window when it pops up as a dialogue box. Line 11
Next, we set up the picker UI preferences.
Now you might be wondering, where the google.picker.
selectedMethod
is coming from. The google call is derived from when we accessed the API library earlier. From what I can see, you call your selected API by doing google.
yourAPI.selecteMethod
.
Back to our view
variable setup on lines 14-18 we first call the DocsView subclass to set up our view. We want this view to include all folders to be traversed with setIncludeFolders(true)
and we also want to be able to select a folder with setSelectFolderEnabled(true)
.
The next task is to set the label or title of the picker, which we set to ‘My Drive’. Line 17
Finally, we set what directory this view will start from. For simplicity sake we will set our directory to ‘root’. However, if you want it to be set from somewhere else you can add the folder ID here. Line 18
Note: You can create multiple views that will be accessed at the top of your picker. This can be done by adding a new instance of new google.picker.DocsView()
and filling in the required information then adding another setView() to the main picker builder. However, be warned. This label header bar seems to be a bit of a blind spot for most users; they don’t notice it is there. I’ve had to resort to providing buttons on a main page to different directory locations and an instance of the file picker is loaded for each instead.
Building the picker
To build the Google Picker we call a new instance of new google.picker.PickerBuilder()
. Let’s look at what we included in the build:
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
: This lets the user select more than one file or folder in the same director by holding ctrl + clicking selected items or by clicking and items and holding shift + selecting the end range of files or folders, or by simply holding down the left-mouse button and dragging across a range. Why and I mentioning this? Because it is a very good idea to point out to your users or they won’t know..hideTitleBar()
: Hiding the title bar give us a little extra space to display our picker..setOAuthToken(config.oauthToken
): Required: We grab our OAuth token from our config file..addView(view)
: Required: This is where we add our view configuration. As I mentioned earlier, you can add more than one and they will come up as labels in the header of the Picker..setDeveloperKey(config.developerKey)
: Required: We grab our Developer (API) key token from our config file..setOrigin(google.script.host.origin)
: Required: This needs to be set because we are calling the picker from our Web-App..setSize(DIALOG_DIMENSIONS.width, DIALOG_DIMENSIONS.height)
: self-explanatory..setCallback(pickerCallback)
: Required: What happens when either the ‘Select’ or ‘Cancel’ buttons are clicked. ThepickerCallback()
will send our data to WebApp.gs to be store or cleared respectively..build();
: Required: This builds in all the above details.
Once our picker is built we set the picker to visible. Line 34
Finally, we clear our config file so no one can see our developer key. Line 37
pickerCallback(data)
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 |
// A simple callback implementation. /** * Callback that collects selected docs. * @param {object} data - Picker data. * @param {object} data.picked - Information of each picked doc. * @return {object} files and folders {url: , name: , id: , type:} */ function pickerCallback(data) { if (data.action == google.picker.Action.PICKED) { // Just grab the url, name, id and type let filesAndFolders = data.docs.map(doc => { return { url: doc.url, name: doc.name, id: doc.id, type: doc.type } }) // Sends filesAndFolders server-side to be stored. // Then runs the function ot close the Picker and WebApp window. google.script.run .withFailureHandler(errorMessage) .withSuccessHandler(closeWebAppWindow) .storeDriveSelections(filesAndFolders); } else if (data.action == google.picker.Action.CANCEL){ // Do nothing and close the webapp. closeWebAppWindow() } } |
The pickerCallback(data)
function is run when the user either submits or closes the Google Picker.
The data
parameter contains an object of properties about the picker and your selections. With two selections your data object would look similar to this:
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 |
{ "action":"picked", "viewToken":[ "all", "My Drive",{ "parent":"root", "selectFolder":true, "includeFolders":true } ], "docs":[ { "id":"13UmsSaYb4gZQnkq2Pk19t02vF0tFK_Ji", "serviceId":"docs", "mimeType":"image/jpeg", "name":"Yagi 32x32.jpg", "description":"", "type":"photo", "lastEditedUtc":1621399133376, "iconUrl":"https://drive-thirdparty.googleusercontent.com/16/type/image/jpeg", "url":"https://drive.google.com/file/d/13UmsSaYb4gZQnkq2Pk19t02vF0tFK_Ji/view?usp=drive_web", "embedUrl":"https://drive.google.com/file/d/13UmsSaYb4gZQnkq2Pk19t02vF0tFK_Ji/preview?usp=drive_web", "sizeBytes":24221, "rotation":0, "rotationDegree":0, "parentId":"root", "isShared":true }, { "id":"1cEjgZc5GopN6eG94_RZm34MJUtEuzIny3M5YA5Xfb0I", "serviceId":"spread", "mimeType":"application/vnd.google-apps.spreadsheet", "name":"Someone Else's Google Sheet", "description":"", "type":"document", "lastEditedUtc":1618389796598, "iconUrl":"https://drive-thirdparty.googleusercontent.com/16/type/application/vnd.google-apps.spreadsheet", "url":"https://docs.google.com/spreadsheets/d/1cEjgZc5GopN6eG94_RZm34MJUtEuzIny3M5YA5Xfb0I/edit?usp=drive_web", "embedUrl":"https://docs.google.com/spreadsheets/d/1cEjgZc5GopN6eG94_RZm34MJUtEuzIny3M5YA5Xfb0I/htmlembed", "sizeBytes":0, "parentId":"root"} ] } |
For our purposes, we just want the docs
property information that is an array of each selection of files or folders as an object.
If ‘select’ button is clicked
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
if (data.action == google.picker.Action.PICKED) { // Just grab the url, name, id and type let filesAndFolders = data.docs.map(doc => { return { url: doc.url, name: doc.name, id: doc.id, type: doc.type } }) // Sends filesAndFolders server-side to be stored. // Then runs the function ot close the Picker and WebApp window. google.script.run .withFailureHandler(errorMessage) .withSuccessHandler(closeWebAppWindow) .storeDriveSelections(filesAndFolders); } |
If the ‘Select’ button is clicked then the google.picker.Action
state is ‘PICKED’. We check the action state by checking data.action
. Line 1 of the snippet above
Probably the most interesting bits of each selection for us is going to be the file or folders, URL, name, id and type properties. So we have selected them to be returned server-side. We then iterate through the date.docs
object and grab these details and put them in the filesAndFolders
variable. Lines 5-10
Finally, we use google.script.run
again in order to attempt to send our data to our server to be stored using the storeDriveSelections()
function before calling our client-side closeWebAppWindow()
function to close our picker and popup window in one fell swoop. Lines 16-19
If ‘Cancel’ button is clicked
1 2 3 4 5 6 |
... else if (data.action == google.picker.Action.CANCEL){ // Do nothing and close the webapp. closeWebAppWindow() } ... |
If cancel is clicked then we call the closeWebAppWindow()
function and that’s it.
closeWebAppWindow()
1 2 3 4 5 6 7 8 |
/** * Closes webapp. * * @return command to close the web-app window. */ function closeWebAppWindow(){ return window.top.close(); }; |
The closeWebAppWindow()
function closes not only the picker but the entire pop-out window.
errorMessage(e)
1 2 3 4 5 6 7 |
/** * Sends error message to Webapp window. */ function errorMessage(e){ document.getElementById("message").innerHTML = e; }; |
The errorMessage(e)
function is called from the withFailureHandler(e)
and will report error messages to the body of the popout window. You could also use this function to report other errors that might be caught in a try-catch.
Conclusion
As you can see, it is quite a process to get a File Picker popup window up and running in a Google Workspace Add-on. Perhaps as the CardService class grows and is developed by the Google team, a simpler implementation might be provided for us, but that is a bit of wishful thinking. Until then, this is what we have and I am grateful that something like this exists.
I really wanted to get into the weeds on this process. Firstly, I could not find a tutorial on providing the file picker specifically for a Google Workspace Add-on. Secondly, the picker tutorials seem to miss a lot of the setup information; particularly when setting up the Google Cloud Project side of things. I understand why, it’s a lot of work to get all the screenshots together.
So how have I used this. In a recent project for a client, I have modified this script so that I have a popup window that the user can choose from a bunch of directory locations (including shared drives) and they will be saved and stored for processing. Here, I disabled the autoload of the file picker and let it appear when the user picks a file directory from a button set. Like I said earlier, the labels at the top of the picker seem to be a bit of a blind spot for users.
Anyways, I really hope you found all or part of this tutorial useful. I would love to hear how you have applied the code to your own project. And if you did find it helpful, please consider buying me a beer or a coffee by hitting my donate button. It makes the full week I put into building, testing, writing and editing this tutorial worthwhile (top-right sidebar).
~Yagi
I am very excited to see this tutorial as I have tried to and failed to setup the file picker before. Are there earlier tutorials on add-ons, card service or Standard Google Cloud Platform which I should do first. I have been using Tanaike’s code as it does not require all that fancy stuff.
https://gist.github.com/tanaikech/96166a32e7781fee22da9e498b2289d0
Thank you as always – I will see how far I can get but really think I need some pre-lessons.
Hi L.Klein,
Tanaike is a legend. He really has some great stuff out there.
It would be a little tricky to go his approach with Card Service, because you would need to refresh the card each time. Definitely not impossible, but just a pain.
There isn’t a lot out there about Card Services at the moment, but stay tuned. I hope to get a little series going on this. Just recently, however, I did stumble across a post from Steve Bazyl ( Boss man in charge of the Google Apps Script team) who came up with a cool post on The Card Builder Tool and creating a chatbot with Card Service.
I think you should be at a pretty good level to tackle this tutorial. It will just take a little time to work through.
Best of luck.
~Yagi
Wow Yagi! Your tutorials are the BEST. I always learn a lot from you.
Hi JD,
Thanks for the kind words.
~Yagi
Hi Yagi,
Thanks for this tutorial, it has helped me to achieve what I wanted to do. Also all your ressources are immensly helpfull to me to learn google app script !
I am now trying to improve on what you did here, by dynamicaly refreshing my add on card when the file is selected. This works well in your case because you use .setOnClose(CardService.OnClose.RELOAD)); but you are on the “main” card so it does reload the main card without problem.
In my case, my file picker is in an other card on top of the main one, so when the picker reloads the add on, it goes back to the main card. Do you see any way to either prevent the reload to close my opened card or to pass a parameter to the reload action in order to reopen the card that was on top ?
Thank you
Hi Quentin,
Yes, it will reload the main card. My approach is to generally store a card indicator in Properties Service, cardStatus: pickerupdate for example and rebuild the card and check for the card indicator each time. Then onHomepage() check the cardStatus and navigate to the appropriate build for the card.
Hi Yagi,
Thank you so much for your answer, I did not think of that before ! So now that you gave me this amazing idea, I tried all day to make it work but impossible. Actually, I am blocked because it seems that the homepage function can only return a built card. But what I actually want to do is build the main card and add a navigation to push the second card I actually want to show (which is the one where the picker is called and that should be refreshed with the picker selection).
Just so that it’s perfectly clear :
There is card1 acting like a menu
card1 is build on the homepage function
A button on card1 creates and builds card2
The file picker is on card2
Once a file is selected, the picker reloads the add on so if I don’t do anything special, it goes back to card1
I would like that on reload, it loads card1 and card2 on top of it (so that I have the back button in the header to return to card1)
I can make a conditionnal call to the card2 build when I am in the reload case, but it is not what I want as in this case I don’t have the back button in the header to go back to card1.
Unfortunatly, when I try to return something like that on the homepage function, it doesn’t work (I get a strange “content not available for this message” error and the refresh of the add on is not possible anymore) :
var card1 = createMainMenuCard();
var card2 = createFilePickerCard();
var nav = CardService.newNavigation().pushCard(card1).pushCard(card2);
return CardService.newActionResponseBuilder().setNavigation(nav).build();
Any idea ?
Yagi, you are always an inspiration. are there any news about CardService that could make all this stuff easier and ‘more native’? thank you lot!
Hey Dani,
Thanks for the support and kind words!
I don’t think the general structure of CardService for Apps Script will ever change. However, they are making some minor ‘improvements’. In a recent update they now provide: ‘multi-select’ and ‘columns’.
You can learn more here: https://developers.google.com/apps-script/docs/release-notes#July_25_2024
I am planning on providing a small tutorial on these two updates in my Card Service course.
CardService and other Apps Script APIs are generally very intuitive wrappers for object building.
A more traditional REST API approach is to use the HTTPS runtime (But a lot of unnecessary setup work): https://developers.google.com/workspace/add-ons/reference/rpc/google.apps.card.v1#selectioninput
Cheers,
Yagi
I’d like your opinion, if you can. I have split up your code in order to build a library, so that the code can be called from different addons to build a filepicker section. all the web app sections, plus a part of code.gs are now into the library. I moved builder functions in the calling addon code. I have rewritten some functions so that they return a card section, instead of building the whole card. the card itself is built by the calling addon code. (when it will be complete, I’d like to share it with you, if you want to). I am building a more complex card, containing further widgets, in order to ask to users additional information, together with the filepicker widget. coming to my the question: when the filepicker overlay window is closed, the addon in loaded again (so the whole card is built from zero). selected files are retrieved from user properties. how can I preserve the additional values already entered by the user (I mean, in the other widgets)? should I use properties to store and retrieve all of them? is there a smarter way on your opinion? thank you anyway, cheers
You certainly can use Property Service or Cache Service to store the data. Also, take a look at what data the event (
e
) parameter provides in terms of your input data.Looking forward to see your end result.
~ Yagi
Hello Yagi, thank you for your contribution your knowledge! I have tested my addon it works from side addon menu, but after installation card menu from extension to launch add-on does not support addon card from displaying. can you give some information to make custom menu for gwao cardservice to start? appreciate your feedback in advance