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. Consentono agli sviluppatori di creare interfacce utente personalizzate integrate direttamente in Google Workspace. I componenti aggiuntivi aiutano gli utenti a lavorare in modo più efficiente con meno cambi di contesto.

In questo codelab imparerai a creare e implementare un semplice componente aggiuntivo per l'elenco delle attività utilizzando Node.js, Cloud Run e Datastore.

Cosa imparerai a fare

  • Utilizzare Cloud Shell
  • Esegui il deployment in Cloud Run
  • Creare ed eseguire il deployment di un descrittore di deployment del componente aggiuntivo
  • Creare UI dei componenti aggiuntivi con il framework delle schede
  • Rispondere alle interazioni degli utenti
  • Sfruttare il contesto utente in un componente aggiuntivo

2. Configurazione e requisiti

Segui le istruzioni di configurazione per creare un progetto Google Cloud e attivare le API e i servizi che il componente aggiuntivo utilizzerà.

Configurazione dell'ambiente autonomo

  1. Apri Cloud Console e crea un nuovo progetto. Se non hai già un account Gmail o Google Workspace, creane uno.

Il menu Seleziona un progetto

Il nuovo pulsante Progetto

L'ID progetto

Ricorda l'ID progetto, un nome univoco tra tutti i progetti Google Cloud (il nome sopra è già stato utilizzato e non funzionerà per te, mi dispiace). In questo codelab verrà chiamato PROJECT_ID.

  1. Successivamente, per utilizzare le risorse Google Cloud, abilita la fatturazione in Cloud Console.

L'esecuzione di questo codelab non dovrebbe costare molto, se non nulla. Assicurati di seguire le istruzioni riportate nella sezione "Pulizia" alla fine del codelab, che ti consiglia come arrestare le risorse in modo da non incorrere in addebiti oltre questo tutorial. I nuovi utenti di Google Cloud possono beneficiare del programma prova senza costi di 300$.

Google Cloud Shell

Sebbene Google Cloud possa essere gestito 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. Nella console Cloud, fai clic su Attiva Cloud Shell L'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 visualizzato di nuovo. Ecco il messaggio di benvenuto:

Messaggio di benvenuto di Cloud Shell

Bastano pochi istanti per eseguire il provisioning e connettersi a Cloud Shell. Una volta connesso, vedrai il terminale Cloud Shell:

Il terminale Cloud Shell

Questa macchina virtuale è caricata con tutti gli strumenti per sviluppatori di cui hai bisogno. Offre una home directory permanente da 5 GB e viene eseguita in Google Cloud, migliorando notevolmente le prestazioni e l'autenticazione della rete. Tutto il lavoro in questo codelab può essere svolto con un browser o con 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 GCP, 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 verificare di aver selezionato il progetto corretto, in Cloud Shell esegui:

gcloud config list project

Output comando

[core]
project = <PROJECT_ID>

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

gcloud config set project <PROJECT_ID>

Output comando

Updated property [core/project].

Il codelab utilizza un mix di operazioni da riga di comando e modifica dei file. Per la modifica dei file, puoi utilizzare 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 disponibili anche editor popolari come vim ed emacs.

3. Abilita le API Cloud Run, Datastore e dei componenti aggiuntivi

Abilita le 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

Il completamento di 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'attivazione 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 suoi dati. Configura la schermata per il consenso del progetto per abilitare questa opzione. Per il codelab, per iniziare configurerai la schermata del consenso come applicazione interna, il che significa che 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 del tuo progetto.
  5. Fai clic su Schermata consenso OAuth. Viene visualizzata la schermata "Schermata per il consenso OAuth".
  6. Nella sezione "Tipo di utente", seleziona Interno. Se utilizzi un account @gmail.com, seleziona Esterno.
  7. Fai clic su Crea. Viene visualizzata la pagina "Modifica registrazione app".
  8. Compila il modulo:
    • In Nome app, inserisci "Componente aggiuntivo Elenco di cose da fare".
    • In Email di assistenza utenti, inserisci il tuo indirizzo email personale.
    • In Informazioni di contatto dello sviluppatore, inserisci il tuo indirizzo email personale.
  9. Fai clic su Salva e continua. Viene visualizzato un modulo Scopi.
  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

Inizializza il progetto

