Tạo tiện ích bổ sung của Google Workspace bằng Node.js và Cloud Run

1. Giới thiệu

Tiện ích bổ sung của Google Workspace là các ứng dụng tuỳ chỉnh, tích hợp với các ứng dụng của Google Workspace như Gmail, Tài liệu, Trang tính và Trang trình bày. Các dịch vụ này cho phép nhà phát triển tạo giao diện người dùng tuỳ chỉnh và tích hợp trực tiếp vào Google Workspace. Tiện ích bổ sung giúp người dùng làm việc hiệu quả hơn mà không cần phải chuyển đổi ngữ cảnh.

Trong lớp học lập trình này, bạn sẽ tìm hiểu cách xây dựng và triển khai tiện ích bổ sung đơn giản cho danh sách công việc bằng Node.js, Cloud RunDatastore.

Kiến thức bạn sẽ học được

  • Sử dụng Cloud Shell
  • Triển khai lên Cloud Run
  • Tạo và triển khai bộ mô tả triển khai Tiện ích bổ sung
  • Tạo giao diện người dùng tiện ích bổ sung bằng khung thẻ
  • Phản hồi tương tác của người dùng
  • Tận dụng bối cảnh của người dùng trong tiện ích bổ sung

2. Thiết lập và yêu cầu

Làm theo hướng dẫn thiết lập để tạo một dự án trên Google Cloud và bật các API cũng như dịch vụ mà tiện ích bổ sung sẽ sử dụng.

Thiết lập môi trường theo tiến độ riêng

  1. Mở Cloud Console rồi tạo một dự án mới. (Nếu bạn chưa có tài khoản Gmail hoặc Google Workspace, hãy tạo một tài khoản.)

Trình đơn chọn dự án

Nút Dự án mới

Mã dự án

Xin lưu ý rằng mã dự án là một tên riêng biệt trong tất cả dự án Google Cloud (tên ở trên đã được sử dụng nên sẽ không phù hợp với bạn!). Lớp này sẽ được đề cập sau trong lớp học lập trình này là PROJECT_ID.

  1. Tiếp theo, để sử dụng các tài nguyên của Google Cloud, hãy bật tính năng thanh toán trong Cloud Console.

Việc chạy qua lớp học lập trình này sẽ không tốn nhiều chi phí. Hãy nhớ làm theo mọi hướng dẫn trong phần "Dọn dẹp" ở cuối lớp học lập trình sẽ hướng dẫn bạn cách tắt tài nguyên để bạn không phải thanh toán ngoài hướng dẫn này. Người dùng mới của Google Cloud đủ điều kiện tham gia chương trình Dùng thử miễn phí 300 USD.

Google Cloud Shell

Mặc dù bạn có thể vận hành Google Cloud từ xa trên máy tính xách tay, nhưng trong lớp học lập trình này, chúng ta sẽ sử dụng Google Cloud Shell, một môi trường dòng lệnh chạy trong Đám mây.

Kích hoạt Cloud Shell

  1. Trong Cloud Console, hãy nhấp vào Kích hoạt Cloud Shell Biểu tượng Cloud Shell.

Biểu tượng Cloud Shell trong thanh trình đơn

Lần đầu tiên mở Cloud Shell, bạn sẽ thấy một thông báo chào mừng có tính mô tả. Nếu bạn thấy thư chào mừng, hãy nhấp vào Tiếp tục. Tin nhắn chào mừng sẽ không xuất hiện nữa. Dưới đây là tin nhắn chào mừng:

Thư chào mừng của Cloud Shell

Quá trình cấp phép và kết nối với Cloud Shell chỉ mất vài phút. Sau khi kết nối, bạn sẽ thấy Cloud Shell Terminal:

Nhà ga Cloud Shell

