สร้างส่วนเสริมของ Google Workspace ด้วย Node.js และ Cloud Run

1. บทนำ

ส่วนเสริมของ Google Workspace คือแอปพลิเคชันที่ปรับแต่งซึ่งผสานรวมกับแอปพลิเคชัน Google Workspace เช่น Gmail, เอกสาร, ชีต และสไลด์ ซึ่งช่วยให้นักพัฒนาแอปสร้างอินเทอร์เฟซผู้ใช้ที่กำหนดเองซึ่งผสานรวมเข้ากับ Google Workspace ได้โดยตรง ส่วนเสริมช่วยให้ผู้ใช้ทำงานได้อย่างมีประสิทธิภาพมากขึ้นโดยไม่ต้องสลับบริบท

ใน Codelab นี้ คุณจะได้เรียนรู้วิธีสร้างและทำให้ส่วนเสริมรายการงานอย่างง่ายใช้งานได้โดยใช้ Node.js, Cloud Run และ Datastore

สิ่งที่คุณจะได้เรียนรู้

  • ใช้ Cloud Shell
  • ทำให้ใช้งานได้กับ Cloud Run
  • สร้างและติดตั้งใช้งานตัวอธิบายการติดตั้งใช้งานส่วนเสริม
  • สร้าง UI ของส่วนเสริมด้วยเฟรมเวิร์กการ์ด
  • ตอบสนองต่อการโต้ตอบของผู้ใช้
  • ใช้ประโยชน์จากบริบทของผู้ใช้ในส่วนเสริม

2. การตั้งค่าและข้อกำหนด

ทำตามวิธีการตั้งค่าเพื่อสร้างโปรเจ็กต์ Google Cloud และเปิดใช้ API และบริการที่ส่วนเสริมจะใช้

การตั้งค่าสภาพแวดล้อมแบบเรียนรู้ด้วยตนเอง

  1. เปิด Cloud Console แล้วสร้างโปรเจ็กต์ใหม่ (หากยังไม่มีบัญชี Gmail หรือ Google Workspace ให้สร้างบัญชี)

เมนูเลือกโปรเจ็กต์

ปุ่มโปรเจ็กต์ใหม่

รหัสโปรเจ็กต์

โปรดจดจำรหัสโปรเจ็กต์ ซึ่งเป็นชื่อที่ไม่ซ้ำกันในโปรเจ็กต์ Google Cloud ทั้งหมด (ชื่อด้านบนมีผู้ใช้แล้วและจะใช้ไม่ได้ ขออภัย) ซึ่งจะเรียกว่า PROJECT_ID ในภายหลังใน Codelab นี้

  1. จากนั้นเปิดใช้การเรียกเก็บเงินใน Cloud Console เพื่อใช้ทรัพยากร Google Cloud

การทำตาม Codelab นี้ไม่ควรมีค่าใช้จ่ายมากนัก หรืออาจไม่มีเลย อย่าลืมทำตามวิธีการในส่วน "ล้างข้อมูล" ที่ตอนท้ายของ Codelab ซึ่งจะแนะนำวิธีปิดแหล่งข้อมูลเพื่อไม่ให้มีการเรียกเก็บเงินนอกเหนือจากบทแนะนำนี้ ผู้ใช้ Google Cloud รายใหม่มีสิทธิ์เข้าร่วมโปรแกรมช่วงทดลองใช้ฟรีมูลค่า$300 USD

Google Cloud Shell

แม้ว่าคุณจะใช้งาน Google Cloud จากแล็ปท็อปได้จากระยะไกล แต่ใน Codelab นี้เราจะใช้ Google Cloud Shell ซึ่งเป็นสภาพแวดล้อมบรรทัดคำสั่งที่ทำงานในระบบคลาวด์

เปิดใช้งาน Cloud Shell

  1. จาก Cloud Console ให้คลิกเปิดใช้งาน Cloud Shell ไอคอน Cloud Shell

ไอคอน Cloud Shell ในแถบเมนู

เมื่อเปิด Cloud Shell เป็นครั้งแรก คุณจะเห็นข้อความต้อนรับที่อธิบาย หากเห็นข้อความต้อนรับ ให้คลิกต่อไป ข้อความต้อนรับจะไม่ปรากฏขึ้นอีก ข้อความต้อนรับมีดังนี้

ข้อความต้อนรับของ Cloud Shell

