Build a Google Workspace Add-on with Node.js and Cloud Run

1. Introduction

Google Workspace Add-ons are customized applications that integrate with Google Workspace applications such as Gmail, Docs, Sheets, and Slides. They enable developers to create customized user interfaces that are directly integrated into Google Workspace. Add-ons help users work more efficiently with less context switching.

In this codelab, you'll learn how to build and deploy a simple task list add-on using Node.js, Cloud Run, and Datastore.

What you'll learn

  • Use the Cloud Shell
  • Deploy to Cloud Run
  • Create and deploy an Add-on deployment descriptor
  • Create Add-on UIs with the card framework
  • Respond to user interactions
  • Leverage user context in an Add-on

2. Setup and requirements

Follow the setup instructions to create a Google Cloud project and enable the APIs and services the add-on will use.

Self-paced environment setup

  1. Open Cloud Console and create a new project. (If you don't already have a Gmail or Google Workspace account, create one.)

The select a project menu

The new Project button

The Project ID

Remember the project ID, a unique name across all Google Cloud projects (the name above has already been taken and will not work for you, sorry!). It will be referred to later in this codelab as PROJECT_ID.

  1. Next, in order to use Google Cloud resources, enable billing in Cloud Console..

Running through this codelab shouldn't cost much, if anything at all. Be sure to to follow any instructions in the "Clean up" section at the end of the codelab which advises you how to shut down resources so you don't incur billing beyond this tutorial. New users of Google Cloud are eligible for the $300USD Free Trial program.

Google Cloud Shell

While Google Cloud can be operated remotely from your laptop, in this codelab we will be using Google Cloud Shell, a command line environment running in the Cloud.

Activate Cloud Shell

  1. From the Cloud Console, click Activate Cloud Shell The Cloud Shell icon.

The Cloud Shell icon in the menu bar

The first time you open Cloud Shell, you're presented with a descriptive welcome message. If you see the welcome message, click Continue. The welcome message doesn't appear again. Here's the welcome message:

Cloud Shell welcome message

It should only take a few moments to provision and connect to Cloud Shell. After connecting, you see the Cloud Shell Terminal:

The Cloud Shell Terminal

This virtual machine is loaded with all the development tools you need. It offers a persistent 5GB home directory and runs in Google Cloud, greatly enhancing network performance and authentication. All of your work in this codelab can be done with a browser or your Chromebook.

Once connected to Cloud Shell, you should see that you are already authenticated and that the project is already set to your project ID.

  1. Run the following command in Cloud Shell to confirm that you are authenticated:
gcloud auth list

If you are prompted to authorize Cloud Shell to make a GCP API call, click Authorize.

Command output

 Credentialed Accounts
ACTIVE  ACCOUNT
*       <my_account>@<my_domain.com>

To set the active account, run:

gcloud config set account <ACCOUNT>

To confirm you have selected the correct project, in Cloud Shell, run:

gcloud config list project

Command output

[core]
project = <PROJECT_ID>

If the correct project is not returned, you can set it with this command:

gcloud config set project <PROJECT_ID>

Command output

Updated property [core/project].

The codelab uses a mix of command line operations as well as file editing. For file editing, you can use the built-in code editor in Cloud Shell by clicking the Open Editor button on the right hand side of the Cloud Shell toolbar. You'll also find popular editors such as vim and emacs available in Cloud Shell.

3. Enable Cloud Run, Datastore, and Add-on APIs

Enable Cloud APIs

From Cloud Shell, enable the Cloud APIs for the components that will be used:

gcloud services enable \
  run.googleapis.com \
  cloudbuild.googleapis.com \
  cloudresourcemanager.googleapis.com \
  datastore.googleapis.com \
  gsuiteaddons.googleapis.com

This operation may take a few moments to complete.

Once completed, a success message similar to this one appears:

Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.

Create a datastore instance

Next, enable App Engine and create a Datastore database. Enabling App Engine is a prerequisite to use Datastore, but we won't use App Engine for anything else.

gcloud app create --region=us-central
gcloud firestore databases create --type=datastore-mode --region=us-central

The add-on requires user permission to run and take action on their data. Configure the project's consent screen to enable this. For the codelab, you'll configure the consent screen as an internal application, meaning it's not for public distribution, to get started.

  1. Open the Google Cloud Console in a new tab or window.
  2. Next to "Google Cloud Console," click the Down arrow drop down arrow and select your project.
  3. At the top-left corner, click Menu menu icon.
  4. Click APIs & Services > Credentials. The credential page for your project appears.
  5. Click OAuth consent screen. The "OAuth consent screen" screen appears.
  6. Under "User Type," select Internal. If using an @gmail.com account, select External.
  7. Click Create. An "Edit app registration" page appears.
  8. Fill out the form:
    • In App name, enter "Todo Add-on".
    • In User support email, enter your personal email address.
    • Under Developer contact information, enter your personal email address.
  9. Click Save and Continue. A Scopes form appears.
  10. From the Scopes form, click Save and Continue. A summary appears.
  11. Click Back to Dashboard.

4. Create the initial add-on

Initialize the project

To begin, you'll create a simple "Hello world" add-on and deploy it. Add-ons are web services that respond to https requests and respond with a JSON payload that describes the UI and actions to take. In this add-on, you'll use Node.js and the Express framework.

To create this template project, use Cloud Shell to create a new directory named todo-add-on and navigate to it:

mkdir ~/todo-add-on
cd ~/todo-add-on

You'll do all the work for the codelab in this directory.

Initialize the Node.js project:

npm init

NPM asks several questions about the project configuration, such as name and version. For each question, press ENTER to accept the default values. The default entry point is a file named index.js, which we'll create next.

Next, install the Express web framework:

npm install --save express express-async-handler

Create the add-on backend

Time to start creating the app.

Create a file named index.js. To create files, you can use the Cloud Shell Editor by clicking the Open Editor button on the toolbar of the Cloud Shell window. Alternatively, you can edit and manage files in Cloud Shell by using vim or emacs.

After you create the index.js file, add the following content:

const express = require('express');
const asyncHandler = require('express-async-handler');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post("/", asyncHandler(async (req, res) => {
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello world!`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

The server doesn't do much other than show the ‘Hello world' message and that's OK. You'll add more functionality later.

Deploy to Cloud Run

To deploy on Cloud Run, the app needs to be containerized.

Create the container

Create a Dockerfile named Dockerfile containing:

FROM node:12-slim

# Create and change to the app directory.
WORKDIR /usr/src/app

# Copy application dependency manifests to the container image.
# A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
# Copying this first prevents re-running npm install on every code change.
COPY package*.json ./

# Install production dependencies.
# If you add a package-lock.json, speed your build by switching to 'npm ci'.
# RUN npm ci --only=production
RUN npm install --only=production

# Copy local code to the container image.
COPY . ./

# Run the web service on container startup.
CMD [ "node", "index.js" ]

Keep unwanted files out of the container

To help keep the container light, create a .dockerignore file containing:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Enable Cloud Build

In this codelab you'll build and deploy the add-on several times as new functionality is added. Instead of running separate commands to build the container, push it to the container registery, and deploy it to Cloud Build, use Cloud Build to orchestrate the procedure. Create a cloudbuild.yaml file with instructions on how to build and deploy the application:

steps:
 # Build the container image
 - name: 'gcr.io/cloud-builders/docker'
   args: ['build', '-t', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME', '.']
 # Push the container image to Container Registry
 - name: 'gcr.io/cloud-builders/docker'
   args: ['push', 'gcr.io/$PROJECT_ID/$_SERVICE_NAME']
 # Deploy container image to Cloud Run
 - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
   entrypoint: gcloud
   args:
   - 'run'
   - 'deploy'
   - '$_SERVICE_NAME'
   - '--image'
   - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
   - '--region'
   - '$_REGION'
   - '--platform'
   - 'managed'
images:
 - 'gcr.io/$PROJECT_ID/$_SERVICE_NAME'
substitutions:
   _SERVICE_NAME: todo-add-on
   _REGION: us-central1

Run the following commands to grant Cloud Build permission to deploy the app:

PROJECT_ID=$(gcloud config list --format='value(core.project)')
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
gcloud projects add-iam-policy-binding $PROJECT_ID \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/run.admin
gcloud iam service-accounts add-iam-policy-binding \
    $PROJECT_NUMBER-compute@developer.gserviceaccount.com \
    --member=serviceAccount:$PROJECT_NUMBER@cloudbuild.gserviceaccount.com \
    --role=roles/iam.serviceAccountUser

Build and deploy the add-on backend

To start the build, in Cloud Shell, run:

gcloud builds submit

The full build and deploy may take a few minutes to complete, particularly the first time around.

Once the build completes, verify the service is deployed and find the URL. Run the command:

gcloud run services list --platform managed

Copy this URL, you'll need it for the next step – telling Google Workspace how to invoke the add-on.

Register the add-on

Now that the server is up and running, describe the add-on so Google Workspace knows how to display and invoke it.

Create a deployment descriptor

Create the file deployment.json with the following content. Make sure to use the URL of the deployed app in place of the URL placeholder.

{
  "oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute"
  ],
  "addOns": {
    "common": {
      "name": "Todo Codelab",
      "logoUrl": "https://raw.githubusercontent.com/webdog/octicons-png/main/black/check.png",
      "homepageTrigger": {
        "runFunction": "URL"
      }
    },
    "gmail": {},
    "drive": {},
    "calendar": {},
    "docs": {},
    "sheets": {},
    "slides": {}
  }
}

Upload the deployment descriptor by running the command:

gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json

Authorize access to the add-on backend

The add-ons framework also needs permission to call the service. Run the following commands to update the IAM policy for Cloud Run to allow Google Workspace to invoke the add-on:

SERVICE_ACCOUNT_EMAIL=$(gcloud workspace-add-ons get-authorization --format="value(serviceAccountEmail)")
gcloud run services add-iam-policy-binding todo-add-on --platform managed --region us-central1 --role roles/run.invoker --member "serviceAccount:$SERVICE_ACCOUNT_EMAIL"

Install the add-on for testing

To install the add-on in development mode for your account, in Cloud Shell, run:

gcloud workspace-add-ons deployments install todo-add-on

Open (Gmail)[https://mail.google.com/] in a new tab or window. On the right-hand side, find the add-on with a checkmark icon.

Installed add-on icon

To open the add-on, click the checkmark icon. A prompt to authorize the add-on appears.

Authorization prompt

Click Authorize Access and follow the authorization flow instructions in the popup. Once complete, the add-on automatically reloads and displays the ‘Hello world!' message.

Congratulations! You now have a simple add-on deployed and installed. Time to turn it into a task list application!

5. Access the user identity

Add-ons are typically used by many users to work with information that is private to them or their organizations. In this codelab, the add-on should only show the tasks for the current user. The user identity is sent to the add-on via an identity token that needs to be decoded.

Add scopes to the deployment descriptor

The user identity isn't sent by default. It's user data and the add-on needs permission to access it. To gain that permission, update deployment.json and add the openid and email OAuth scopes to the list of scopes the add-on requires. After adding OAuth scopes, the add-on prompts users to grant access the next time they use the add-on.

"oauthScopes": [
      "https://www.googleapis.com/auth/gmail.addons.execute",
      "https://www.googleapis.com/auth/calendar.addons.execute",
      "openid",
      "email"
],

Then, in Cloud Shell, run this command to update the deployment descriptor:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

Update the add-on server

While the add-on is configured to request the user identity, the implementation still needs to be updated.

Parse the identity token

Start by adding the Google auth library to the project:

npm install --save google-auth-library

Then edit index.js to require OAuth2Client:

const { OAuth2Client } = require('google-auth-library');

Then add a helper method to parse the ID token:

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

Display the user identity

This is a good time for a checkpoint before adding all of the task list functionality. Update the app's route to print the user's email address and unique ID instead of ‘Hello world.'

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

After these changes, the resulting index.js file should look like:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const card = {
        sections: [{
            widgets: [
                {
                    textParagraph: {
                        text: `Hello ${user.email} ${user.sub}`
                    }
                },
            ]
        }]
    };
    const renderAction = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(renderAction);
}));

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

Redeploy and test

Rebuild and redeploy the addon. From Cloud Shell, run:

gcloud builds submit

Once the server is redeployed, open or reload Gmail and open the add-on again. Since the scopes have changed, the add-on will ask for reauthorization. Authorize the add-on again, and once complete the add-on displays your email address and user ID.

Now that add-on knows who the user is, you can start adding the task list functionality.

6. Implement the task list

The initial data model for the codelab is straightforward: a list of Task entities, each with properties for the task descriptive text and a timestamp.

Create the datastore index

Datastore was already enabled for the project earlier in the codelab. It doesn't require a schema, though it does require explicitly creating indexes for compound queries. Creating the index can take a few minutes, so you'll do that first.

Create a file named index.yaml with the following:

indexes:
- kind: Task
  ancestor: yes
  properties:
  - name: created

Then update the Datastore indexes:

gcloud datastore indexes create index.yaml

When prompted to continue, press ENTER on your keyboard. Index creation happens in the background. While that's happening, start updating the add-on code to implement the "todos".

Update the add-on backend

Install the Datastore library to the project:

npm install --save @google-cloud/datastore

Read and write to Datastore

Update index.js to implement the "todos" beginning with importing the datastore library and creating the client:

const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

Add methods to read and write tasks from Datastore:

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

Implement rendering of the UI

Most of the changes are to the add-on UI. Earlier, all the cards returned by the UI were static – they didn't change depending on the data available. Here, the card needs to be constructed dynamically based on the user's current task list.

The UI for the codelab consists of a text input along with a list of tasks with check boxes to mark them complete. Each of these also has an onChangeAction property that results in a callback into the add-on server when the user adds or deletes a task. In each of these cases, the UI needs to be rerendered with the updated task list. To handle this, let's introduce a new method for building the card UI.

Continue to edit index.js and add the following method:

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

Update the routes

Now that there are helper methods to read and write to Datastore and build the UI, let's wire them together in the app routes. Replace the existing route and add two more: one for adding tasks and one for deleting them.

app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

Here's the final, fully functional index.js file:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date()
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    // Input for adding a new task
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        // Create text & checkbox for each task
        tasks.forEach(task => taskListSection.widgets.push({
            decoratedText: {
                text: task.text,
                wrapText: true,
                switchControl: {
                    controlType: 'CHECKBOX',
                    name: 'completedTasks',
                    value: task[datastore.KEY].id,
                    selected: false,
                    onChangeAction: {
                        function: `${baseUrl}/complete`,
                    }
                }
            }
        }));
    } else {
        // Placeholder for empty task list
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

Redeploy and test

To rebuild and redeploy the add-on, start a build. In Cloud Shell, run:

gcloud builds submit

In Gmail, reload the add-on and the new UI appears. Take a minute to explore the add-on. Add a few tasks by entering some text into the input and pressing ENTER on your keyboard, then click the checkbox to delete them.

Add-on with tasks

If you'd like, you can skip ahead to the final step in this codelab and clean up your project. Or, if you'd like to continue learning more about add-ons, there's one more step you can complete.

7. (Optional) Adding context

One of the most powerful features of add-ons is context-awareness. Add-ons can, with user permission, access Google Workspace contexts such as the email a user is looking at, a calendar event, and a document. Add-ons can also take actions such as inserting content. In this codelab, you'll add context support for the Workspace editors (Docs, Sheets, and Slides) to attach the current document to any tasks created while in the editors. When the task is displayed, clicking on it will then open the document in a new tab to bring the user back to document to finish their task.

Update the add-on backend

Update the newTask route

First, update the /newTask route to include the document id in a task if it is available:

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

Newly created tasks now include the current document ID. However, context in the editors is not shared by default. Like other user data, the user must grant permission for the add-on to access the data. To prevent over-sharing of information, the preferred approach is to request and grant permission on a per-file basis.

Update the UI

In index.js, update buildCard to make two changes. The first is updating the rendering of the tasks to include a link to the document if present. The second is to display an optional authorization prompt if the add-on is rendered in an editor and file access isn't yet granted.

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

Implement the file authorization route

The authorization button adds a new route to the app, so let's implement it. This route introduces a new concept, host app actions. These are special instructions for interacting with the add-on's host application. In this case, to request access to the current editor file.

In index.js, add the /authorizeFile route:

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

Here's the final, fully functional index.js file:

const express = require('express');
const asyncHandler = require('express-async-handler');
const { OAuth2Client } = require('google-auth-library');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

// Create and configure the app
const app = express();

// Trust GCPs front end to for hostname/port forwarding
app.set("trust proxy", true);
app.use(express.json());

// Initial route for the add-on
app.post('/', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);
    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        action: {
            navigations: [{
                pushCard: card
            }]
        }
    };
    res.json(responsePayload);
}));

app.post('/newTask', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const newTask = formInputs.newTask;
    if (!newTask || !newTask.stringInputs) {
        return {};
    }

    // Get the current document if it is present
    const editorInfo = event.docs || event.sheets || event.slides;
    let document = null;
    if (editorInfo && editorInfo.id) {
        document = {
            id: editorInfo.id,
        }
    }
    const task = {
        text: newTask.stringInputs.value[0],
        created: new Date(),
        document,
    };
    await addTask(user.sub, task);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task added.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/complete', asyncHandler(async (req, res) => {
    const event = req.body;
    const user = await userInfo(event);

    const formInputs = event.commonEventObject.formInputs || {};
    const completedTasks = formInputs.completedTasks;
    if (!completedTasks || !completedTasks.stringInputs) {
        return {};
    }

    await deleteTasks(user.sub, completedTasks.stringInputs.value);

    const tasks = await listTasks(user.sub);
    const card = buildCard(req, tasks);
    const responsePayload = {
        renderActions: {
            action: {
                navigations: [{
                    updateCard: card
                }],
                notification: {
                    text: 'Task completed.'
                },
            }
        }
    };
    res.json(responsePayload);
}));

app.post('/authorizeFile', asyncHandler(async (req, res) => {
    const responsePayload = {
        renderActions: {
            hostAppAction: {
                editorAction: {
                    requestFileScopeForActiveDocument: {}
                }
            },
        }
    };
    res.json(responsePayload);
}));

function buildCard(req, tasks) {
    const baseUrl = `${req.protocol}://${req.hostname}${req.baseUrl}`;
    const inputSection = {
        widgets: [
            {
                textInput: {
                    label: 'Task to add',
                    name: 'newTask',
                    value: '',
                    onChangeAction: {
                        function: `${baseUrl}/newTask`,
                    },
                }
            }
        ]
    };
    const taskListSection = {
        header: 'Your tasks',
        widgets: []
    };
    if (tasks && tasks.length) {
        tasks.forEach(task => {
            const widget = {
                decoratedText: {
                    text: task.text,
                    wrapText: true,
                    switchControl: {
                        controlType: 'CHECKBOX',
                        name: 'completedTasks',
                        value: task[datastore.KEY].id,
                        selected: false,
                        onChangeAction: {
                            function: `${baseUrl}/complete`,
                        }
                    }
                }
            };
            // Make item clickable and open attached doc if present
            if (task.document) {
                widget.decoratedText.bottomLabel = 'Click to open  document.';
                const id = task.document.id;
                const url = `https://drive.google.com/open?id=${id}`
                widget.decoratedText.onClick = {
                    openLink: {
                        openAs: 'FULL_SIZE',
                        onClose: 'NOTHING',
                        url: url,
                    }
                }
            }
            taskListSection.widgets.push(widget)
        });
    } else {
        taskListSection.widgets.push({
            textParagraph: {
                text: 'Your task list is empty.'
            }
        });
    }
    const card = {
        sections: [
            inputSection,
            taskListSection,
        ]
    };

    // Display file authorization prompt if the host is an editor
    // and no doc ID present
    const event = req.body;
    const editorInfo = event.docs || event.sheets || event.slides;
    const showFileAuth = editorInfo && editorInfo.id === undefined;
    if (showFileAuth) {
        card.fixedFooter = {
            primaryButton: {
                text: 'Authorize file access',
                onClick: {
                    action: {
                        function: `${baseUrl}/authorizeFile`,
                    }
                }
            }
        }
    }
    return card;
}

async function userInfo(event) {
    const idToken = event.authorizationEventObject.userIdToken;
    const authClient = new OAuth2Client();
    const ticket = await authClient.verifyIdToken({
        idToken
    });
    return ticket.getPayload();
}

async function listTasks(userId) {
    const parentKey = datastore.key(['User', userId]);
    const query = datastore.createQuery('Task')
        .hasAncestor(parentKey)
        .order('created')
        .limit(20);
    const [tasks] = await datastore.runQuery(query);
    return tasks;;
}

async function addTask(userId, task) {
    const key = datastore.key(['User', userId, 'Task']);
    const entity = {
        key,
        data: task,
    };
    await datastore.save(entity);
    return entity;
}

async function deleteTasks(userId, taskIds) {
    const keys = taskIds.map(id => datastore.key(['User', userId,
                                                  'Task', datastore.int(id)]));
    await datastore.delete(keys);
}

// Start the server
const port = process.env.PORT || 8080;
app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
});

Add scopes to the deployment descriptor

Before rebuilding the server, update the add-on deployment descriptor to include the https://www.googleapis.com/auth/drive.file OAuth scope. Update deployment.json to add https://www.googleapis.com/auth/drive.file to the list of OAuth scopes:

"oauthScopes": [
    "https://www.googleapis.com/auth/gmail.addons.execute",
    "https://www.googleapis.com/auth/calendar.addons.execute",
    "https://www.googleapis.com/auth/drive.file",
    "openid",
    "email"
]

Upload the new version by running this Cloud Shell command:

gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json

Redeploy and test

Finally, rebuild of the server. From Cloud Shell, run:

gcloud builds submit

Once complete, instead of opening Gmail, open an existing Google document or create a new one by opening doc.new. If creating a new doc, be sure to enter some text or give the file a name.

Open the add-on. The add-on displays an Authorize File Access button at the bottom of the add-on. Click the button, then authorize access to the file.

Once authorized, add a task while in the editor. The task features a label indicating that the document is attached. Clicking on the link opens the document in a new tab. Of course, opening the document you already have open is a little silly. If you'd like to optimize the UI to filter out links for the current document, consider that extra credit!

8. Congratulations

Congratulations! You've successfully built and deployed a Google Workpace Add-on using Cloud Run. While the codelab covered many of the core concepts for building an add-on, there's a lot more to explore. See the resources below and don't forget to clean up your project to avoid additional charges.

Clean up

To uninstall the add-on from your account, in Cloud Shell, run this command:

gcloud workspace-add-ons deployments uninstall todo-add-on

To avoid incurring charges to your Google Cloud Platform account for the resources used in this tutorial:

  • In the Cloud Console, go to the Manage resources page. Click At the top-left corner, click Menu menu icon > IAM & Admin > Manage Resources.
  1. In the project list, select your project then click Delete.
  2. In the dialog, type the project ID and then click Shut down to delete the project.

Learn more