1. Introduction
Les modules complémentaires Google Workspace sont des applications personnalisées qui s'intègrent aux applications Google Workspace telles que Gmail, Docs, Sheets et Slides. Elles permettent aux développeurs de créer des interfaces utilisateur personnalisées directement intégrées à Google Workspace. Les modules complémentaires aident les utilisateurs à travailler plus efficacement en limitant les changements de contexte.
Dans cet atelier de programmation, vous allez apprendre à créer et à déployer un module complémentaire de liste de tâches simple à l'aide de Node.js, Cloud Run et Datastore.
Points abordés
- Utiliser Cloud Shell
- Déployer dans Cloud Run
- Créer et déployer un descripteur de déploiement de module complémentaire
- Créer des UI de modules complémentaires avec le framework de fiches
- Répondre aux interactions des utilisateurs
- Exploiter le contexte utilisateur dans un module complémentaire
2. Préparation
Suivez les instructions de configuration pour créer un projet Google Cloud et activer les API et les services que le module complémentaire utilisera.
Configuration de l'environnement au rythme de chacun
- Ouvrez Cloud Console et créez un projet. (Si vous ne possédez pas encore de compte Gmail ou Google Workspace, créez-en un.)
Mémorisez l'ID du projet. Il s'agit d'un nom unique permettant de différencier chaque projet Google Cloud (le nom ci-dessus est déjà pris ; vous devez en trouver un autre). Il sera désigné par le nom PROJECT_ID tout au long de cet atelier de programmation.
- Ensuite, pour pouvoir utiliser les ressources Google Cloud, activez la facturation dans la console Cloud.
L'exécution de cet atelier de programmation est très peu coûteuse, voire sans frais. Veillez à suivre les instructions de la section "Effectuer un nettoyage" à la fin de l'atelier de programmation, qui indique comment désactiver les ressources afin d'éviter toute facturation au-delà de ce tutoriel. Les nouveaux utilisateurs de Google Cloud peuvent participer au programme d'essai sans frais pour bénéficier d'un crédit de 300 $.
Google Cloud Shell
Bien que Google Cloud puisse être utilisé à distance depuis votre ordinateur portable, nous allons nous servir de Google Cloud Shell pour cet atelier de programmation, un environnement de ligne de commande exécuté dans le cloud.
Activer Cloud Shell
- Dans Cloud Console, cliquez sur Activer Cloud Shell
.
Lorsque vous ouvrez Cloud Shell pour la première fois, un message de bienvenue descriptif s'affiche. Si le message de bienvenue s'affiche, cliquez sur Continuer. Le message de bienvenue n'apparaîtra plus. Voici le message de bienvenue :
Le provisionnement et la connexion à Cloud Shell ne devraient pas prendre plus de quelques minutes. Une fois connecté, le terminal Cloud Shell s'affiche :
Cette machine virtuelle contient tous les outils de développement dont vous avez besoin. Elle comprend un répertoire d'accueil persistant de 5 Go et s'exécute sur Google Cloud, ce qui améliore nettement les performances du réseau et l'authentification. Vous pouvez réaliser toutes les activités de cet atelier dans un simple navigateur ou sur votre Chromebook.
Une fois connecté à Cloud Shell, vous êtes en principe authentifié, et le projet est déjà défini avec votre ID de projet.
- Exécutez la commande suivante dans Cloud Shell pour vérifier que vous êtes authentifié :
gcloud auth list
Si vous êtes invité à autoriser Cloud Shell à effectuer un appel d'API GCP, cliquez sur Autoriser.
Résultat de la commande
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Pour définir le compte actif, exécutez la commande suivante :
gcloud config set account <ACCOUNT>
Pour vérifier que vous avez sélectionné le bon projet, exécutez la commande suivante dans Cloud Shell :
gcloud config list project
Résultat de la commande
[core] project = <PROJECT_ID>
Si vous obtenez un résultat différent, exécutez cette commande :
gcloud config set project <PROJECT_ID>
Résultat de la commande
Updated property [core/project].
Cet atelier de programmation utilise à la fois des opérations de ligne de commande et des modifications de fichiers. Pour modifier des fichiers, vous pouvez utiliser l'éditeur de code intégré à Cloud Shell en cliquant sur le bouton Ouvrir l'éditeur à droite de la barre d'outils Cloud Shell. D'autres éditeurs courants, tels que vim et emacs, sont également disponibles dans Cloud Shell.
3. Activer les API Cloud Run, Datastore et Add-ons
Activer API Cloud
Dans Cloud Shell, activez les API Cloud pour les composants qui seront utilisés :
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
Cette opération peut prendre quelques minutes.
Une fois l'opération terminée, un message de confirmation semblable à celui-ci s'affiche :
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Créer une instance de datastore
Ensuite, activez App Engine et créez une base de données Datastore. L'activation d'App Engine est une condition préalable à l'utilisation de Datastore, mais nous n'utiliserons pas App Engine pour autre chose.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Créer un écran de consentement OAuth
Le module complémentaire nécessite l'autorisation de l'utilisateur pour s'exécuter et agir sur ses données. Pour ce faire, configurez l'écran de consentement du projet. Pour cet atelier de programmation, vous allez configurer l'écran de consentement en tant qu'application interne (c'est-à-dire non destinée à la distribution publique) pour commencer.
- Ouvrez la console Google Cloud dans un nouvel onglet ou une nouvelle fenêtre.
- À côté de "Console Google Cloud", cliquez sur la flèche vers le bas
, puis sélectionnez votre projet. - En haut à gauche, cliquez sur Menu
. - Cliquez sur API et services > Identifiants. La page des identifiants de votre projet s'affiche.
- Cliquez sur l'écran de consentement OAuth. La page "Écran de consentement OAuth" s'affiche.
- Sous "Type d'utilisateur", sélectionnez Interne. Si vous utilisez un compte @gmail.com, sélectionnez Externe.
- Cliquez sur Créer. La page "Modifier l'enregistrement de l'application" s'affiche.
- Remplissez le formulaire :
- Dans Nom de l'application, saisissez "Todo Add-on".
- Dans le champ Adresse e-mail d'assistance utilisateur, saisissez votre adresse e-mail personnelle.
- Dans Coordonnées du développeur, saisissez votre adresse e-mail personnelle.
- Cliquez sur Enregistrer et continuer. Un formulaire "Champs d'application" s'affiche.
- Dans le formulaire "Champs d'application", cliquez sur Enregistrer et continuer. Un récapitulatif s'affiche.
- Cliquez sur Revenir au tableau de bord.
4. Créer le module complémentaire initial
Initialiser le projet
Pour commencer, vous allez créer un module complémentaire "Hello World" simple et le déployer. Les modules complémentaires sont des services Web qui répondent aux requêtes HTTPS avec une charge utile JSON décrivant l'UI et les actions à effectuer. Dans ce module complémentaire, vous utiliserez Node.js et le framework Express.
Pour créer ce projet de modèle, utilisez Cloud Shell pour créer un répertoire nommé todo-add-on et accédez-y :
mkdir ~/todo-add-on cd ~/todo-add-on
Vous effectuerez toutes les tâches de cet atelier de programmation dans ce répertoire.
Initialisez le projet Node.js :
npm init
GPR pose plusieurs questions sur la configuration du projet, telles que son nom et sa version. Pour chaque question, appuyez sur ENTER pour accepter les valeurs par défaut. Le point d'entrée par défaut est un fichier nommé index.js, que nous allons créer ensuite.
Installez ensuite le framework Web Express :
npm install --save express express-async-handler
Créer le backend du module complémentaire
Il est temps de commencer à créer l'application.
Créez un fichier nommé index.js. Pour créer des fichiers, vous pouvez utiliser l'éditeur Cloud Shell en cliquant sur le bouton Ouvrir l'éditeur dans la barre d'outils de la fenêtre Cloud Shell. Vous pouvez également modifier et gérer des fichiers dans Cloud Shell à l'aide de vim ou emacs.
Après avoir créé le fichier index.js, ajoutez le contenu suivant :
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}`)
});
Le serveur ne fait pas grand-chose à part afficher le message "Hello world", et c'est normal. Vous ajouterez des fonctionnalités par la suite.
Déployer dans Cloud Run
Pour déployer l'application sur Cloud Run, vous devez la conteneuriser.
Créer le conteneur
Créez un fichier Dockerfile nommé Dockerfile contenant :
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" ]
Éviter d'inclure des fichiers indésirables dans le conteneur
Pour que le conteneur reste léger, créez un fichier .dockerignore contenant les éléments suivants :
Dockerfile
.dockerignore
node_modules
npm-debug.log
Activer Cloud Build
Dans cet atelier de programmation, vous allez créer et déployer le module complémentaire à plusieurs reprises à mesure que de nouvelles fonctionnalités sont ajoutées. Au lieu d'exécuter des commandes distinctes pour créer le conteneur, le transférer vers Container Registry et le déployer sur Cloud Run, utilisez Cloud Build pour orchestrer la procédure. Créez un fichier cloudbuild.yaml contenant des instructions sur la façon de créer et de déployer l'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
Exécutez les commandes suivantes pour autoriser Cloud Build à déployer l'application :
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
Créer et déployer le backend du module complémentaire
Pour démarrer la compilation, exécutez la commande suivante dans Cloud Shell :
gcloud builds submit
La compilation et le déploiement complets peuvent prendre quelques minutes, en particulier la première fois.
Une fois la compilation terminée, vérifiez que le service est déployé et recherchez l'URL. Exécutez la commande suivante :
gcloud run services list --platform managed
Copiez cette URL, car vous en aurez besoin à l'étape suivante pour indiquer à Google Workspace comment appeler le module complémentaire.
Enregistrer le module complémentaire
Maintenant que le serveur est opérationnel, décrivez le module complémentaire pour que Google Workspace sache comment l'afficher et l'appeler.
Créer un descripteur de déploiement
Créez le fichier deployment.json avec le contenu suivant. Veillez à utiliser l'URL de l'application déployée à la place de l'espace réservé URL.
{
"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": {}
}
}
Importez le descripteur de déploiement en exécutant la commande suivante :
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Autoriser l'accès au backend du module complémentaire
Le framework de modules complémentaires a également besoin d'une autorisation pour appeler le service. Exécutez les commandes suivantes pour mettre à jour la stratégie IAM pour Cloud Run afin d'autoriser Google Workspace à appeler le module complémentaire :
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"
Installer le module complémentaire pour les tests
Pour installer le module complémentaire en mode développement pour votre compte, exécutez la commande suivante dans Cloud Shell :
gcloud workspace-add-ons deployments install todo-add-on
Ouvrez (Gmail)[https://mail.google.com/] dans un nouvel onglet ou une nouvelle fenêtre. Sur la droite, recherchez le module complémentaire avec une icône en forme de coche.

Pour ouvrir le module complémentaire, cliquez sur l'icône en forme de coche. Une invite s'affiche pour autoriser le module complémentaire.

Cliquez sur Autoriser l'accès, puis suivez les instructions de la procédure d'autorisation dans le pop-up. Une fois l'opération terminée, le module complémentaire se recharge automatiquement et affiche le message "Hello world!".
Félicitations ! Vous avez maintenant un module complémentaire simple déployé et installé. Il est temps de la transformer en application de liste de tâches !
5. Accéder à l'identité de l'utilisateur
Les modules complémentaires sont généralement utilisés par de nombreux utilisateurs pour travailler avec des informations privées ou propres à leur organisation. Dans cet atelier de programmation, le module complémentaire ne doit afficher que les tâches de l'utilisateur actuel. L'identité de l'utilisateur est envoyée au module complémentaire via un jeton d'identité qui doit être décodé.
Ajouter des niveaux d'accès au fichier descripteur de déploiement
L'identité de l'utilisateur n'est pas envoyée par défaut. Il s'agit de données utilisateur, et le module complémentaire a besoin d'une autorisation pour y accéder. Pour obtenir cette autorisation, mettez à jour deployment.json et ajoutez les habilitations OAuth openid et email à la liste des habilitations requises par le module complémentaire. Après avoir ajouté des habilitations OAuth, le module complémentaire invite les utilisateurs à accorder l'accès la prochaine fois qu'ils l'utiliseront.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Ensuite, dans Cloud Shell, exécutez cette commande pour mettre à jour le descripteur de déploiement :
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Mettre à jour le serveur de modules complémentaires
Bien que le module complémentaire soit configuré pour demander l'identité de l'utilisateur, l'implémentation doit encore être mise à jour.
Analyser le jeton d'identité
Commencez par ajouter la bibliothèque d'authentification Google au projet :
npm install --save google-auth-library
Modifiez ensuite index.js pour exiger OAuth2Client :
const { OAuth2Client } = require('google-auth-library');
Ajoutez ensuite une méthode d'assistance pour analyser le jeton d'identité :
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Afficher l'identité de l'utilisateur
C'est le moment idéal pour faire le point avant d'ajouter toutes les fonctionnalités de la liste des tâches. Mettez à jour la route de l'application pour imprimer l'adresse e-mail et l'ID unique de l'utilisateur au lieu de "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);
}));
Une fois ces modifications effectuées, le fichier index.js obtenu devrait se présenter comme suit :
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}`)
});
Redéployer et tester
Recompilez et redéployez le module complémentaire. Dans Cloud Shell, exécutez la commande suivante :
gcloud builds submit
Une fois le serveur redéployé, ouvrez ou actualisez Gmail, puis ouvrez à nouveau le module complémentaire. Étant donné que les niveaux d'accès ont changé, le module complémentaire demandera une nouvelle autorisation. Autorisez à nouveau le module complémentaire. Une fois l'opération terminée, votre adresse e-mail et votre ID utilisateur s'affichent.
Maintenant que le module complémentaire sait qui est l'utilisateur, vous pouvez commencer à ajouter la fonctionnalité de liste de tâches.
6. Implémenter la liste de tâches
Le modèle de données initial de l'atelier de programmation est simple : il s'agit d'une liste d'entités Task, chacune avec des propriétés pour le texte descriptif de la tâche et un code temporel.
Créer l'index du datastore
Datastore a déjà été activé pour le projet plus tôt dans l'atelier de programmation. Il ne nécessite pas de schéma, mais il faut créer explicitement des index pour les requêtes composées. La création de l'index peut prendre quelques minutes. Vous allez donc commencer par cette étape.
Créez un fichier nommé index.yaml avec le contenu suivant :
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Mettez ensuite à jour les index Datastore :
gcloud datastore indexes create index.yaml
Lorsque vous êtes invité à continuer, appuyez sur ENTRÉE sur votre clavier. La création de l'index s'effectue en arrière-plan. Pendant ce temps, commencez à mettre à jour le code du module complémentaire pour implémenter les "todos".
Mettre à jour le backend du module complémentaire
Installez la bibliothèque Datastore dans le projet :
npm install --save @google-cloud/datastore
Lire et écrire dans Datastore
Mettez à jour index.js pour implémenter les "todos" en commençant par importer la bibliothèque Datastore et créer le client :
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Ajoutez des méthodes pour lire et écrire des tâches à partir de 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);
}
Implémenter l'affichage de l'UI
La plupart des modifications concernent l'UI du module complémentaire. Auparavant, toutes les cartes renvoyées par l'UI étaient statiques. Elles ne changeaient pas en fonction des données disponibles. Ici, la fiche doit être construite de manière dynamique en fonction de la liste des tâches actuelles de l'utilisateur.
L'UI de l'atelier de programmation se compose d'une entrée de texte et d'une liste de tâches avec des cases à cocher pour les marquer comme terminées. Chacun d'eux possède également une propriété onChangeAction qui entraîne un rappel sur le serveur du module complémentaire lorsque l'utilisateur ajoute ou supprime une tâche. Dans chacun de ces cas, l'UI doit être rendue à nouveau avec la liste des tâches mise à jour. Pour résoudre ce problème, nous allons introduire une nouvelle méthode de création de l'UI de la carte.
Continuez à modifier index.js et ajoutez la méthode suivante :
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;
}
Mettre à jour les routes
Maintenant que nous disposons de méthodes d'assistance pour lire et écrire dans Datastore et créer l'UI, nous allons les relier dans les routes de l'application. Remplacez l'itinéraire existant et ajoutez-en deux autres : un pour ajouter des tâches et un pour les supprimer.
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);
}));
Voici le fichier index.js final et entièrement fonctionnel :
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}`)
});
Redéployer et tester
Pour reconstruire et redéployer le module complémentaire, lancez une compilation. Dans Cloud Shell, exécutez la commande suivante :
gcloud builds submit
Dans Gmail, actualisez le module complémentaire pour afficher la nouvelle interface utilisateur. Prenez quelques instants pour explorer le module complémentaire. Ajoutez quelques tâches en saisissant du texte dans le champ de saisie et en appuyant sur ENTRÉE sur votre clavier, puis cochez la case pour les supprimer.

Si vous le souhaitez, vous pouvez passer directement à la dernière étape de cet atelier de programmation et nettoyer votre projet. Si vous souhaitez en savoir plus sur les modules complémentaires, vous pouvez suivre une étape supplémentaire.
7. (Facultatif) Ajouter du contexte
L'une des fonctionnalités les plus puissantes des modules complémentaires est la prise en compte du contexte. Avec l'autorisation de l'utilisateur, les modules complémentaires peuvent accéder aux contextes Google Workspace, tels que l'e-mail qu'un utilisateur consulte, un événement d'agenda et un document. Les modules complémentaires peuvent également effectuer des actions telles que l'insertion de contenu. Dans cet atelier de programmation, vous allez ajouter la prise en charge du contexte pour les éditeurs Workspace (Docs, Sheets et Slides) afin de joindre le document actuel aux tâches créées dans les éditeurs. Lorsque la tâche s'affiche, l'utilisateur peut cliquer dessus pour ouvrir le document dans un nouvel onglet et terminer sa tâche.
Mettre à jour le backend du module complémentaire
Mettre à jour l'itinéraire newTask
Commencez par mettre à jour la route /newTask pour inclure l'ID du document dans une tâche, le cas échéant :
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);
}));
Les tâches nouvellement créées incluent désormais l'ID du document actuel. Toutefois, le contexte dans les éditeurs n'est pas partagé par défaut. Comme pour les autres données utilisateur, l'utilisateur doit autoriser le module complémentaire à accéder aux données. Pour éviter le partage excessif d'informations, l'approche privilégiée consiste à demander et à accorder l'autorisation fichier par fichier.
Mettre à jour l'UI
Dans index.js, mettez à jour buildCard pour effectuer deux modifications. La première consiste à mettre à jour le rendu des tâches pour inclure un lien vers le document, le cas échéant. La seconde consiste à afficher une invite d'autorisation facultative si le module complémentaire est affiché dans un éditeur et que l'accès aux fichiers n'est pas encore accordé.
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;
}
Implémenter la route d'autorisation de fichier
Le bouton d'autorisation ajoute une nouvelle route à l'application. Implémentons-la. Cette route présente un nouveau concept : les actions d'application hôte. Il s'agit d'instructions spéciales pour interagir avec l'application hôte du module complémentaire. Dans ce cas, pour demander l'accès au fichier de l'éditeur actuel.
Dans index.js, ajoutez la route /authorizeFile :
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Voici le fichier index.js final et entièrement fonctionnel :
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}`)
});
Ajouter des niveaux d'accès au fichier descripteur de déploiement
Avant de reconstruire le serveur, mettez à jour le fichier manifeste du module complémentaire pour inclure le champ d'application OAuth https://www.googleapis.com/auth/drive.file. Mettez à jour deployment.json pour ajouter https://www.googleapis.com/auth/drive.file à la liste des champs d'application OAuth :
"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"
]
Importez la nouvelle version en exécutant cette commande Cloud Shell :
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Redéployer et tester
Enfin, reconstruisez le serveur. Dans Cloud Shell, exécutez la commande suivante :
gcloud builds submit
Une fois l'opération terminée, au lieu d'ouvrir Gmail, ouvrez un document Google existant ou créez-en un en ouvrant doc.new. Si vous créez un document, veillez à saisir du texte ou à lui donner un nom.
Ouvrez le module complémentaire. Le module complémentaire affiche un bouton Autoriser l'accès aux fichiers en bas de l'écran. Cliquez sur le bouton, puis autorisez l'accès au fichier.
Une fois l'autorisation accordée, ajoutez une tâche dans l'éditeur. La tâche comporte un libellé indiquant que le document est joint. Si vous cliquez sur le lien, le document s'ouvre dans un nouvel onglet. Bien sûr, ouvrir un document déjà ouvert n'a pas beaucoup de sens. Si vous souhaitez optimiser l'UI pour filtrer les liens du document actuel, vous pouvez considérer que vous avez gagné des points supplémentaires !
8. Félicitations
Félicitations ! Vous venez de créer et de déployer un module complémentaire Google Workspace à l'aide de Cloud Run. Cet atelier de programmation a abordé de nombreux concepts fondamentaux de la création d'un module complémentaire. Il reste toutefois beaucoup à découvrir. Consultez les ressources ci-dessous et n'oubliez pas de nettoyer votre projet pour éviter tous frais supplémentaires.
Effectuer un nettoyage
Pour désinstaller le module complémentaire de votre compte, exécutez la commande suivante dans Cloud Shell :
gcloud workspace-add-ons deployments uninstall todo-add-on
Afin d'éviter la facturation sur votre compte Google Cloud Platform des ressources utilisées dans ce tutoriel, procédez comme suit :
- Dans la console Cloud, accédez à la page Gérer les ressources. En haut à gauche, cliquez sur Menu
> IAM et administration > Gérer les ressources.
- Dans la liste des projets, sélectionnez votre projet, puis cliquez sur Supprimer.
- Dans la boîte de dialogue, saisissez l'ID du projet, puis cliquez sur Arrêter pour supprimer le projet.
En savoir plus
- Présentation des modules complémentaires Google Workspace
- Rechercher des applications et des modules complémentaires existants sur le Marketplace