การจัดสรรและเชื่อมต่อกับ Cloud Shell จะใช้เวลาไม่นาน หลังจากเชื่อมต่อแล้ว คุณจะเห็นเทอร์มินัล Cloud Shell ดังนี้

เทอร์มินัล Cloud Shell

เครื่องเสมือนนี้มีเครื่องมือพัฒนาซอฟต์แวร์ทั้งหมดที่คุณต้องการ โดยมีไดเรกทอรีหลักแบบถาวรขนาด 5 GB และทำงานใน Google Cloud ซึ่งช่วยเพิ่มประสิทธิภาพเครือข่ายและการตรวจสอบสิทธิ์ได้อย่างมาก คุณสามารถทำงานทั้งหมดในโค้ดแล็บนี้ได้ด้วยเบราว์เซอร์หรือ Chromebook

เมื่อเชื่อมต่อกับ Cloud Shell แล้ว คุณควรเห็นว่าคุณได้รับการตรวจสอบสิทธิ์แล้วและโปรเจ็กต์ได้รับการตั้งค่าเป็นรหัสโปรเจ็กต์ของคุณแล้ว

  1. เรียกใช้คำสั่งต่อไปนี้ใน Cloud Shell เพื่อยืนยันว่าคุณได้รับการตรวจสอบสิทธิ์แล้ว
gcloud auth list

หากได้รับแจ้งให้อนุญาต Cloud Shell เพื่อทำการเรียก API ของ GCP ให้คลิกให้สิทธิ์

เอาต์พุตของคำสั่ง

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

หากต้องการตั้งค่าบัญชีที่ใช้งานอยู่ ให้เรียกใช้คำสั่งต่อไปนี้

gcloud config set account <ACCOUNT>

หากต้องการยืนยันว่าคุณได้เลือกโปรเจ็กต์ที่ถูกต้องแล้ว ให้เรียกใช้คำสั่งต่อไปนี้ใน Cloud Shell

gcloud config list project

เอาต์พุตของคำสั่ง

[core]
project = <PROJECT_ID>

หากระบบไม่แสดงโปรเจ็กต์ที่ถูกต้อง คุณสามารถตั้งค่าได้โดยใช้คำสั่งนี้

gcloud config set project <PROJECT_ID>

เอาต์พุตของคำสั่ง

Updated property [core/project].

Codelab นี้ใช้การดำเนินการบรรทัดคำสั่งและการแก้ไขไฟล์ร่วมกัน สำหรับการแก้ไขไฟล์ คุณสามารถใช้โปรแกรมแก้ไขโค้ดในตัวใน Cloud Shell ได้โดยคลิกปุ่มเปิดโปรแกรมแก้ไขทางด้านขวาของแถบเครื่องมือ Cloud Shell นอกจากนี้ คุณยังจะเห็นโปรแกรมแก้ไขยอดนิยม เช่น vim และ emacs ใน Cloud Shell ด้วย

3. เปิดใช้ Cloud Run, Datastore และ Add-on API

เปิดใช้ Cloud API

จาก Cloud Shell ให้เปิดใช้ Cloud API สำหรับคอมโพเนนต์ที่จะใช้

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

การดำเนินการนี้อาจใช้เวลาสักครู่จึงจะเสร็จสมบูรณ์

เมื่อเสร็จสมบูรณ์แล้ว ข้อความแสดงความสำเร็จที่คล้ายกับข้อความนี้จะปรากฏขึ้น

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

สร้างอินสแตนซ์ Datastore

จากนั้นเปิดใช้ App Engine และสร้างฐานข้อมูล Datastore การเปิดใช้ App Engine เป็นข้อกำหนดเบื้องต้นในการใช้ Datastore แต่เราจะไม่ใช้ App Engine เพื่อวัตถุประสงค์อื่นใด

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