Per iniziare, creerai un semplice componente aggiuntivo "Hello World" e lo implementerai. I componenti aggiuntivi sono servizi web che rispondono alle richieste https e restituiscono un payload JSON che descrive la UI e le azioni da intraprendere. In questo componente aggiuntivo utilizzerai Node.js e il framework Express.

Per creare questo progetto modello, utilizza Cloud Shell per creare una nuova directory denominata todo-add-on e accedi 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

NPM pone diverse domande sulla configurazione del progetto, ad esempio nome e versione. Per ogni domanda, premi ENTER per accettare i valori predefiniti. Il punto di ingresso predefinito è un file denominato index.js, che creeremo in seguito.

Successivamente, 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 fa molto altro oltre a mostrare il messaggio "Hello world", il che va bene. 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 contenitore

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

Evitare che i file indesiderati entrino nel contenitore

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

Dockerfile
.dockerignore
node_modules
npm-debug.log

Attiva Cloud Build

In questo codelab creerai e implementerai il componente aggiuntivo più volte man mano che vengono aggiunte nuove funzionalità. Anziché eseguire comandi separati per creare il container, eseguirne il push in Container Registry ed eseguirne il deployment in Cloud Build, utilizza Cloud Build per coordinare la procedura. Crea un file cloudbuild.yaml con le istruzioni su come creare ed eseguire il deployment dell'applicazione:

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 e implementa il backend del componente aggiuntivo

Per avviare la build, in Cloud Shell esegui:

gcloud builds submit

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

Una volta completata la build, verifica che il servizio sia stato implementato e trova l'URL. Esegui il comando:

gcloud run services list --platform managed

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

Registrare 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 il seguente contenuto. 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

Autorizzare l'accesso al backend del componente aggiuntivo

Il framework dei componenti aggiuntivi richiede anche l'autorizzazione per chiamare il servizio. Esegui i seguenti comandi per aggiornare il criterio IAM per Cloud Run in modo da 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"

Installare il componente aggiuntivo per i test

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

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

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

Icona del componente aggiuntivo installato

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

Prompt di autorizzazione

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

Complimenti! Ora hai un semplice componente aggiuntivo di cui è stato eseguito il deployment e l'installazione. È il momento di trasformarlo in un'applicazione per elenchi di attività.

5. Accedere 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 deve mostrare solo le attività dell'utente corrente. L'identità dell'utente viene inviata al componente aggiuntivo tramite un token di identità che deve essere decodificato.

Aggiungi ambiti al descrittore del deployment

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

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

Poi, in Cloud Shell, esegui questo comando per aggiornare il descrittore del deployment:

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

Aggiornare il server del componente aggiuntivo

Sebbene il componente aggiuntivo sia configurato per richiedere l'identità dell'utente, l'implementazione deve comunque essere aggiornata.

Analizzare il token di identità

Inizia aggiungendo la libreria di autenticazione Google al progetto:

npm install --save google-auth-library

Quindi modifica index.js per richiedere OAuth2Client:

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

Poi 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à dell'utente

È il momento giusto per un checkpoint prima di aggiungere tutte le funzionalità dell'elenco delle attività. Aggiorna la route 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 nuovamente il deployment e testa

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

gcloud builds submit

Una volta eseguito il nuovo deployment del server, apri o ricarica Gmail e apri di nuovo il componente aggiuntivo. Poiché gli ambiti sono cambiati, il componente aggiuntivo chiederà una nuova autorizzazione. Autorizza di nuovo il componente aggiuntivo e, al termine, il componente aggiuntivo visualizza 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 di dati iniziale per il codelab è semplice: un elenco di entità Task, ognuna con proprietà per il testo descrittivo dell'attività e un timestamp.

Crea l'indice del datastore

Datastore è già stato 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 la eseguirai per prima.

Crea un file denominato index.yaml con quanto segue:

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

Poi aggiorna gli indici Datastore:

gcloud datastore indexes create index.yaml

Quando ti viene chiesto di continuare, premi INVIO sulla tastiera. La creazione dell'indice avviene in background. Mentre questo avviene, inizia ad aggiornare il codice del componente aggiuntivo per implementare gli "impegni".

Aggiorna il backend del componente aggiuntivo

Installa la libreria Datastore nel progetto:

npm install --save @google-cloud/datastore

