Crea un componente aggiuntivo di Google Workspace con Node.js e Cloud Run

1. Introduzione

I componenti aggiuntivi di Google Workspace sono applicazioni personalizzate che si integrano con le applicazioni Google Workspace come Gmail, Documenti, Fogli e Presentazioni. Permettono agli sviluppatori di creare interfacce utente personalizzate direttamente integrate in Google Workspace. I componenti aggiuntivi consentono agli utenti di lavorare in modo più efficiente riducendo il rischio di cambio di contesto.

In questo codelab, imparerai a creare ed eseguire il deployment di un semplice componente aggiuntivo dell'elenco di attività utilizzando Node.js, Cloud Run e Datastore.

Cosa imparerai a fare

  • Utilizzare Cloud Shell
  • Esegui il deployment in Cloud Run
  • Crea ed esegui il deployment di un descrittore di deployment per componenti aggiuntivi
  • Crea interfacce utente aggiuntive con la struttura delle schede
  • Rispondere alle interazioni degli utenti
  • Utilizzo del contesto utente in un componente aggiuntivo

2. Configurazione e requisiti

Segui le istruzioni di configurazione per creare un progetto Google Cloud e abilitare le API e i servizi che verranno utilizzati dal componente aggiuntivo.

Configurazione dell'ambiente da seguire in modo autonomo

  1. Apri la console Cloud e crea un nuovo progetto. Se non hai ancora un account Gmail o Google Workspace, creane uno.

Il menu Seleziona un progetto

Pulsante Nuovo progetto

ID progetto

Ricorda l'ID progetto, un nome univoco in tutti i progetti Google Cloud (il nome precedente è già stato utilizzato e non funzionerà correttamente). Verrà indicato più avanti in questo codelab come PROJECT_ID.

  1. Successivamente, per utilizzare le risorse Google Cloud, abilita la fatturazione nella console Cloud.

Eseguire questo codelab non dovrebbe costare molto. Assicurati di seguire le istruzioni nella sezione "Pulizia" alla fine del codelab, che ti spiega come arrestare le risorse per evitare addebiti oltre a questo tutorial. I nuovi utenti di Google Cloud sono idonei al programma prova senza costi di 300$.

Google Cloud Shell

Anche se Google Cloud può essere utilizzato da remoto dal tuo laptop, in questo codelab utilizzeremo Google Cloud Shell, un ambiente a riga di comando in esecuzione nel cloud.

Attiva Cloud Shell

  1. Dalla console Cloud, fai clic su Attiva Cloud Shell Icona di Cloud Shell.

L'icona di Cloud Shell nella barra dei menu

La prima volta che apri Cloud Shell, viene visualizzato un messaggio di benvenuto descrittivo. Se viene visualizzato il messaggio di benvenuto, fai clic su Continua. Il messaggio di benvenuto non viene più visualizzato. Ecco il messaggio di benvenuto:

Messaggio di benvenuto di Cloud Shell

Il provisioning e la connessione a Cloud Shell dovrebbero richiedere solo qualche istante. Dopo la connessione, vedrai il terminale Cloud Shell:

Il terminale Cloud Shell

Questa macchina virtuale viene caricata con tutti gli strumenti di sviluppo di cui hai bisogno. Offre una home directory permanente da 5 GB e viene eseguita in Google Cloud, migliorando notevolmente le prestazioni di rete e l'autenticazione. Tutto il lavoro in questo codelab può essere svolto con un browser o Chromebook.

Una volta eseguita la connessione a Cloud Shell, dovresti vedere che il tuo account è già autenticato e il progetto è già impostato sul tuo ID progetto.

  1. Esegui questo comando in Cloud Shell per verificare che l'account sia autenticato:
gcloud auth list

Se ti viene chiesto di autorizzare Cloud Shell a effettuare una chiamata API Google Cloud, fai clic su Autorizza.

Output comando

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

Per impostare l'account attivo, esegui:

gcloud config set account <ACCOUNT>

Per confermare di aver selezionato il progetto corretto, esegui in Cloud Shell:

gcloud config list project

Output comando

[core]
project = <PROJECT_ID>

Se non viene restituito il progetto corretto, puoi impostarlo con questo comando:

gcloud config set project <PROJECT_ID>

Output comando

Updated property [core/project].

Il codelab utilizza una combinazione di operazioni a riga di comando e modifica di file. Per la modifica di file, puoi usare l'editor di codice integrato in Cloud Shell facendo clic sul pulsante Apri editor sul lato destro della barra degli strumenti di Cloud Shell. In Cloud Shell sono inoltre disponibili editor popolari, come vim ed emacs.