ส่วนเสริมนี้ต้องได้รับสิทธิ์จากผู้ใช้เพื่อเรียกใช้และดำเนินการกับข้อมูลของผู้ใช้ กำหนดค่าหน้าจอคำยินยอมของโปรเจ็กต์เพื่อเปิดใช้ สำหรับ Codelab คุณจะต้องกำหนดค่าหน้าจอขอความยินยอมเป็นแอปพลิเคชันภายใน ซึ่งหมายความว่าแอปพลิเคชันนี้ไม่ได้มีไว้สำหรับการเผยแพร่ต่อสาธารณะ เพื่อเริ่มต้นใช้งาน

  1. เปิด Google Cloud Console ในแท็บหรือหน้าต่างใหม่
  2. คลิกลูกศรลง ลูกศรแบบเลื่อนลง ข้าง "Google Cloud Console" แล้วเลือกโปรเจ็กต์
  3. คลิกเมนู ไอคอนเมนู ที่มุมซ้ายบน
  4. คลิก API และบริการ > ข้อมูลเข้าสู่ระบบ หน้าข้อมูลเข้าสู่ระบบสำหรับโปรเจ็กต์จะปรากฏขึ้น
  5. คลิกหน้าจอขอความยินยอม OAuth หน้าจอ "หน้าจอขอความยินยอม OAuth" จะปรากฏขึ้น
  6. เลือกภายในในส่วน "ประเภทผู้ใช้" หากใช้บัญชี @gmail.com ให้เลือกภายนอก
  7. คลิกสร้าง หน้า "แก้ไขการลงทะเบียนแอป" จะปรากฏขึ้น
  8. กรอกแบบฟอร์ม:
    • ในชื่อแอป ให้ป้อน "ส่วนเสริม Todo"
    • ในอีเมลสนับสนุนผู้ใช้ ให้ป้อนอีเมลส่วนตัว
    • ในส่วนข้อมูลติดต่อของนักพัฒนาแอป ให้ป้อนอีเมลส่วนตัว
  9. คลิกบันทึกและต่อไป แบบฟอร์มขอบเขตจะปรากฏขึ้น
  10. จากแบบฟอร์มขอบเขต ให้คลิกบันทึกและดำเนินการต่อ ข้อมูลสรุปจะปรากฏขึ้น
  11. คลิกกลับไปที่แดชบอร์ด

4. สร้างส่วนเสริมเริ่มต้น

เริ่มต้นโปรเจ็กต์

ในการเริ่มต้น คุณจะต้องสร้างและติดตั้งใช้งานส่วนเสริม "Hello world" แบบง่าย ส่วนเสริมคือบริการเว็บที่ตอบสนองต่อคำขอ https และตอบสนองด้วยเพย์โหลด JSON ที่อธิบาย UI และการดำเนินการที่จะทำ ในส่วนเสริมนี้ คุณจะใช้ Node.js และเฟรมเวิร์ก Express

หากต้องการสร้างโปรเจ็กต์เทมเพลตนี้ ให้ใช้ Cloud Shell เพื่อสร้างไดเรกทอรีใหม่ชื่อ todo-add-on แล้วไปที่ไดเรกทอรีดังกล่าว

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

คุณจะทำงานทั้งหมดสำหรับโค้ดแล็บในไดเรกทอรีนี้

เริ่มต้นโปรเจ็กต์ Node.js โดยใช้คำสั่งต่อไปนี้

npm init

NPM จะถามคำถามหลายข้อเกี่ยวกับการกำหนดค่าโปรเจ็กต์ เช่น ชื่อและเวอร์ชัน สำหรับคำถามแต่ละข้อ ให้กด ENTER เพื่อยอมรับค่าเริ่มต้น จุดแรกเข้าเริ่มต้นคือไฟล์ชื่อ index.js ซึ่งเราจะสร้างในขั้นตอนถัดไป

จากนั้นติดตั้งเฟรมเวิร์กเว็บ Express โดยทำดังนี้

npm install --save express express-async-handler

สร้างแบ็กเอนด์ของส่วนเสริม

ถึงเวลาเริ่มสร้างแอปแล้ว

สร้างไฟล์ชื่อ index.js หากต้องการสร้างไฟล์ ให้ใช้ Cloud Shell Editor โดยคลิกปุ่มเปิดตัวแก้ไขบนแถบเครื่องมือของหน้าต่าง Cloud Shell หรือจะแก้ไขและจัดการไฟล์ใน Cloud Shell โดยใช้ vim หรือ emacs ก็ได้

หลังจากสร้างไฟล์ index.js แล้ว ให้เพิ่มเนื้อหาต่อไปนี้

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

เซิร์ฟเวอร์ไม่ได้ทำอะไรมากไปกว่าการแสดงข้อความ "Hello world" ซึ่งก็ถือว่าใช้ได้ คุณจะเพิ่มฟังก์ชันการทำงานเพิ่มเติมได้ในภายหลัง