Máy ảo này chứa tất cả các công cụ phát triển mà bạn cần. Dịch vụ này cung cấp thư mục gốc 5 GB ổn định và chạy trong Google Cloud, giúp nâng cao đáng kể hiệu suất và khả năng xác thực của mạng. Bạn có thể thực hiện tất cả công việc trong lớp học lập trình này bằng trình duyệt hoặc Chromebook.

Sau khi kết nối với Cloud Shell, bạn sẽ thấy mình đã được xác thực và dự án đã được đặt thành mã dự án.

  1. Chạy lệnh sau trong Cloud Shell để xác nhận rằng bạn đã được xác thực:
gcloud auth list

Nếu bạn được nhắc cho phép Cloud Shell thực hiện lệnh gọi API GCP, hãy nhấp vào Uỷ quyền.

Kết quả lệnh

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

Để đặt tài khoản đang hoạt động, hãy chạy:

gcloud config set account <ACCOUNT>

Để xác nhận rằng bạn đã chọn đúng dự án, trong Cloud Shell, hãy chạy:

gcloud config list project

Kết quả lệnh

[core]
project = <PROJECT_ID>

Nếu dự án chính xác không được trả về, bạn có thể đặt dự án bằng lệnh sau:

gcloud config set project <PROJECT_ID>

Kết quả lệnh

Updated property [core/project].

Lớp học lập trình này sử dụng kết hợp các thao tác dòng lệnh cũng như chỉnh sửa tệp. Để chỉnh sửa tệp, bạn có thể sử dụng trình soạn thảo mã tích hợp sẵn trong Cloud Shell bằng cách nhấp vào nút Open Editor (Mở trình chỉnh sửa) ở bên phải thanh công cụ Cloud Shell. Bạn cũng sẽ tìm thấy các trình chỉnh sửa phổ biến như vim và emacs trong Cloud Shell.

3. Bật các API Cloud Run, Datastore và Add-on

Bật Cloud API

Từ Cloud Shell, hãy bật Cloud API cho những thành phần sẽ được sử dụng:

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

Thao tác này có thể mất vài phút để hoàn tất.

Sau khi hoàn tất, một thông báo thành công tương tự như thông báo này sẽ xuất hiện:

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

Tạo một thực thể kho dữ liệu

Tiếp theo, hãy bật App Engine và tạo cơ sở dữ liệu Datastore. Việc bật App Engine là điều kiện tiên quyết để sử dụng Datastore, nhưng chúng tôi sẽ không sử dụng App Engine cho bất cứ tác vụ nào khác.

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

Tiện ích bổ sung này cần người dùng cho phép để chạy và thao tác trên dữ liệu. Hãy định cấu hình màn hình xin phép của dự án để bật tính năng này. Đối với lớp học lập trình này, để bắt đầu, bạn cần định cấu hình màn hình đồng ý như một ứng dụng nội bộ (tức là ứng dụng này không dành cho việc phân phối công khai).

  1. Mở Google Cloud Console trong một thẻ hoặc cửa sổ mới.
  2. Bên cạnh "Google Cloud Console", nhấp vào biểu tượng Mũi tên xuống mũi tên thả xuống rồi chọn dự án.
  3. Ở góc trên cùng bên trái, hãy nhấp vào biểu tượng Trình đơn biểu tượng menu.
  4. Nhấp vào API & Dịch vụ > Thông tin đăng nhập. Trang thông tin xác thực cho dự án của bạn sẽ xuất hiện.
  5. Nhấp vào Màn hình xin phép bằng ứng dụng OAuth. "Màn hình xin phép bằng OAuth" màn hình xuất hiện.
  6. Trong "Loại người dùng", chọn Nội bộ. Nếu sử dụng tài khoản @gmail.com, hãy chọn Bên ngoài.
  7. Nhấp vào Tạo. "Chỉnh sửa đăng ký ứng dụng" trang xuất hiện.
  8. Điền vào biểu mẫu:
    • Trong phần Tên ứng dụng, hãy nhập "Tiện ích bổ sung việc cần làm".
    • Trong mục Email hỗ trợ người dùng, hãy nhập địa chỉ email cá nhân của bạn.
    • Trong phần Thông tin liên hệ của nhà phát triển, hãy nhập địa chỉ email cá nhân của bạn.
  9. Nhấp vào Lưu và tiếp tục. Biểu mẫu Phạm vi sẽ xuất hiện.
  10. Trong biểu mẫu Phạm vi, hãy nhấp vào Lưu và tiếp tục. Một bản tóm tắt sẽ xuất hiện.
  11. Nhấp vào Quay lại trang tổng quan.

