1. Introducción
Los complementos de Google Workspace son aplicaciones personalizadas que se integran en aplicaciones de Google Workspace, como Gmail, Documentos, Hojas de cálculo y Presentaciones. Permiten que los desarrolladores creen interfaces de usuario personalizadas que están integradas directamente en Google Workspace. Los complementos ayudan a los usuarios a trabajar de forma más eficiente con menos cambios de contexto.
En este codelab, aprenderás a compilar e implementar un complemento simple de lista de tareas con Node.js, Cloud Run y Datastore.
Qué aprenderás
- Cómo usar Cloud Shell
- Implementa en Cloud Run
- Crea e implementa un descriptor de implementación de complementos
- Cómo crear IU de complementos con el marco de trabajo de tarjetas
- Cómo responder a las interacciones de los usuarios
- Aprovecha el contexto del usuario en un complemento
2. Configuración y requisitos
Sigue las instrucciones de configuración para crear un proyecto de Google Cloud y habilitar las APIs y los servicios que usará el complemento.
Configuración del entorno de autoaprendizaje
- Abre la consola de Cloud y crea un proyecto nuevo. (Si aún no tienes una cuenta de Gmail o de Google Workspace, crea una).
Recuerde el ID de proyecto, un nombre único en todos los proyectos de Google Cloud (el nombre anterior ya se encuentra en uso y no lo podrá usar). Se mencionará más adelante en este codelab como PROJECT_ID
.
- A continuación, habilita la facturación en la consola de Cloud para usar los recursos de Google Cloud.
Ejecutar este codelab no debería costar mucho, tal vez nada. Asegúrate de seguir las instrucciones de la sección “Realiza una limpieza” al final del codelab, en la que se aconseja cómo cerrar los recursos para que no se te facture más allá de este instructivo. Los usuarios nuevos de Google Cloud son aptos para participar en el programa Prueba gratuita de $300.
Google Cloud Shell
Si bien Google Cloud se puede operar de manera remota desde tu laptop, en este codelab usaremos Google Cloud Shell, un entorno de línea de comandos que se ejecuta en la nube.
Activar Cloud Shell
- En la consola de Cloud, haz clic en Activar Cloud Shell.
La primera vez que abras Cloud Shell, verás un mensaje de bienvenida descriptivo. Si lo ves, haz clic en Continuar. El mensaje de bienvenida no volverá a aparecer. Este es el mensaje de bienvenida:
El aprovisionamiento y la conexión a Cloud Shell solo tomará unos minutos. Después de conectarte, verás la terminal de Cloud Shell:
Esta máquina virtual está cargada con todas las herramientas de desarrollo que necesitas. Ofrece un directorio principal persistente de 5 GB y se ejecuta en Google Cloud, lo que permite mejorar considerablemente el rendimiento de la red y la autenticación. Todo tu trabajo en este codelab se puede hacer con un navegador o tu Chromebook.
Una vez que te conectes a Cloud Shell, deberías ver que ya se te autenticó y que el proyecto ya se configuró con el ID de tu proyecto.
- En Cloud Shell, ejecuta el siguiente comando para confirmar que tienes la autenticación:
gcloud auth list
Si se te solicita que autorices que Cloud Shell realice una llamada a la API de GCP, haz clic en Autorizar.
Resultado del comando
Credentialed Accounts ACTIVE ACCOUNT * <my_account>@<my_domain.com>
Para configurar la cuenta activa, ejecuta el siguiente comando:
gcloud config set account <ACCOUNT>
Para confirmar que seleccionaste el proyecto correcto, en Cloud Shell, ejecuta el siguiente comando:
gcloud config list project
Resultado del comando
[core] project = <PROJECT_ID>
Si no aparece el proyecto correcto, puedes establecerlo con este comando:
gcloud config set project <PROJECT_ID>
Resultado del comando
Updated property [core/project].
En el codelab, se usa una combinación de operaciones de línea de comandos y la edición de archivos. Para editar archivos, puedes usar el editor de código integrado en Cloud Shell. Para ello, haz clic en el botón Abrir editor en el lado derecho de la barra de herramientas de Cloud Shell. También encontrarás editores populares, como Vim y Emacs, disponibles en Cloud Shell.
3. Habilita Cloud Run, Datastore y las APIs de complementos
Habilita las API de Cloud
En Cloud Shell, habilita las APIs de Cloud para los componentes que se usarán:
gcloud services enable \ run.googleapis.com \ cloudbuild.googleapis.com \ cloudresourcemanager.googleapis.com \ datastore.googleapis.com \ gsuiteaddons.googleapis.com
Es posible que esta operación se tarde unos minutos en completarse.
Cuando se complete, aparecerá un mensaje de éxito similar a este:
Operation "operations/acf.cc11852d-40af-47ad-9d59-477a12847c9e" finished successfully.
Crea una instancia de almacén de datos
A continuación, habilita App Engine y crea una base de datos de Datastore. Habilitar App Engine es un requisito previo para usar Datastore, pero no lo usaremos con nada más.
gcloud app create --region=us-central gcloud firestore databases create --type=datastore-mode --region=us-central
Crea una pantalla de consentimiento de OAuth
El complemento requiere permiso del usuario para ejecutar sus datos y tomar medidas con respecto a ellos. Para habilitar esta opción, configura la pantalla de consentimiento del proyecto. En el codelab, para comenzar, configurarás la pantalla de consentimiento como una aplicación interna, lo que significa que no es de distribución pública.
- Abre la consola de Google Cloud en una pestaña o ventana nueva.
- Junto a la "consola de Google Cloud", Haz clic en la flecha hacia abajo y selecciona tu proyecto.
- En la esquina superior izquierda, haz clic en Menú .
- Haz clic en APIs y Servicios > Credenciales. Aparecerá la página de credenciales de tu proyecto.
- Haz clic en la pantalla de consentimiento de OAuth. La "pantalla de consentimiento de OAuth" aparecerá una pantalla.
- En "Tipo de usuario", selecciona Interno. Si usas una cuenta del tipo @gmail.com, selecciona Externo.
- Haz clic en Crear. Un permiso de "Editar registro de app" .
- Complete el formulario:
- En Nombre de la app, escribe "Complemento de tareas pendientes".
- En Correo electrónico de asistencia del usuario, ingresa tu dirección de correo electrónico personal.
- En Información de contacto del desarrollador, ingresa tu dirección de correo electrónico personal.
- Haga clic en Guardar y continuar. Aparecerá un formulario de permisos.
- En el formulario Permisos, haz clic en Guardar y continuar. Aparecerá un resumen.
- Haz clic en Volver al panel.
4. Crea el complemento inicial
Inicializa el proyecto
Para comenzar, crearás una app simple de “Hello World” complemento e implementarlo. Los complementos son servicios web que responden a solicitudes HTTPS y responden con una carga útil de JSON que describe la IU y las acciones que se deben realizar. En este complemento, usarás Node.js y el framework Express.
Para crear este proyecto de plantilla, usa Cloud Shell para crear un directorio nuevo llamado todo-add-on
y navega hasta él:
mkdir ~/todo-add-on cd ~/todo-add-on
En este directorio, harás todo el trabajo para el codelab.
Inicializa el proyecto de Node.js:
npm init
NPM hace varias preguntas sobre la configuración del proyecto, como el nombre y la versión. En cada pregunta, presiona ENTER
para aceptar los valores predeterminados. El punto de entrada predeterminado es un archivo llamado index.js
, que crearemos a continuación.
A continuación, instala el framework web Express:
npm install --save express express-async-handler
Crea el backend del complemento
Es hora de comenzar a crear la app.
Crea un archivo llamado index.js
. Para crear archivos, puedes usar el Editor de Cloud Shell haciendo clic en el botón Abrir editor en la barra de herramientas de la ventana de Cloud Shell. De forma alternativa, puedes editar y administrar archivos en Cloud Shell con Vim o Emacs.
Después de crear el archivo index.js
, agrega el siguiente contenido:
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}`)
});
El servidor no hace mucho más que mostrar el mensaje "Hello world" y eso está bien. Podrás agregarle más funciones más adelante.
Implementa en Cloud Run
Para implementar en Cloud Run, la app debe estar alojada en contenedores.
Crea el contenedor
Crea un Dockerfile llamado Dockerfile
que contenga lo siguiente:
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" ]
Mantén los archivos no deseados fuera del contenedor
Para ayudar a mantener el contenedor ligero, crea un archivo .dockerignore
que contenga lo siguiente:
Dockerfile
.dockerignore
node_modules
npm-debug.log
Habilitar Cloud Build
En este codelab, compilarás e implementarás el complemento varias veces a medida que se agreguen funciones nuevas. En lugar de ejecutar comandos separados para compilar el contenedor, enviarlo al registro del contenedor y, luego, implementarlo en Cloud Build, usa Cloud Build para organizar el procedimiento. Crea un archivo cloudbuild.yaml
con instrucciones para compilar e implementar la aplicación:
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
Ejecuta los siguientes comandos si quieres otorgarle permiso a Cloud Build para implementar la 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
Compila e implementa el backend del complemento
Para iniciar la compilación, en Cloud Shell, ejecuta el siguiente comando:
gcloud builds submit
Es posible que la compilación y la implementación completas tarden unos minutos en completarse, especialmente la primera vez.
Cuando se complete la compilación, verifica que el servicio esté implementado y busca la URL. Ejecuta el siguiente comando:
gcloud run services list --platform managed
Copia esta URL, ya que la necesitarás para el siguiente paso: indicarle a Google Workspace cómo invocar el complemento.
Registra el complemento
Ahora que el servidor está en funcionamiento, describe el complemento para que Google Workspace sepa cómo mostrarlo e invocarlo.
Crea un descriptor de implementación
Crea el archivo deployment.json
con el siguiente contenido. Asegúrate de usar la URL de la app implementada en lugar del marcador de posición 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": {}
}
}
Sube el descriptor de implementación ejecutando el siguiente comando:
gcloud workspace-add-ons deployments create todo-add-on --deployment-file=deployment.json
Autoriza el acceso al backend de complementos
El framework de complementos también necesita permiso para llamar al servicio. Ejecuta los siguientes comandos para actualizar la política de IAM de Cloud Run y permitir que Google Workspace invoque el complemento:
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"
Instala el complemento para realizar pruebas
Para instalar el complemento en modo de desarrollo para tu cuenta, ejecuta el siguiente comando en Cloud Shell:
gcloud workspace-add-ons deployments install todo-add-on
Abre (Gmail)[https://mail.google.com/] en una pestaña o ventana nueva. En el lado derecho, busca el complemento con un ícono de marca de verificación.
Para abrir el complemento, haz clic en el ícono de marca de verificación. Aparecerá un mensaje para autorizar el complemento.
Haz clic en Autorizar acceso y sigue las instrucciones del flujo de autorización que aparecen en la ventana emergente. Cuando se complete el proceso, el complemento se volverá a cargar automáticamente y mostrará el mensaje “Hello world!”. mensaje.
¡Felicitaciones! Ahora tienes un complemento simple implementado y, luego, instalado. Es hora de convertirla en una aplicación de lista de tareas.
5. Accede a la identidad del usuario
Muchos usuarios suelen usar los complementos para trabajar con información privada de ellos o de sus organizaciones. En este codelab, el complemento solo debe mostrar las tareas para el usuario actual. La identidad del usuario se envía al complemento a través de un token de identidad que se debe decodificar.
Agrega permisos al descriptor de implementación
La identidad del usuario no se envía de forma predeterminada. Los datos del usuario y el complemento necesitan permiso para acceder a ellos. Para obtenerlo, actualiza deployment.json
y agrega los permisos de OAuth openid
y email
a la lista de permisos que requiere el complemento. Después de agregar permisos de OAuth, el complemento solicita a los usuarios que otorguen acceso la próxima vez que lo usen.
"oauthScopes": [
"https://www.googleapis.com/auth/gmail.addons.execute",
"https://www.googleapis.com/auth/calendar.addons.execute",
"openid",
"email"
],
Luego, en Cloud Shell, ejecuta este comando para actualizar el descriptor de implementación:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Actualiza el servidor de complementos
Si bien el complemento está configurado para solicitar la identidad del usuario, la implementación debe actualizarse.
Analiza el token de identidad
Para comenzar, agrega la biblioteca de autenticación de Google al proyecto:
npm install --save google-auth-library
Luego, edita index.js
para que requiera OAuth2Client
:
const { OAuth2Client } = require('google-auth-library');
Luego, agrega un método auxiliar para analizar el token de ID:
async function userInfo(event) {
const idToken = event.authorizationEventObject.userIdToken;
const authClient = new OAuth2Client();
const ticket = await authClient.verifyIdToken({
idToken
});
return ticket.getPayload();
}
Muestra la identidad del usuario
Este es un buen momento para realizar un punto de control antes de agregar toda la funcionalidad de la lista de tareas. Actualiza la ruta de la app para imprimir la dirección de correo electrónico y el ID único del usuario en lugar 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);
}));
Después de estos cambios, el archivo index.js
resultante debería verse de la siguiente manera:
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}`)
});
Vuelve a implementar y realiza pruebas
Vuelve a compilar y a implementar el complemento. En Cloud Shell, ejecuta el siguiente comando:
gcloud builds submit
Una vez que se vuelva a implementar el servidor, abre o vuelve a cargar Gmail y abre el complemento otra vez. Debido a que se modificaron los permisos, el complemento solicitará una nueva autorización. Vuelve a autorizar el complemento y, una vez que lo hayas completado, este mostrará tu dirección de correo electrónico y tu ID de usuario.
Ahora que el complemento sabe quién es el usuario, puedes comenzar a agregar la funcionalidad de lista de tareas.
6. Cómo implementar la lista de tareas
El modelo de datos inicial para el codelab es sencillo: una lista de entidades Task
, cada una con propiedades para el texto descriptivo de la tarea y una marca de tiempo.
Crea el índice del almacén de datos
Datastore ya estaba habilitado para el proyecto anterior en el codelab. No requiere un esquema, aunque sí debes crear índices de manera explícita para consultas compuestas. Crear el índice puede tardar unos minutos, así que primero debes hacerlo.
Crea un archivo llamado index.yaml
con lo siguiente:
indexes:
- kind: Task
ancestor: yes
properties:
- name: created
Luego, actualiza los índices de Datastore:
gcloud datastore indexes create index.yaml
Cuando se te solicite continuar, presiona INTRO en el teclado. La creación del índice se realiza en segundo plano. Mientras esto sucede, comienza a actualizar el código del complemento para implementar "todos".
Actualiza el backend del complemento
Instala la biblioteca de Datastore en el proyecto:
npm install --save @google-cloud/datastore
Lee y escribe en Datastore
Actualiza index.js
para implementar “todos” Comienza con la importación de la biblioteca del almacén de datos y la creación del cliente:
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
Agrega métodos para leer y escribir tareas desde 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);
}
Cómo implementar la renderización de la IU
La mayoría de los cambios se aplican a la IU de complementos. Anteriormente, todas las tarjetas mostradas por la IU eran estáticas; no cambiaban según los datos disponibles. Aquí, la tarjeta debe construirse de forma dinámica en función de la lista de tareas actual del usuario.
La IU del codelab consiste en una entrada de texto junto con una lista de tareas con casillas de verificación para marcarlas como completadas. Cada uno de estos métodos también tiene una propiedad onChangeAction
que genera una devolución de llamada al servidor de complementos cuando el usuario agrega o borra una tarea. En cada uno de estos casos, la IU debe volver a renderizarse con la lista de tareas actualizada. A fin de controlar esto, presentaremos un nuevo método para compilar la IU de tarjetas.
Sigue editando index.js
y agrega el siguiente método:
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;
}
Actualiza las rutas
Ahora que hay métodos auxiliares para leer y escribir en Datastore, y compilar la IU, los conectaremos en las rutas de la app. Reemplaza la ruta existente y agrega dos más: una para agregar tareas y otra para borrarlas.
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);
}));
Este es el archivo index.js
final y completamente funcional:
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}`)
});
Vuelve a implementar y realiza pruebas
Para volver a compilar y a implementar el complemento, inicia una compilación. En Cloud Shell, ejecuta este comando:
gcloud builds submit
En Gmail, vuelve a cargar el complemento y aparecerá la nueva IU. Tómate un minuto para explorar el complemento. Para agregar algunas tareas, ingresa texto en la entrada, presiona INTRO en el teclado y, luego, haz clic en la casilla de verificación para borrarlas.
Si lo deseas, puedes pasar al último paso de este codelab y limpiar tu proyecto. O, si quieres seguir aprendiendo más sobre los complementos, puedes completar un paso más.
7. Agrega contexto (opcional)
Una de las funciones más poderosas de los complementos es el conocimiento del contexto. Con el permiso del usuario, los complementos pueden acceder a contextos de Google Workspace, como el correo electrónico que está consultando el usuario, un evento de calendario o un documento. Los complementos también pueden realizar acciones como insertar contenido. En este codelab, agregarás compatibilidad de contexto para que los editores de Workspace (Documentos, Hojas de cálculo y Presentaciones) adjunten el documento actual a cualquier tarea creada en los editores. Cuando se muestre la tarea, si haces clic en ella, se abrirá el documento en una pestaña nueva para que el usuario regrese al documento y complete la tarea.
Actualiza el backend del complemento
Actualiza la ruta newTask
Primero, actualiza la ruta /newTask
para incluir el ID de documento en una tarea si está disponible:
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);
}));
Las tareas recién creadas ahora incluyen el ID del documento actual. Sin embargo, el contexto en los editores no se comparte de forma predeterminada. Al igual que otros datos del usuario, este debe otorgar permiso para que el complemento acceda a los datos. Para evitar el uso compartido excesivo de la información, el enfoque preferido es solicitar y otorgar permisos por archivo.
Cómo actualizar la IU
En index.js
, actualiza buildCard
para hacer dos cambios. La primera es actualizar la renderización de las tareas para incluir un vínculo al documento si está presente. La segunda es mostrar una solicitud de autorización opcional si el complemento se renderiza en un editor y aún no se otorgó el acceso al archivo.
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;
}
Implementa la ruta de autorización de archivos
El botón de autorización agrega una ruta nueva a la app, así que vamos a implementarla. Esta ruta presenta un concepto nuevo: acciones en apps de host. Se trata de instrucciones especiales para interactuar con la aplicación host del complemento. En este caso, para solicitar acceso al archivo del editor actual.
En index.js
, agrega la ruta /authorizeFile
:
app.post('/authorizeFile', asyncHandler(async (req, res) => {
const responsePayload = {
renderActions: {
hostAppAction: {
editorAction: {
requestFileScopeForActiveDocument: {}
}
},
}
};
res.json(responsePayload);
}));
Este es el archivo index.js
final y completamente funcional:
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}`)
});
Agrega permisos al descriptor de implementación
Antes de volver a compilar el servidor, actualiza el descriptor de implementación de complementos para que incluya el alcance de OAuth https://www.googleapis.com/auth/drive.file
. Actualiza deployment.json
para agregar https://www.googleapis.com/auth/drive.file
a la lista de permisos de 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"
]
Sube la versión nueva con este comando de Cloud Shell:
gcloud workspace-add-ons deployments replace todo-add-on --deployment-file=deployment.json
Vuelve a implementar y realiza pruebas
Por último, vuelve a compilar el servidor. En Cloud Shell, ejecuta el siguiente comando:
gcloud builds submit
Cuando termines, en lugar de abrir Gmail, abre un documento de Google existente o crea uno nuevo abriendo doc.new. Si creas un documento nuevo, asegúrate de ingresar texto o darle un nombre al archivo.
Abre el complemento. El complemento muestra el botón Authorize File Access en la parte inferior. Haz clic en el botón y, luego, autoriza el acceso al archivo.
Una vez que se haya autorizado, agrega una tarea desde el editor. La tarea tiene una etiqueta que indica que el documento está adjunto. Si haces clic en el vínculo, se abrirá el documento en una pestaña nueva. Por supuesto, abrir el documento que ya tienes abierto es un poco. Si deseas optimizar la IU para filtrar los vínculos del documento actual, ten en cuenta el crédito adicional.
8. Felicitaciones
¡Felicitaciones! Compilaste e implementaste correctamente un complemento de Google Workpace con Cloud Run. Si bien en el codelab se abordaron muchos de los conceptos básicos para compilar un complemento, hay mucho más por explorar. Consulta los recursos que están más abajo y no te olvides de limpiar tu proyecto para evitar cargos adicionales.
Limpia
Para desinstalar el complemento de tu cuenta, en Cloud Shell, ejecuta este comando:
gcloud workspace-add-ons deployments uninstall todo-add-on
Sigue estos pasos para evitar que se apliquen cargos a tu cuenta de Google Cloud Platform para los recursos que se usaron en este instructivo:
- En la consola de Cloud, ve a la página Administrar recursos. Haz clic en la esquina superior izquierda, luego en Menú > IAM y administración > Administra recursos.
- En la lista de proyectos, selecciona el tuyo y haz clic en Borrar.
- En el diálogo, escribe el ID del proyecto y, luego, haz clic en Cerrar para borrarlo.
Más información
- Descripción general de los complementos de Google Workspace
- Busca apps y complementos existentes en el mercado