ทำให้ใช้งานได้กับ Cloud Run

หากต้องการทําให้ใช้งานได้ใน Cloud Run คุณต้องสร้างคอนเทนเนอร์ของแอป

สร้างคอนเทนเนอร์

สร้าง Dockerfile ชื่อ Dockerfile ที่มีเนื้อหาต่อไปนี้

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

ป้องกันไม่ให้ไฟล์ที่ไม่ต้องการอยู่ในคอนเทนเนอร์

สร้าง.dockerignoreที่มีเนื้อหาต่อไปนี้เพื่อช่วยให้คอนเทนเนอร์มีขนาดเล็ก

Dockerfile
.dockerignore
node_modules
npm-debug.log

เปิดใช้ Cloud Build

ในโค้ดแล็บนี้ คุณจะได้สร้างและติดตั้งใช้งานส่วนเสริมหลายครั้งเมื่อมีการเพิ่มฟังก์ชันการทำงานใหม่ แทนที่จะเรียกใช้คำสั่งแยกต่างหากเพื่อสร้างคอนเทนเนอร์ พุชไปยัง Container Registry และทำให้ใช้งานได้ใน Cloud Build ให้ใช้ Cloud Build เพื่อจัดระเบียบขั้นตอน สร้างไฟล์ cloudbuild.yaml ที่มีวิธีการสร้างและติดตั้งใช้งานแอปพลิเคชัน

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

เรียกใช้คำสั่งต่อไปนี้เพื่อให้สิทธิ์ Cloud Build ในการติดตั้งใช้งานแอป

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

สร้างและติดตั้งใช้งานแบ็กเอนด์ของส่วนเสริม

หากต้องการเริ่มบิลด์ ให้เรียกใช้คำสั่งต่อไปนี้ใน Cloud Shell

gcloud builds submit

การสร้างและติดตั้งใช้งานทั้งหมดอาจใช้เวลาสักครู่จึงจะเสร็จสมบูรณ์ โดยเฉพาะอย่างยิ่งในครั้งแรก

เมื่อการสร้างเสร็จสมบูรณ์แล้ว ให้ยืนยันว่าได้ติดตั้งใช้งานบริการแล้วและค้นหา URL เรียกใช้คำสั่งต่อไปนี้

gcloud run services list --platform managed

คัดลอก URL นี้ คุณจะต้องใช้ในขั้นตอนถัดไป ซึ่งเป็นการบอก Google Workspace วิธีเรียกใช้ส่วนเสริม

ลงทะเบียนส่วนเสริม

เมื่อเซิร์ฟเวอร์พร้อมใช้งานแล้ว ให้เขียนคำอธิบายส่วนเสริมเพื่อให้ Google Workspace ทราบวิธีแสดงและเรียกใช้ส่วนเสริม

สร้างตัวอธิบายการทำให้ใช้งานได้

สร้างไฟล์ deployment.json ที่มีเนื้อหาต่อไปนี้ อย่าลืมใช้ URL ของแอปที่ติดตั้งใช้งานแทนตัวยึดตำแหน่ง 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": {}
  }
}

อัปโหลด Deployment Descriptor โดยเรียกใช้คำสั่งต่อไปนี้

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

ให้สิทธิ์เข้าถึงแบ็กเอนด์ของส่วนเสริม

นอกจากนี้ เฟรมเวิร์กของส่วนเสริมยังต้องมีสิทธิ์ในการเรียกใช้บริการด้วย เรียกใช้คำสั่งต่อไปนี้เพื่ออัปเดตนโยบาย IAM สำหรับ Cloud Run เพื่ออนุญาตให้ Google Workspace เรียกใช้ส่วนเสริม

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"

ติดตั้งส่วนเสริมสำหรับการทดสอบ

หากต้องการติดตั้งส่วนเสริมในโหมดการพัฒนาสำหรับบัญชี ให้เรียกใช้คำสั่งต่อไปนี้ใน Cloud Shell

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