4. Tạo tiện ích bổ sung ban đầu

Khởi chạy dự án

Để bắt đầu, bạn sẽ tạo một câu lệnh đơn giản "Xin chào thế giới" và triển khai tiện ích bổ sung. Tiện ích bổ sung là các dịch vụ web phản hồi các yêu cầu https và phản hồi bằng tải trọng JSON mô tả giao diện người dùng và các hành động cần thực hiện. Trong tiện ích bổ sung này, bạn sẽ sử dụng Node.js và khung Express.

Để tạo dự án mẫu này, hãy dùng Cloud Shell để tạo một thư mục mới có tên là todo-add-on rồi chuyển đến thư mục đó:

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

Bạn sẽ làm mọi việc trong lớp học lập trình này trong thư mục này.

Khởi chạy dự án Node.js:

npm init

Đối tác phân bổ giá trị gia tăng đặt ra một số câu hỏi về cấu hình dự án, chẳng hạn như tên và phiên bản. Đối với mỗi câu hỏi, hãy nhấn ENTER để chấp nhận các giá trị mặc định. Điểm truy cập mặc định là tệp có tên index.js mà chúng ta sẽ tạo trong bước tiếp theo.

Tiếp theo, hãy cài đặt khung web Express:

npm install --save express express-async-handler

Tạo phần phụ trợ tiện ích bổ sung

Đã đến lúc bắt đầu tạo ứng dụng.

Tạo một tệp có tên index.js. Để tạo tệp, bạn có thể sử dụng Cloud Shell Editor bằng cách nhấp vào nút Open Editor (Mở trình chỉnh sửa) trên thanh công cụ của cửa sổ Cloud Shell. Hoặc bạn có thể chỉnh sửa và quản lý các tệp trong Cloud Shell bằng cách sử dụng vim hoặc emacs.

Sau khi bạn tạo tệp index.js, hãy thêm nội dung sau:

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

Máy chủ không làm gì nhiều ngoài việc hiện thông báo "Hello world" tin nhắn là bình thường. Sau này bạn sẽ bổ sung thêm chức năng.

Triển khai lên Cloud Run

Để triển khai trên Cloud Run, bạn cần phải xếp ứng dụng vào vùng chứa.

Tạo vùng chứa

Tạo một Dockerfile có tên là Dockerfile chứa:

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

Loại bỏ các tệp không mong muốn ra khỏi vùng chứa

Để giúp vùng chứa trở nên sáng, hãy tạo một tệp .dockerignore chứa:

Dockerfile
.dockerignore
node_modules
npm-debug.log

Bật Cloud Build

Trong lớp học lập trình này, bạn sẽ xây dựng và triển khai tiện ích bổ sung nhiều lần khi thêm chức năng mới. Thay vì chạy các lệnh riêng biệt để tạo vùng chứa, hãy đẩy vùng chứa đó vào sổ đăng ký vùng chứa rồi triển khai lên Cloud Build, hãy sử dụng Cloud Build để sắp xếp quy trình này. Tạo tệp cloudbuild.yaml kèm theo hướng dẫn về cách xây dựng và triển khai ứng dụng:

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

Chạy các lệnh sau để cấp quyền triển khai ứng dụng 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

Xây dựng và triển khai phần phụ trợ tiện ích bổ sung

Để bắt đầu tạo bản dựng, trong Cloud Shell, hãy chạy:

gcloud builds submit