3. Abilita le API Cloud Run, Datastore e Add-on

Abilita API Cloud

Da Cloud Shell, abilita le API Cloud per i componenti che verranno utilizzati:

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

Questa operazione potrebbe richiedere alcuni istanti.

Al termine, viene visualizzato un messaggio di operazione riuscita simile a questo:

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

Crea un'istanza di datastore

A questo punto, abilita App Engine e crea un database Datastore. L'abilitazione di App Engine è un prerequisito per l'utilizzo di Datastore, ma non utilizzeremo App Engine per altri scopi.

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

Il componente aggiuntivo richiede l'autorizzazione dell'utente per essere eseguito e intervenire sui dati. Configura la schermata per il consenso del progetto per attivare questa funzionalità. Per quanto riguarda il codelab, per iniziare devi configurare la schermata di consenso come applicazione interna, ovvero non destinata alla distribuzione pubblica.

  1. Apri la console Google Cloud in una nuova scheda o finestra.
  2. Accanto a "Console Google Cloud", fai clic sulla Freccia giù freccia menu a discesa e seleziona il progetto.
  3. Nell'angolo in alto a sinistra, fai clic su Menu icona del menu.
  4. Fai clic su API e Servizi > Credenziali. Viene visualizzata la pagina delle credenziali per il tuo progetto.
  5. Fai clic su Schermata consenso OAuth. "Schermata consenso OAuth" viene visualizzata la schermata iniziale.
  6. Nella sezione "Tipo di utente", Seleziona Interno. Se utilizzi un account @gmail.com, seleziona Esterno.
  7. Fai clic su Crea. Un pulsante "Modifica registrazione app" .
  8. Compila il modulo:
    • In Nome app, inserisci "Componente aggiuntivo di cose da fare".
    • In Email per l'assistenza agli utenti, inserisci il tuo indirizzo email personale.
    • In Dati di contatto dello sviluppatore, inserisci il tuo indirizzo email personale.
  9. Fai clic su Salva e continua. Viene visualizzato un modulo Ambiti.
  10. Nel modulo Ambiti, fai clic su Salva e continua. Viene visualizzato un riepilogo.
  11. Fai clic su Torna alla dashboard.

4. Crea il componente aggiuntivo iniziale

Inizializzare il progetto

Per iniziare, devi creare un semplice "Hello World" il componente aggiuntivo ed eseguirne il deployment. I componenti aggiuntivi sono servizi web che rispondono alle richieste https e con un payload JSON che descrive l'interfaccia utente e le azioni da intraprendere. In questo componente aggiuntivo, utilizzerai Node.js e il framework Express.

Per creare questo progetto modello, usa Cloud Shell per creare una nuova directory denominata todo-add-on e vai alla directory:

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

Svolgerai tutto il lavoro per il codelab in questa directory.

Inizializza il progetto Node.js:

npm init

Gestione dei partner di rete pone varie domande sulla configurazione del progetto, ad esempio il nome e la versione. Per ogni domanda, premi ENTER per accettare i valori predefiniti. Il punto di ingresso predefinito è un file denominato index.js, che creeremo successivamente.

Quindi, installa il framework web Express:

npm install --save express express-async-handler

Crea il backend del componente aggiuntivo

È ora di iniziare a creare l'app.

Crea un file denominato index.js. Per creare file, puoi utilizzare l'editor di Cloud Shell facendo clic sul pulsante Apri editor sulla barra degli strumenti della finestra di Cloud Shell. In alternativa, puoi modificare e gestire i file in Cloud Shell utilizzando vim o emacs.