เปิด (Gmail)[https://mail.google.com/] ในแท็บหรือหน้าต่างใหม่ ทางด้านขวามือ ให้ค้นหาส่วนเสริมที่มีไอคอนเครื่องหมายถูก

ไอคอนส่วนเสริมที่ติดตั้งแล้ว

หากต้องการเปิดส่วนเสริม ให้คลิกไอคอนเครื่องหมายถูก ข้อความแจ้งให้สิทธิ์ส่วนเสริมจะปรากฏขึ้น

ข้อความแจ้งการให้สิทธิ์

คลิกให้สิทธิ์เข้าถึง แล้วทำตามวิธีการในขั้นตอนการให้สิทธิ์ในป๊อปอัป เมื่อเสร็จแล้ว ส่วนเสริมจะโหลดซ้ำโดยอัตโนมัติและแสดงข้อความ "Hello world!"

ยินดีด้วย ตอนนี้คุณได้ติดตั้งและใช้งานส่วนเสริมแบบง่ายๆ แล้ว ถึงเวลาเปลี่ยนให้เป็นแอปพลิเคชันรายการงานแล้ว

5. เข้าถึงข้อมูลประจำตัวของผู้ใช้

โดยปกติแล้ว ผู้ใช้จำนวนมากจะใช้ส่วนเสริมเพื่อทำงานกับข้อมูลที่เป็นส่วนตัวสำหรับตนเองหรือองค์กร ใน Codelab นี้ ส่วนเสริมควรแสดงเฉพาะงานของผู้ใช้ปัจจุบัน ระบบจะส่งข้อมูลประจำตัวของผู้ใช้ไปยังส่วนเสริมผ่านโทเค็นข้อมูลประจำตัวที่ต้องถอดรหัส

เพิ่มขอบเขตไปยังตัวอธิบายการติดตั้งใช้งาน

ระบบจะไม่ส่งข้อมูลประจำตัวของผู้ใช้โดยค่าเริ่มต้น เนื่องจากเป็นข้อมูลผู้ใช้และส่วนเสริมต้องมีสิทธิ์เข้าถึง หากต้องการรับสิทธิ์ดังกล่าว ให้อัปเดต deployment.json และเพิ่มขอบเขต OAuth ของ openid และ email ลงในรายการขอบเขตที่ส่วนเสริมต้องการ หลังจากเพิ่มขอบเขต OAuth แล้ว ส่วนเสริมจะแจ้งให้ผู้ใช้ให้สิทธิ์เข้าถึงในครั้งถัดไปที่ใช้ส่วนเสริม

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

จากนั้นใน Cloud Shell ให้เรียกใช้คำสั่งนี้เพื่ออัปเดตตัวอธิบายการติดตั้งใช้งาน

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

อัปเดตเซิร์ฟเวอร์ส่วนเสริม

แม้ว่าส่วนเสริมจะได้รับการกำหนดค่าให้ขอข้อมูลระบุตัวตนของผู้ใช้ แต่ก็ยังต้องอัปเดตการติดตั้งใช้งานอยู่

แยกวิเคราะห์โทเค็นข้อมูลประจำตัว

เริ่มต้นด้วยการเพิ่มไลบรารีการตรวจสอบสิทธิ์ของ Google ลงในโปรเจ็กต์โดยทำดังนี้

npm install --save google-auth-library

จากนั้นแก้ไข index.js เพื่อกำหนดให้ต้องมี OAuth2Client ดังนี้

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

จากนั้นเพิ่มเมธอดตัวช่วยเพื่อแยกวิเคราะห์โทเค็นรหัส

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

แสดงข้อมูลระบุตัวตนของผู้ใช้

ตอนนี้เป็นเวลาที่เหมาะสมในการตรวจสอบก่อนที่จะเพิ่มฟังก์ชันการทำงานทั้งหมดของรายการงาน อัปเดตเส้นทางของแอปเพื่อพิมพ์อีเมลและรหัสที่ไม่ซ้ำของผู้ใช้แทนข้อความ "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);
}));

หลังจากทำการเปลี่ยนแปลงเหล่านี้แล้ว ไฟล์ index.js ที่ได้ควรมีลักษณะดังนี้

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

ติดตั้งใช้งานอีกครั้งและทดสอบ

สร้างและติดตั้งใช้งานส่วนเสริมอีกครั้ง จาก Cloud Shell ให้เรียกใช้คำสั่งต่อไปนี้

gcloud builds submit

เมื่อติดตั้งเซิร์ฟเวอร์ใหม่แล้ว ให้เปิดหรือโหลด Gmail ซ้ำ แล้วเปิดส่วนเสริมอีกครั้ง เนื่องจากขอบเขตมีการเปลี่ยนแปลง ส่วนเสริมจึงจะขอให้คุณให้สิทธิ์อีกครั้ง ให้สิทธิ์ส่วนเสริมอีกครั้ง เมื่อเสร็จแล้ว ส่วนเสริมจะแสดงอีเมลและรหัสผู้ใช้ของคุณ

