สร้างส่วนเสริมของ 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 ทั้งหมด (ชื่อด้านบนมีคนใช้แล้ว และจะใช้ไม่ได้ ขออภัย) และจะมีการอ้างอิงใน Codelab ว่า PROJECT_ID ในภายหลัง

  1. ถัดไป หากต้องการใช้ทรัพยากร Google Cloud ให้เปิดใช้การเรียกเก็บเงินใน Cloud Console

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

Google Cloud Shell

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

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

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

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

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

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

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

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

เครื่องเสมือนนี้เต็มไปด้วยเครื่องมือการพัฒนาทั้งหมดที่คุณต้องการ โดยมีไดเรกทอรีหลักขนาด 5 GB ที่ทำงานอย่างต่อเนื่องใน Google Cloud ซึ่งจะช่วยเพิ่มประสิทธิภาพของเครือข่ายและการตรวจสอบสิทธิ์ได้อย่างมาก งานทั้งหมดใน Codelab นี้ทำได้โดยใช้เบราว์เซอร์หรือ 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 และ API ส่วนเสริม

เปิดใช้ Cloud APIs

จาก 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.

สร้างอินสแตนซ์พื้นที่เก็บข้อมูล

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

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

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

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

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

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

คุณจะทำงานทั้งหมดให้กับ Codelab ในไดเรกทอรีนี้

เริ่มต้นโปรเจ็กต์ 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}`)
});

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

ทำให้ใช้งานได้กับ 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

ใน Codelab นี้ คุณจะสร้างและทำให้ส่วนเสริมใช้งานได้หลายครั้งเมื่อมีการเพิ่มฟังก์ชันใหม่ แทนที่จะเรียกใช้คำสั่งแยกต่างหากเพื่อสร้างคอนเทนเนอร์ ให้พุชไปยังการลงทะเบียนคอนเทนเนอร์และทำให้ใช้งานได้กับ 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

บิลด์และการทำให้ใช้งานได้เต็มรูปแบบอาจใช้เวลา 2-3 นาที โดยเฉพาะในครั้งแรก

เมื่อบิลด์เสร็จสมบูรณ์ ให้ตรวจสอบว่าบริการใช้งานได้และค้นหา 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": {}
  }
}

อัปโหลดข้อบ่งชี้การติดตั้งใช้งานโดยเรียกใช้คำสั่งดังนี้

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/] ในแท็บหรือหน้าต่างใหม่ มองหาส่วนเสริมที่มีไอคอนเครื่องหมายถูกทางด้านขวามือ

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

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

พรอมต์การให้สิทธิ์

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

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

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

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

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

แสดงข้อมูลประจำตัวผู้ใช้

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

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. ใช้รายการงาน

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

สร้างดัชนีพื้นที่เก็บข้อมูล

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

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 ของ Codelab ประกอบด้วยการป้อนข้อความและรายการงานที่มีช่องทำเครื่องหมายสำหรับทำเครื่องหมายว่าเสร็จสิ้น โดยแต่ละบริการจะมีพร็อพเพอร์ตี้ 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 รายการ โดยรายการแรกสำหรับเพิ่มงานและอีก 1 รายการสำหรับลบงาน

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 บนแป้นพิมพ์ แล้วคลิกช่องทำเครื่องหมายเพื่อลบออก

ส่วนเสริมที่มี Tasks

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

เพิ่มขอบเขตให้กับข้อบ่งชี้การทำให้ใช้งานได้

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

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

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

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

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

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

ล้างข้อมูล

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

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

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

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

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