Quá trình xây dựng và triển khai đầy đủ có thể mất vài phút để hoàn tất, đặc biệt là trong lần đầu tiên.

Sau khi quá trình tạo bản dựng hoàn tất, hãy xác minh dịch vụ đã được triển khai và tìm URL. Chạy lệnh:

gcloud run services list --platform managed

Sao chép URL này, bạn sẽ cần nó cho bước tiếp theo – cho Google Workspace biết cách gọi tiện ích bổ sung.

Đăng ký tiện ích bổ sung

Hiện tại, máy chủ đã được thiết lập và đang chạy, hãy mô tả tiện ích bổ sung để Google Workspace biết cách hiển thị và gọi tiện ích bổ sung đó.

Tạo chỉ số mô tả triển khai

Tạo tệp deployment.json với nội dung sau. Hãy nhớ sử dụng URL của ứng dụng đã triển khai thay cho phần giữ chỗ 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": {}
  }
}

Tải lên mã mô tả triển khai bằng cách chạy lệnh:

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

Cho phép truy cập vào phần phụ trợ của tiện ích bổ sung

Khung tiện ích bổ sung cũng cần có quyền để gọi dịch vụ. Chạy các lệnh sau để cập nhật chính sách IAM cho Cloud Run để cho phép Google Workspace gọi tiện ích bổ sung:

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"

Cài đặt tiện ích bổ sung để kiểm thử

Để cài đặt tiện ích bổ sung ở chế độ phát triển cho tài khoản của bạn, trong Cloud Shell, hãy chạy:

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

Mở (Gmail)[https://mail.google.com/] trong thẻ hoặc cửa sổ mới. Ở phía bên phải, hãy tìm tiện ích bổ sung có biểu tượng dấu kiểm.

Biểu tượng tiện ích bổ sung đã cài đặt

Để mở tiện ích bổ sung, hãy nhấp vào biểu tượng dấu kiểm. Lời nhắc cấp quyền cho tiện ích bổ sung này sẽ xuất hiện.

Lời nhắc uỷ quyền

Nhấp vào Uỷ quyền truy cập rồi làm theo hướng dẫn về quy trình uỷ quyền trong cửa sổ bật lên. Sau khi hoàn tất, tiện ích bổ sung này sẽ tự động tải lại và hiển thị thông báo "Hello world!" (Xin chào thế giới!) .

Xin chúc mừng! Giờ đây, bạn đã triển khai và cài đặt một tiện ích bổ sung đơn giản. Đã đến lúc chuyển thành ứng dụng danh sách việc cần làm!

5. Truy cập vào danh tính người dùng

Tiện ích bổ sung thường được nhiều người dùng sử dụng để làm việc với thông tin riêng tư đối với họ hoặc tổ chức của họ. Trong lớp học lập trình này, tiện ích bổ sung sẽ chỉ hiển thị các nhiệm vụ cho người dùng hiện tại. Danh tính của người dùng được gửi đến tiện ích bổ sung thông qua mã thông báo danh tính cần được giải mã.

Thêm phạm vi vào chỉ số mô tả quá trình triển khai

Danh tính người dùng không được gửi theo mặc định. Đó là dữ liệu người dùng và tiện ích bổ sung cần có quyền truy cập vào dữ liệu đó. Để có được quyền đó, hãy cập nhật deployment.json rồi thêm phạm vi OAuth openidemail vào danh sách phạm vi mà tiện ích bổ sung yêu cầu. Sau khi thêm phạm vi OAuth, tiện ích bổ sung này sẽ nhắc người dùng cấp quyền truy cập vào lần tiếp theo họ sử dụng tiện ích bổ sung.

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

Sau đó, trong Cloud Shell, hãy chạy lệnh này để cập nhật chỉ số mô tả quá trình triển khai:

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

Cập nhật máy chủ tiện ích bổ sung

Mặc dù tiện ích bổ sung được định cấu hình để yêu cầu danh tính người dùng, nhưng bạn vẫn cần cập nhật phương thức triển khai.

Phân tích cú pháp mã thông báo danh tính

Bắt đầu bằng cách thêm thư viện xác thực của Google vào dự án:

npm install --save google-auth-library

Sau đó, hãy chỉnh sửa index.js để yêu cầu OAuth2Client:

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

Sau đó, hãy thêm một phương thức trợ giúp để phân tích cú pháp mã thông báo mã nhận dạng:

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

Hiển thị danh tính người dùng

Đây là thời điểm thích hợp để kiểm tra trước khi thêm tất cả chức năng danh sách công việc. Cập nhật tuyến của ứng dụng để in địa chỉ email và mã nhận dạng duy nhất của người dùng thay vì "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);
}));