ตอนนี้ส่วนเสริมรู้แล้วว่าผู้ใช้คือใคร คุณจึงเริ่มเพิ่มฟังก์ชันรายการงานได้

6. ใช้รายการงาน

โมเดลข้อมูลเริ่มต้นสำหรับโค้ดแล็บนั้นตรงไปตรงมา นั่นคือรายการTaskเอนทิตี ซึ่งแต่ละรายการมีพร็อพเพอร์ตี้สำหรับข้อความอธิบายงานและไทม์สแตมป์

สร้างดัชนี Datastore

เราได้เปิดใช้ Datastore สำหรับโปรเจ็กต์แล้วก่อนหน้านี้ใน Codelab ไม่จำเป็นต้องมีสคีมา แต่ต้องสร้างดัชนีสำหรับการค้นหาแบบผสมอย่างชัดแจ้ง การสร้างดัชนีอาจใช้เวลาสักครู่ ดังนั้นคุณจะต้องสร้างดัชนีก่อน

สร้างไฟล์ชื่อ index.yaml ที่มีเนื้อหาต่อไปนี้

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

จากนั้นอัปเดตดัชนี Datastore โดยทำดังนี้

gcloud datastore indexes create index.yaml

เมื่อได้รับแจ้งให้ดำเนินการต่อ ให้กด ENTER บนแป้นพิมพ์ การสร้างดัชนีจะเกิดขึ้นในเบื้องหลัง ในระหว่างนี้ ให้เริ่มอัปเดตโค้ดส่วนเสริมเพื่อใช้ "สิ่งที่ต้องทำ"

อัปเดตแบ็กเอนด์ของส่วนเสริม

ติดตั้งไลบรารี Datastore ลงในโปรเจ็กต์โดยทำดังนี้

npm install --save @google-cloud/datastore

อ่านและเขียนไปยัง Datastore

อัปเดต index.js เพื่อใช้ "todos" โดยเริ่มจากการนำเข้าไลบรารี Datastore และสร้างไคลเอ็นต์ ดังนี้

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

เพิ่มเมธอดเพื่ออ่านและเขียนงานจาก 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);
}

ใช้การแสดงผล UI

การเปลี่ยนแปลงส่วนใหญ่เป็นการเปลี่ยนแปลง UI ของส่วนเสริม ก่อนหน้านี้ การ์ดทั้งหมดที่ UI แสดงผลจะเป็นแบบคงที่ ซึ่งจะไม่เปลี่ยนแปลงตามข้อมูลที่มี ในที่นี้ การ์ดจะต้องสร้างแบบไดนามิกโดยอิงตามรายการงานปัจจุบันของผู้ใช้

UI ของโค้ดแล็บประกอบด้วยช่องป้อนข้อความพร้อมกับรายการงานที่มีช่องทำเครื่องหมายเพื่อทำเครื่องหมายว่าเสร็จสมบูรณ์ แต่ละรายการยังมีพร็อพเพอร์ตี้ onChangeAction ซึ่งทำให้เกิดการเรียกกลับไปยังเซิร์ฟเวอร์ส่วนเสริมเมื่อผู้ใช้เพิ่มหรือลบงาน ในแต่ละกรณีเหล่านี้ ระบบจะต้องแสดง UI อีกครั้งพร้อมกับรายการงานที่อัปเดต เราจึงขอแนะนำวิธีใหม่ในการสร้าง UI ของการ์ดเพื่อจัดการปัญหานี้

แก้ไข index.js ต่อไปและเพิ่มวิธีการต่อไปนี้

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

อัปเดตเส้นทาง

ตอนนี้มีเมธอดตัวช่วยในการอ่านและเขียนไปยัง Datastore รวมถึงสร้าง UI แล้ว เรามาเชื่อมต่อเมธอดเหล่านี้ในเส้นทางของแอปกัน แทนที่เส้นทางที่มีอยู่และเพิ่มอีก 2 เส้นทาง ได้แก่ เส้นทางสำหรับเพิ่มงานและเส้นทางสำหรับลบงาน

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

นี่คือไฟล์ index.js ที่ใช้งานได้อย่างสมบูรณ์ฉบับสุดท้าย

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