Dopo aver creato il file index.js, aggiungi i seguenti contenuti:

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}`)
});

Il server non serve altro che mostrare "Hello World" è del tutto normale. Aggiungerai altre funzionalità in un secondo momento.

Esegui il deployment in Cloud Run

Per eseguire il deployment su Cloud Run, l'app deve essere containerizzata.

Crea il container

Crea un Dockerfile denominato Dockerfile contenente:

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" ]

Tieni i file indesiderati fuori dal contenitore

Per mantenere il contenitore leggero, crea un file .dockerignore contenente:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Attiva Cloud Build

In questo codelab, creerai ed eseguirai il deployment del componente aggiuntivo più volte man mano che verranno aggiunte nuove funzionalità. Anziché eseguire comandi separati per creare il container, eseguirne il push al registro dei container ed eseguirne il deployment su Cloud Build, utilizza Cloud Build per orchestrare la procedura. Crea un file cloudbuild.yaml con le istruzioni per creare l'applicazione ed eseguirne il deployment:

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

Esegui questi comandi per concedere a Cloud Build l'autorizzazione per eseguire il deployment dell'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

Crea ed esegui il deployment del backend del componente aggiuntivo

Per avviare la build, in Cloud Shell esegui questo comando:

gcloud builds submit

Il completamento della build e del deployment potrebbe richiedere alcuni minuti, in particolare la prima volta.

Una volta completata la build, verifica il deployment del servizio e trova l'URL. Esegui il comando:

gcloud run services list --platform managed

Copia questo URL, ti servirà per il passaggio successivo, ovvero per indicare a Google Workspace come richiamare il componente aggiuntivo.

Registra il componente aggiuntivo

Ora che il server è attivo e in esecuzione, descrivi il componente aggiuntivo in modo che Google Workspace sappia come visualizzarlo e richiamarlo.

Crea un descrittore di deployment

Crea il file deployment.json con i seguenti contenuti. Assicurati di utilizzare l'URL dell'app di cui è stato eseguito il deployment al posto del segnaposto 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": {}
  }
}

Carica il descrittore del deployment eseguendo il comando:

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

Autorizza l'accesso al backend del componente aggiuntivo

Il framework dei componenti aggiuntivi richiede anche l'autorizzazione per chiamare il servizio. Esegui questi comandi per aggiornare il criterio IAM per Cloud Run e consentire a Google Workspace di richiamare il componente aggiuntivo:

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"

Installa il componente aggiuntivo per i test

Per installare il componente aggiuntivo in modalità sviluppo per il tuo account, esegui in Cloud Shell:

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

Apri (Gmail)[https://mail.google.com/] in una nuova scheda o finestra. Sul lato destro, individua il componente aggiuntivo con l'icona di un segno di spunta.

Icona del componente aggiuntivo installato

Per aprire il componente aggiuntivo, fai clic sull'icona del segno di spunta. Viene visualizzata una richiesta di autorizzazione per il componente aggiuntivo.

Richiesta di autorizzazione

Fai clic su Autorizza accesso e segui le istruzioni del flusso di autorizzazione nel popup. Al termine, il componente aggiuntivo si ricarica automaticamente e visualizza il messaggio "Hello world!". .

Complimenti! Ora hai eseguito il deployment e l'installazione di un semplice componente aggiuntivo. È ora di trasformarlo in un'applicazione per l'elenco di attività.

5. Accesso all'identità utente

I componenti aggiuntivi vengono in genere utilizzati da molti utenti per lavorare con informazioni private per loro o per le loro organizzazioni. In questo codelab, il componente aggiuntivo dovrebbe mostrare solo le attività per l'utente corrente. L'identità dell'utente viene inviata al componente aggiuntivo tramite un token di identità che deve essere decodificato.

Aggiungi ambiti al descrittore di deployment

Per impostazione predefinita, l'identità utente non viene inviata. Si tratta dei dati utente e il componente aggiuntivo deve disporre dell'autorizzazione per accedervi. Per ottenere questa autorizzazione, aggiorna deployment.json e aggiungi gli ambiti OAuth openid e email all'elenco di ambiti richiesti dal componente aggiuntivo. Dopo aver aggiunto gli ambiti OAuth, il componente aggiuntivo chiede agli utenti di concedere l'accesso al successivo utilizzo del componente aggiuntivo.

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

Quindi, in Cloud Shell, esegui questo comando per aggiornare il descrittore di deployment:

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

Aggiornare il server dei componenti aggiuntivi

Mentre il componente aggiuntivo è configurato per richiedere l'identità utente, l'implementazione deve ancora essere aggiornata.

Analizza il token di identità

Per iniziare, aggiungi la libreria di autenticazione Google al progetto:

npm install --save google-auth-library

Poi modifica index.js per richiedere OAuth2Client:

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

Quindi, aggiungi un metodo helper per analizzare il token ID:

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

Visualizzare l'identità utente

È un buon momento per un checkpoint prima di aggiungere tutte le funzionalità dell'elenco di attività. Aggiorna il percorso dell'app per stampare l'indirizzo email e l'ID univoco dell'utente anziché "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);
}));

Dopo queste modifiche, il file index.js risultante dovrebbe avere il seguente aspetto:

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}`)
});