Sau khi thực hiện những thay đổi này, tệp index.js thu được sẽ có dạng như sau:

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

Triển khai lại và thử nghiệm

Tạo lại và triển khai lại tiện ích bổ sung. Trong Cloud Shell, hãy chạy:

gcloud builds submit

Sau khi triển khai lại máy chủ, hãy mở hoặc tải lại Gmail rồi mở lại tiện ích bổ sung. Vì phạm vi đã thay đổi nên tiện ích bổ sung sẽ yêu cầu cấp quyền lại. Hãy cho phép tiện ích bổ sung thêm lần nữa. Sau khi hoàn tất, tiện ích bổ sung sẽ hiển thị địa chỉ email và mã nhận dạng người dùng của bạn.

Giờ đây, tiện ích bổ sung đã biết được người dùng là ai, bạn có thể bắt đầu thêm chức năng danh sách công việc.

6. Triển khai danh sách việc cần làm

Mô hình dữ liệu ban đầu cho lớp học lập trình này rất đơn giản: một danh sách thực thể Task, mỗi thực thể có thuộc tính dành cho văn bản mô tả nhiệm vụ và dấu thời gian.

Tạo chỉ mục kho dữ liệu

Kho dữ liệu đã được bật cho dự án trước đó trong lớp học lập trình. Phương thức này không yêu cầu giản đồ, mặc dù yêu cầu phải tạo chỉ mục rõ ràng cho các truy vấn phức hợp. Có thể mất vài phút để tạo chỉ mục, vì vậy bạn sẽ thực hiện việc đó trước.

Tạo một tệp có tên index.yaml như sau:

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

Sau đó, hãy cập nhật các chỉ mục của Datastore:

gcloud datastore indexes create index.yaml

Khi được nhắc để tiếp tục, hãy nhấn ENTER trên bàn phím. Quá trình tạo chỉ mục diễn ra trong nền. Trong lúc đó, hãy bắt đầu cập nhật mã tiện ích bổ sung để triển khai các "việc cần làm".

Cập nhật phần phụ trợ của tiện ích bổ sung

Cài đặt thư viện Datastore cho dự án:

npm install --save @google-cloud/datastore

Đọc và ghi vào Datastore

Cập nhật index.js để triển khai các "việc cần làm" bắt đầu bằng việc nhập thư viện kho dữ liệu và tạo ứng dụng:

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

Thêm phương thức để đọc và ghi tác vụ từ 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);
}

Triển khai quá trình kết xuất giao diện người dùng

Hầu hết các thay đổi đều nằm trong giao diện người dùng của tiện ích bổ sung. Trước đó, tất cả các thẻ mà giao diện người dùng trả về đều ở dạng tĩnh – không thay đổi theo dữ liệu có sẵn. Ở đây, thẻ cần được tạo một cách linh động dựa trên danh sách công việc hiện tại của người dùng.

Giao diện người dùng của lớp học lập trình này bao gồm một mục nhập văn bản cùng với danh sách các nhiệm vụ có các hộp đánh dấu để đánh dấu là đã hoàn thành. Mỗi đối tượng trong số này cũng có một thuộc tính onChangeAction dẫn đến lệnh gọi lại đến máy chủ tiện ích bổ sung khi người dùng thêm hoặc xoá một công việc. Trong mỗi trường hợp như vậy, bạn cần kết xuất lại giao diện người dùng với danh sách tác vụ đã cập nhật. Để xử lý vấn đề này, hãy giới thiệu một phương thức mới để tạo giao diện người dùng cho thẻ.