Lettura e scrittura in Datastore

Aggiorna index.js per implementare l'elenco di cose da fare iniziando con l'importazione della libreria 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);
}

Implementare il rendering dell'interfaccia utente

La maggior parte delle modifiche riguarda la UI del componente aggiuntivo. In precedenza, tutte le schede restituite dall'interfaccia utente erano statiche e non cambiavano in base ai dati disponibili. In questo caso, la scheda deve essere creata in modo dinamico in base all'elenco delle attività corrente dell'utente.

L'interfaccia utente del codelab è costituita da un input di testo e da un elenco di attività con caselle di controllo per contrassegnarle come completate. Ognuna di queste ha anche una proprietà onChangeAction che genera un callback nel server del componente aggiuntivo quando l'utente aggiunge o elimina un'attività. In ognuno di questi casi, la UI deve essere sottoposta a rendering di nuovo con l'elenco delle attività aggiornato. Per gestire questo problema, introduciamo un nuovo metodo per creare la UI 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 in Datastore e creare la UI, colleghiamoli nelle route dell'app. Sostituisci la route 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 e 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 nuovamente il deployment e testa

Per ricompilare 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 viene visualizzata la nuova UI. Prenditi un minuto per esplorare il componente aggiuntivo. Aggiungi alcune attività inserendo del testo nell'input e premendo Invio sulla tastiera, poi fai clic sulla casella di controllo per eliminarle.

Componente aggiuntivo con attività

Se vuoi, puoi passare direttamente all'ultimo passaggio di questo codelab e pulire il progetto. Se vuoi continuare a scoprire di più sui componenti aggiuntivi, puoi completare un altro passaggio.

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 ai contesti di Google Workspace, ad esempio l'email che un utente sta visualizzando, un evento di calendario e un documento. I componenti aggiuntivi possono anche eseguire azioni come l'inserimento di contenuti. In questo codelab, aggiungerai il supporto del contesto per gli editor di Workspace (Documenti, Fogli e Presentazioni) per allegare il documento corrente a qualsiasi attività creata negli editor. Quando viene visualizzato il compito, se fai clic su di esso, il documento si aprirà in una nuova scheda per consentire all'utente di completare il compito.

Aggiorna il backend del componente aggiuntivo

Aggiorna l'itinerario newTask

Innanzitutto, aggiorna la route /newTask in modo da 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 altri dati utente, l'utente deve concedere l'autorizzazione al componente aggiuntivo per accedere ai dati. Per evitare la condivisione eccessiva di informazioni, l'approccio preferito è richiedere e concedere l'autorizzazione per ogni file.

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

Implementa la route di autorizzazione dei file

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

In index.js, aggiungi la route /authorizeFile:

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

Ecco il file index.js finale e 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 del deployment

Prima di ricompilare il server, aggiorna il descrittore del 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 Cloud Shell:

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

Esegui nuovamente il deployment e testa

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

gcloud builds submit

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

Apri il componente aggiuntivo. Il componente aggiuntivo mostra un pulsante Autorizza accesso ai file nella parte inferiore. Fai clic sul pulsante, quindi autorizza l'accesso al file.

Una volta autorizzata, 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. Ovviamente, aprire un documento già aperto è un po' sciocco. Se vuoi ottimizzare l'interfaccia utente per filtrare i link del documento corrente, considera questo come un bonus.

8. Complimenti

Complimenti! Hai creato ed eseguito il deployment di un componente aggiuntivo di Google Workspace utilizzando Cloud Run. Sebbene il codelab abbia trattato molti dei concetti di base per la creazione di un componente aggiuntivo, c'è molto altro da esplorare. Consulta le risorse di seguito e non dimenticare di eseguire la pulizia del 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:

  • In Cloud Console, vai alla pagina Gestisci risorse. In alto a sinistra, fai clic su Menu icona del menu > IAM e amministrazione > Gestisci risorse.
  1. Nell'elenco dei progetti, seleziona il tuo progetto e poi fai clic su Elimina.
  2. Nella finestra di dialogo, digita l'ID progetto, quindi fai clic su Chiudi per eliminare il progetto.

Scopri di più

  • Panoramica dei componenti aggiuntivi di Google Workspace
  • Trovare app e componenti aggiuntivi esistenti nel Marketplace