Esegui di nuovo il deployment e il test

Ricrea ed esegui di nuovo il deployment del componente aggiuntivo. Da Cloud Shell, esegui:

gcloud builds submit

Dopo aver eseguito nuovamente il deployment del server, apri o ricarica Gmail e apri di nuovo il componente aggiuntivo. Poiché gli ambiti sono cambiati, il componente aggiuntivo richiederà una nuova autorizzazione. Autorizza nuovamente il componente aggiuntivo e, una volta completato, visualizzerà il tuo indirizzo email e il tuo ID utente.

Ora che il componente aggiuntivo sa chi è l'utente, puoi iniziare ad aggiungere la funzionalità di elenco delle attività.

6. Implementare l'elenco di attività

Il modello dei dati iniziale per il codelab è semplice: un elenco di entità Task, ciascuna con proprietà per il testo descrittivo dell'attività e un timestamp.

Crea l'indice del datastore

Datastore era già abilitato per il progetto in precedenza nel codelab. Non richiede uno schema, ma richiede la creazione esplicita di indici per le query composte. La creazione dell'indice può richiedere alcuni minuti, quindi devi prima farlo.

Crea un file denominato index.yaml con quanto segue:

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

Quindi aggiorna gli indici Datastore:

gcloud datastore indexes create index.yaml

Quando ti viene chiesto di continuare, premi Invio sulla tastiera. La creazione degli indici avviene in background. Nel frattempo, inizia ad aggiornare il codice del componente aggiuntivo per implementare le "Cose da fare".

Aggiorna il backend del componente aggiuntivo

Installa la libreria Datastore nel progetto:

npm install --save @google-cloud/datastore

Lettura e scrittura su Datastore

Aggiorna index.js per implementare le "Cose da fare" iniziando con l'importazione della libreria del datastore e la creazione del client:

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

Aggiungi metodi per leggere e scrivere attività da 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);
}

Implementa il rendering dell'interfaccia utente

La maggior parte delle modifiche riguarda l'interfaccia utente dei componenti aggiuntivi. In precedenza, tutte le schede restituite dall'interfaccia utente erano statiche e non cambiavano a seconda dei dati disponibili. In questo caso, la scheda deve essere creata dinamicamente in base all'elenco di attività corrente dell'utente.

La UI del codelab è composta da un input di testo e un elenco di attività con caselle di controllo per contrassegnarle come completate. Ognuno di questi ha anche una proprietà onChangeAction che comporta un callback al server aggiuntivo quando l'utente aggiunge o elimina un'attività. In ognuno di questi casi, è necessario eseguire di nuovo il rendering della UI con l'elenco di attività aggiornato. Per gestire questa situazione, introduciamo un nuovo metodo per creare l'interfaccia utente della scheda.

Continua a modificare index.js e aggiungi il seguente metodo:

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;
}

Aggiorna le route

Ora che esistono metodi helper per leggere e scrivere su Datastore e creare l'interfaccia utente, colleghiamoli nelle route dell'app. Sostituisci il percorso esistente e aggiungine altre due: una per aggiungere attività e una per eliminarle.

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);
}));