ติดตั้งใช้งานอีกครั้งและทดสอบ

หากต้องการสร้างและติดตั้งใช้งานส่วนเสริมอีกครั้ง ให้เริ่มการสร้าง ใน Cloud Shell ให้เรียกใช้คำสั่งต่อไปนี้

gcloud builds submit

ใน Gmail ให้โหลดส่วนเสริมซ้ำ แล้ว UI ใหม่จะปรากฏขึ้น โปรดสละเวลาสักครู่เพื่อสำรวจส่วนเสริม เพิ่มงาน 2-3 รายการโดยป้อนข้อความลงในช่องป้อนข้อมูลแล้วกด Enter บนแป้นพิมพ์ จากนั้นคลิกช่องทำเครื่องหมายเพื่อลบงาน

ส่วนเสริมที่มีงาน

หากต้องการ คุณสามารถข้ามไปยังขั้นตอนสุดท้ายในโค้ดแล็บนี้และล้างข้อมูลโปรเจ็กต์ได้ หรือหากต้องการดูข้อมูลเพิ่มเติมเกี่ยวกับส่วนเสริมต่อไป คุณสามารถทำตามขั้นตอนอีก 1 ขั้นตอนได้

7. (ไม่บังคับ) การเพิ่มบริบท

ฟีเจอร์ที่มีประสิทธิภาพมากที่สุดอย่างหนึ่งของส่วนเสริมคือการรับรู้บริบท ส่วนเสริมสามารถเข้าถึงบริบทของ Google Workspace เช่น อีเมลที่ผู้ใช้กำลังดู กิจกรรมในปฏิทิน และเอกสารได้โดยได้รับสิทธิ์จากผู้ใช้ นอกจากนี้ ส่วนเสริมยังสามารถดำเนินการต่างๆ เช่น การแทรกเนื้อหาได้ด้วย ใน Codelab นี้ คุณจะได้เพิ่มการรองรับบริบทสำหรับเครื่องมือแก้ไข Workspace (เอกสาร ชีต และสไลด์) เพื่อแนบเอกสารปัจจุบันกับงานที่สร้างขึ้นขณะอยู่ในเครื่องมือแก้ไข เมื่อแสดงงานแล้ว การคลิกงานจะเปิดเอกสารในแท็บใหม่เพื่อนำผู้ใช้กลับไปยังเอกสารเพื่อทำงานให้เสร็จ

อัปเดตแบ็กเอนด์ของส่วนเสริม

อัปเดตเส้นทาง newTask

ก่อนอื่น ให้อัปเดตเส้นทาง /newTask เพื่อรวมรหัสเอกสารในงาน หากมี

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

ตอนนี้งานที่สร้างใหม่จะมีรหัสเอกสารปัจจุบัน อย่างไรก็ตาม ระบบจะไม่แชร์บริบทในเอดิเตอร์โดยค่าเริ่มต้น เช่นเดียวกับข้อมูลผู้ใช้รายอื่นๆ ผู้ใช้ต้องให้สิทธิ์แก่ส่วนเสริมเพื่อเข้าถึงข้อมูล แนวทางที่แนะนำคือการขอและให้สิทธิ์เป็นรายไฟล์ เพื่อป้องกันการแชร์ข้อมูลมากเกินไป

อัปเดต UI

ใน index.js ให้อัปเดต buildCard เพื่อทำการเปลี่ยนแปลง 2 อย่าง อย่างแรกคือการอัปเดตการแสดงผลของงานให้มีลิงก์ไปยังเอกสารหากมี ส่วนที่ 2 คือการแสดงข้อความแจ้งการให้สิทธิ์ที่ไม่บังคับหากมีการแสดงผลส่วนเสริมในเครื่องมือแก้ไขและยังไม่ได้ให้สิทธิ์เข้าถึงไฟล์

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

ใช้เส้นทางการให้สิทธิ์ไฟล์

ปุ่มการให้สิทธิ์จะเพิ่มเส้นทางใหม่ไปยังแอป ดังนั้นเรามาติดตั้งใช้งานกัน เส้นทางนี้จะแนะนำแนวคิดใหม่ นั่นคือการดำเนินการของแอปโฮสต์ ซึ่งเป็นวิธีการพิเศษสำหรับการโต้ตอบกับแอปพลิเคชันโฮสต์ของส่วนเสริม ในกรณีนี้ ให้ขอสิทธิ์เข้าถึงไฟล์เอดิเตอร์ปัจจุบัน