Tiếp tục chỉnh sửa index.js rồi thêm phương thức sau:

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

Cập nhật tuyến đường

Hiện đã có các phương thức trợ giúp để đọc và ghi vào Datastore cũng như xây dựng giao diện người dùng, hãy kết nối các phương thức đó với nhau trong tuyến ứng dụng. Thay thế tuyến đường hiện có và thêm hai tuyến đường khác: một để thêm công việc và một để xoá chúng.

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

Dưới đây là tệp index.js cuối cùng có đầy đủ chức năng:

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

Triển khai lại và thử nghiệm

Để tạo lại và triển khai lại tiện ích bổ sung, hãy bắt đầu tạo một bản dựng. Trong Cloud Shell, hãy chạy:

gcloud builds submit

Trong Gmail, tải lại tiện ích bổ sung và giao diện người dùng mới sẽ xuất hiện. Hãy dành một phút để khám phá tiện ích bổ sung này. Thêm một vài công việc bằng cách nhập một số văn bản vào mục nhập và nhấn ENTER trên bàn phím của bạn, sau đó nhấp vào hộp kiểm để xóa chúng.

Tiện ích bổ sung chứa nhiệm vụ

Nếu muốn, bạn có thể chuyển đến bước cuối cùng trong lớp học lập trình này và dọn dẹp dự án. Hoặc nếu muốn tiếp tục tìm hiểu thêm về các tiện ích bổ sung, bạn có thể hoàn thành thêm một bước nữa.

7. (Không bắt buộc) Thêm bối cảnh

Một trong những tính năng mạnh mẽ nhất của tiện ích bổ sung là khả năng nhận biết theo bối cảnh. Khi có sự cho phép của người dùng, tiện ích bổ sung có thể truy cập vào các ngữ cảnh của Google Workspace, chẳng hạn như email người dùng đang xem, sự kiện trên lịch và tài liệu. Tiện ích bổ sung cũng có thể thực hiện các thao tác như chèn nội dung. Trong lớp học lập trình này, bạn sẽ thêm tính năng hỗ trợ theo bối cảnh cho trình chỉnh sửa Workspace (Tài liệu, Trang tính và Trang trình bày) để đính kèm tài liệu hiện tại vào mọi công việc được tạo khi đang ở trình chỉnh sửa. Khi việc cần làm xuất hiện, việc nhấp vào đó sẽ mở tài liệu trong thẻ mới để đưa người dùng quay lại tài liệu nhằm hoàn tất việc cần làm đó.

Cập nhật phần phụ trợ của tiện ích bổ sung

Cập nhật tuyến đường newTask

Trước tiên, hãy cập nhật tuyến /newTask để đưa mã tài liệu vào một tác vụ (nếu có):

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

Các công việc mới tạo giờ đây sẽ có mã tài liệu hiện tại. Tuy nhiên, ngữ cảnh trong trình chỉnh sửa không được chia sẻ theo mặc định. Giống như dữ liệu khác của người dùng, người dùng phải cấp quyền cho tiện ích bổ sung truy cập vào dữ liệu. Để ngăn việc chia sẻ thông tin quá mức, phương pháp ưu tiên là yêu cầu và cấp quyền cho từng tệp.

Cập nhật giao diện người dùng

Trong index.js, hãy cập nhật buildCard để thực hiện hai thay đổi. Đầu tiên là cập nhật quá trình kết xuất các công việc để thêm một đường liên kết đến tài liệu (nếu có). Thứ hai là hiển thị lời nhắc uỷ quyền tuỳ chọn nếu tiện ích bổ sung được hiển thị trong một trình chỉnh sửa và quyền truy cập vào tệp chưa được cấp.

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