Ecco il file index.js finale completamente funzionante:

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}`)
});

Esegui di nuovo il deployment e il test

Per ricreare ed eseguire nuovamente il deployment del componente aggiuntivo, avvia una build. In Cloud Shell, esegui:

gcloud builds submit

In Gmail, ricarica il componente aggiuntivo e verrà visualizzata la nuova UI. Dedica qualche minuto a esplorare il componente aggiuntivo. Aggiungi alcune attività inserendo del testo nel campo di immissione e premendo Invio sulla tastiera, quindi fai clic sulla casella di controllo per eliminarle.

Componente aggiuntivo con attività

Se vuoi, puoi andare direttamente al passaggio finale di questo codelab ed eseguire la pulizia del progetto. In alternativa, se vuoi continuare a scoprire di più sui componenti aggiuntivi, c'è un altro passaggio da completare.

7. (Facoltativo) Aggiungere contesto

Una delle funzionalità più potenti dei componenti aggiuntivi è la consapevolezza del contesto. Con l'autorizzazione dell'utente, i componenti aggiuntivi possono accedere a contesti di Google Workspace come l'email che l'utente sta guardando, un evento nel calendario e un documento. I componenti aggiuntivi possono anche eseguire azioni come l'inserimento di contenuti. In questo codelab, aggiungerai il supporto contesto per gli editor di Workspace (Documenti, Fogli e Presentazioni) per allegare il documento corrente a tutte le attività create negli editor. Quando l'attività viene visualizzata, se ci fai clic sopra, il documento si aprirà in una nuova scheda per riportare l'utente al documento per completare l'attività.

Aggiorna il backend del componente aggiuntivo

Aggiorna il percorso newTask

Innanzitutto, aggiorna la route /newTask per includere l'ID documento in un'attività, se disponibile:

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);
}));

Le attività appena create ora includono l'ID documento corrente. Tuttavia, il contesto negli editor non viene condiviso per impostazione predefinita. Come per gli altri dati utente, l'utente deve concedere al componente aggiuntivo l'autorizzazione ad accedere ai dati. Per evitare la condivisione eccessiva di informazioni, l'approccio da preferire consiste nel richiedere e concedere l'autorizzazione per ogni singolo file.

Aggiorna l'interfaccia utente

In index.js, aggiorna buildCard per apportare due modifiche. Il primo è l'aggiornamento del rendering delle attività per includere un link al documento, se presente. Il secondo consiste nel visualizzare una richiesta di autorizzazione facoltativa se il componente aggiuntivo viene visualizzato in un editor e l'accesso ai file non è ancora stato concesso.

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;
}

Implementare la route di autorizzazione dei file

Il pulsante di autorizzazione aggiunge un nuovo percorso all'app, quindi implementalo. Questo percorso introduce un nuovo concetto, le azioni dell'app host. Si tratta di istruzioni speciali per interagire con l'applicazione host del componente aggiuntivo. In questo caso, per richiedere l'accesso al file dell'editor corrente.

A index.js, aggiungi il percorso /authorizeFile:

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

Ecco il file index.js finale completamente funzionante:

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}`)
});

Aggiungi ambiti al descrittore di deployment

Prima di ricreare il server, aggiorna il descrittore di deployment del componente aggiuntivo in modo da includere l'ambito OAuth https://www.googleapis.com/auth/drive.file. Aggiorna deployment.json per aggiungere https://www.googleapis.com/auth/drive.file all'elenco degli ambiti 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"
]

Carica la nuova versione eseguendo questo comando di Cloud Shell:

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

Esegui di nuovo il deployment e il test

Infine, ricrea il server. Da Cloud Shell, esegui:

gcloud builds submit

Al termine, invece di aprire Gmail, apri un documento Google esistente o creane uno nuovo aprendo doc.new. Se stai creando un nuovo documento, assicurati di inserire del testo o di assegnare un nome al file.

Apri il componente aggiuntivo. Nella parte inferiore del componente aggiuntivo viene visualizzato un pulsante Autorizza accesso ai file. Fai clic sul pulsante e autorizza l'accesso al file.

Una volta ottenuta l'autorizzazione, aggiungi un'attività nell'editor. L'attività include un'etichetta che indica che il documento è allegato. Se fai clic sul link, il documento si apre in una nuova scheda. Naturalmente, aprire il documento che hai già aperto è un po' sciocco. Se vuoi ottimizzare l'interfaccia utente per filtrare i link per il documento attuale, prendi in considerazione il merito aggiuntivo.

8. Complimenti

Complimenti! Hai creato un componente aggiuntivo Google Workpace ed eseguito il deployment utilizzando Cloud Run. Sebbene il codelab copra molti dei concetti fondamentali per la creazione di un componente aggiuntivo, c'è ancora molto da esplorare. Consulta le risorse riportate di seguito e non dimenticare di pulire il progetto per evitare addebiti aggiuntivi.

Esegui la pulizia

Per disinstallare il componente aggiuntivo dal tuo account, esegui questo comando in Cloud Shell:

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

Per evitare che al tuo account Google Cloud vengano addebitati costi relativi alle risorse utilizzate in questo tutorial:

  • Nella console Cloud, vai alla pagina Gestisci risorse. Nell'angolo in alto a sinistra, fai clic su Menu icona del menu > IAM e Amministratore > Gestione delle risorse.
  1. Nell'elenco dei progetti, seleziona il progetto e fai clic su Elimina.
  2. Nella finestra di dialogo, digita l'ID progetto e fai clic su Chiudi per eliminare il progetto.

Scopri di più

  • Panoramica dei componenti aggiuntivi di Google Workspace
  • Trova le app e i componenti aggiuntivi esistenti nel marketplace