ใน index.js ให้เพิ่มเส้นทาง /authorizeFile ดังนี้

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

นี่คือไฟล์ index.js ที่ใช้งานได้อย่างสมบูรณ์ฉบับสุดท้าย

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

เพิ่มขอบเขตไปยังตัวอธิบายการติดตั้งใช้งาน

ก่อนสร้างเซิร์ฟเวอร์ใหม่ ให้อัปเดตตัวอธิบายการติดตั้งใช้งานส่วนเสริมให้มีhttps://www.googleapis.com/auth/drive.fileขอบเขต OAuth อัปเดต deployment.json เพื่อเพิ่ม https://www.googleapis.com/auth/drive.file ลงในรายการขอบเขตของ 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"
]

อัปโหลดเวอร์ชันใหม่โดยเรียกใช้คำสั่ง Cloud Shell นี้

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

ติดตั้งใช้งานอีกครั้งและทดสอบ

สุดท้ายคือการสร้างเซิร์ฟเวอร์ใหม่ จาก Cloud Shell ให้เรียกใช้คำสั่งต่อไปนี้

gcloud builds submit

เมื่อเสร็จแล้ว ให้เปิดเอกสาร Google ที่มีอยู่หรือสร้างเอกสารใหม่โดยเปิด doc.new แทนการเปิด Gmail หากสร้างเอกสารใหม่ อย่าลืมป้อนข้อความหรือตั้งชื่อไฟล์

เปิดส่วนเสริม ส่วนเสริมจะแสดงปุ่มให้สิทธิ์เข้าถึงไฟล์ที่ด้านล่างของส่วนเสริม คลิกปุ่ม แล้วให้สิทธิ์เข้าถึงไฟล์

เมื่อได้รับอนุญาตแล้ว ให้เพิ่มงานขณะอยู่ในเครื่องมือแก้ไข งานจะมีป้ายกำกับที่ระบุว่ามีการแนบเอกสาร การคลิกลิงก์จะเปิดเอกสารในแท็บใหม่ แน่นอนว่าการเปิดเอกสารที่คุณเปิดอยู่แล้วอาจดูไม่สมเหตุสมผลนัก หากต้องการเพิ่มประสิทธิภาพ UI เพื่อกรองลิงก์สำหรับเอกสารปัจจุบัน โปรดพิจารณาว่าเป็นการให้คะแนนพิเศษ

8. ขอแสดงความยินดี

ยินดีด้วย คุณสร้างและทำให้ส่วนเสริม Google Workspace ใช้งานได้โดยใช้ Cloud Run เรียบร้อยแล้ว แม้ว่าโค้ดแล็บจะครอบคลุมแนวคิดหลักหลายอย่างในการสร้างส่วนเสริม แต่ก็ยังมีอีกหลายเรื่องที่ต้องศึกษา ดูแหล่งข้อมูลด้านล่างและอย่าลืมล้างข้อมูลในโปรเจ็กต์เพื่อหลีกเลี่ยงการเรียกเก็บเงินเพิ่มเติม

ล้างข้อมูล

หากต้องการถอนการติดตั้งส่วนเสริมออกจากบัญชี ให้เรียกใช้คำสั่งต่อไปนี้ใน Cloud Shell

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

โปรดดำเนินการดังนี้เพื่อเลี่ยงไม่ให้เกิดการเรียกเก็บเงินกับบัญชี Google Cloud Platform สำหรับทรัพยากรที่ใช้ในบทแนะนำนี้

  • ใน Cloud Console ให้ไปที่หน้าจัดการทรัพยากร ที่มุมซ้ายบน ให้คลิกเมนู ไอคอนเมนู > IAM และผู้ดูแลระบบ > จัดการทรัพยากร
  1. เลือกโปรเจ็กต์ในรายการโปรเจ็กต์ แล้วคลิกลบ
  2. ในกล่องโต้ตอบ ให้พิมพ์รหัสโปรเจ็กต์ แล้วคลิกปิดเพื่อลบโปรเจ็กต์

ดูข้อมูลเพิ่มเติม

  • ภาพรวมส่วนเสริมของ Google Workspace
  • ค้นหาแอปและส่วนเสริมที่มีอยู่ใน Marketplace