Triển khai tuyến uỷ quyền tệp

Nút uỷ quyền sẽ thêm một tuyến mới vào ứng dụng, vì vậy, hãy triển khai tuyến này. Tuyến này giới thiệu một khái niệm mới là hành động trong ứng dụng lưu trữ. Đây là các hướng dẫn đặc biệt để tương tác với ứng dụng lưu trữ của tiện ích bổ sung. Trong trường hợp này, để yêu cầu quyền truy cập vào tệp của trình chỉnh sửa hiện tại.

Trong index.js, hãy thêm tuyến /authorizeFile:

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

Dưới đây là tệp index.js cuối cùng có đầy đủ chức năng:

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

Thêm phạm vi vào chỉ số mô tả quá trình triển khai

Trước khi xây dựng lại máy chủ, hãy cập nhật bộ mô tả quá trình triển khai tiện ích bổ sung để bao gồm phạm vi OAuth https://www.googleapis.com/auth/drive.file. Cập nhật deployment.json để thêm https://www.googleapis.com/auth/drive.file vào danh sách phạm vi 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"
]

Tải phiên bản mới lên bằng cách chạy lệnh Cloud Shell sau:

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

Triển khai lại và thử nghiệm

Cuối cùng, hãy xây dựng lại máy chủ. Trong Cloud Shell, hãy chạy:

gcloud builds submit

Sau khi hoàn tất, thay vì mở Gmail, hãy mở một tài liệu hiện có trên Google hoặc tạo một tài liệu mới bằng cách mở doc.new. Nếu bạn tạo một tài liệu mới, hãy nhớ nhập một số văn bản hoặc đặt tên cho tệp.

Mở tiện ích bổ sung. Tiện ích bổ sung này hiển thị nút Cho phép truy cập vào tệp ở cuối tiện ích bổ sung. Hãy nhấp vào nút này rồi cho phép ứng dụng truy cập vào tệp đó.

Sau khi được uỷ quyền, hãy thêm công việc khi đang ở trình chỉnh sửa. Nhiệm vụ này có một nhãn cho biết tài liệu được đính kèm. Khi bạn nhấp vào đường liên kết, tài liệu sẽ mở trong một thẻ mới. Tất nhiên, việc mở tài liệu bạn đã mở là hơi ngớ ngẩn. Nếu bạn muốn tối ưu hoá giao diện người dùng để lọc ra các đường liên kết cho tài liệu hiện tại, hãy cân nhắc khoản tín dụng bổ sung đó!

8. Xin chúc mừng

Xin chúc mừng! Bạn đã tạo và triển khai thành công một tiện ích bổ sung Google Workpace bằng Cloud Run. Mặc dù lớp học lập trình này đã đề cập đến nhiều khái niệm chính để xây dựng tiện ích bổ sung, nhưng còn rất nhiều điều khác để bạn khám phá. Hãy xem các tài nguyên dưới đây và đừng quên dọn dẹp dự án của bạn để tránh bị tính thêm phí.

Dọn dẹp

Để gỡ cài đặt tiện ích bổ sung khỏi tài khoản của bạn, trong Cloud Shell, hãy chạy lệnh sau:

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

Để tránh phát sinh phí vào tài khoản Google Cloud Platform của bạn cho các tài nguyên được sử dụng trong hướng dẫn này:

  • Trong Cloud Console, hãy chuyển đến trang Quản lý tài nguyên. Nhấp vào biểu tượng Ở góc trên cùng bên trái, hãy nhấp vào biểu tượng Trình đơn biểu tượng menu > Quản lý danh tính và quyền truy cập (IAM) và Quản trị > Quản lý tài nguyên.
  1. Trong danh sách dự án, hãy chọn dự án rồi nhấp vào Delete (Xoá).
  2. Trong hộp thoại, hãy nhập mã dự án rồi nhấp vào Tắt để xoá dự án.

Tìm hiểu thêm