From 1bb850c6b86f4df73fed0734ff5820ec52329c82 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Wed, 19 Aug 2026 23:06:57 +0700 Subject: [PATCH] =?UTF-8?q?implement=20phase=205=20=E0=B9=81=E0=B8=A5?= =?UTF-8?q?=E0=B8=B0=20fix=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 +- CLAUDE.md | 18 +- README.md | 72 ++- addin/manifest.json | 143 ------ addin/manifest.xml | 12 +- addin/src/config.ts | 4 +- addin/src/services/apiClient.test.ts | 2 +- addin/src/services/authService.test.ts | 17 +- addin/src/services/authService.ts | 26 + addin/src/services/errors.ts | 10 + addin/src/services/fileService.test.ts | 47 +- addin/src/services/fileService.ts | 46 +- addin/src/services/versionFlow.test.ts | 216 +++++++++ addin/src/services/versionFlow.ts | 159 +++++++ addin/src/services/versioningService.ts | 20 + addin/src/taskpane/components/App.tsx | 443 ++++++++++++++++-- .../components/ConfirmVersionDialog.tsx | 55 +++ addin/src/taskpane/components/Header.tsx | 38 -- addin/src/taskpane/components/HeroList.tsx | 62 --- .../src/taskpane/components/TextInsertion.tsx | 57 --- .../components/VersionHistoryPanel.tsx | 128 +++++ addin/src/taskpane/index.tsx | 4 +- addin/src/taskpane/taskpane.html | 2 +- addin/src/taskpane/taskpane.ts | 14 - addin/src/test/httpMock.ts | 12 +- addin/src/test/setup.ts | 4 +- addin/src/test/wordMock.ts | 9 + addin/tsconfig.json | 1 + addin/webpack.config.js | 7 +- .../Controllers/DocumentsController.cs | 27 ++ .../WordVersioning.Api/Models/VersionDtos.cs | 3 + backend/WordVersioning.Api/Program.cs | 40 ++ .../Services/MinioOptions.cs | 3 + .../Services/ObjectStorageService.cs | 27 ++ .../appsettings.Development.json | 7 + backend/WordVersioning.Api/appsettings.json | 2 +- docker-compose.yml | 8 +- docs/plans/officejs-implementation-plan.md | 58 ++- scripts/dev-token.py | 114 +++++ specs/spec-officejs.md | 17 +- 40 files changed, 1507 insertions(+), 429 deletions(-) delete mode 100644 addin/manifest.json create mode 100644 addin/src/services/errors.ts create mode 100644 addin/src/services/versionFlow.test.ts create mode 100644 addin/src/services/versionFlow.ts create mode 100644 addin/src/taskpane/components/ConfirmVersionDialog.tsx delete mode 100644 addin/src/taskpane/components/Header.tsx delete mode 100644 addin/src/taskpane/components/HeroList.tsx delete mode 100644 addin/src/taskpane/components/TextInsertion.tsx create mode 100644 addin/src/taskpane/components/VersionHistoryPanel.tsx delete mode 100644 addin/src/taskpane/taskpane.ts create mode 100755 scripts/dev-token.py diff --git a/.env.example b/.env.example index 3ecd3e5..04f0de3 100644 --- a/.env.example +++ b/.env.example @@ -15,7 +15,7 @@ MINIO_ROOT_USER=wordversioning-minio MINIO_ROOT_PASSWORD=dev-local-pass # --- Keycloak bootstrap admin (service: keycloak) --- -# ใช้ล็อกอิน admin console ครั้งแรกที่ http://localhost:8080/admin/ +# ใช้ล็อกอิน admin console ครั้งแรกที่ https://localhost:8443/admin/ (cert dev ต้อง install ก่อน — ดู README) KC_BOOTSTRAP_ADMIN_USERNAME=admin KC_BOOTSTRAP_ADMIN_PASSWORD=dev-local-pass diff --git a/CLAUDE.md b/CLAUDE.md index 31eb4c7..432922e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## โปรเจกต์นี้คืออะไร -ระบบ **Word Versioning**: จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน: +ระบบ **Word Versioning** (ชื่อที่แสดงใน UI: **e-Court Documents Versioning**): จัดการ version history ของไฟล์ .docx — สร้างเอกสารจาก template ผ่านเว็บ, ตรวจจับ tracked document ใน Word, บันทึก version ผ่านปุ่ม Save & Version และ sync ค่า fields กลับ DB ประกอบด้วย 3 ส่วน: | ส่วน | Stack | หน้าที่ | |---|---|---| @@ -14,7 +14,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`) -**สถานะปัจจุบัน: Phase 0–3 เสร็จ** (scaffold + infra + backend core FR-5–8 + backend template & generation FR-9–11) — งานต่อคือ implement phase 4–8 ในแผน (4–5 add-in กับ 6 web ทำขนานกันได้) +**สถานะปัจจุบัน: Phase 0–5 เสร็จ** (backend ครบ + add-in ครบทั้ง services และ task pane UI — sideload ทดสอบจริงบน Word for Mac แล้ว แก้ปัญหาตกมุม 4 รอบ) — งานต่อคือ phase 6 (web portal), 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน ## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ) @@ -35,6 +35,9 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ 8. **Ports:** add-in = `https://localhost:3000`, web = `http://localhost:5173` (ห้ามชนกัน), backend CORS ต้องอนุญาตทั้งคู่ 9. **Config (DB/MinIO/Keycloak) แยกเป็น env/appsettings เท่านั้น** — ห้าม hardcode; `userId` เอาจาก JWT claims เท่านั้น ห้ามรับจาก client 10. **ทุก Office.js call ต้อง catch** — ห้ามปล่อย unhandled promise rejection +11. **Dev: ทุก service ที่ task pane (หน้า https) เรียกต้องเป็น HTTPS ด้วย cert ของ `office-addin-dev-certs`** — Keycloak = `https://localhost:8443` (Office Dialog API ปฏิเสธ http:// โดยสมบูรณ์), backend = `https://localhost:7232` + `http://localhost:5075` (WKWebView บล็อก mixed content → "Load failed") — รัน `npx office-addin-dev-certs install` ก่อน `docker compose up` +12. **ดาวน์โหลดไฟล์จาก add-in ใช้ presigned URL + `Office.context.ui.openBrowserWindow` เท่านั้น** — webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ ไฟล์ไม่ถูกเขียนที่ไหนเลย; และ**ห้าม `cp` ทับ manifest ใน wef folder** (`npm start` ใช้ symlink — ทับแล้วครั้งหน้า EEXIST, ให้แก้ที่ `addin/manifest.xml` ต้นทาง) +13. **`slice.data` จาก `getSliceAsync` มีหลายรูปแบบตาม host** (ArrayBuffer / view / base64 string / `number[]`) — ต้องผ่าน `fileService.sliceDataToBytes()` เสมอ ห้าม assume รูปเดียว ## สถาปัตยกรรม — flow หลักที่ต้องเข้าใจ @@ -44,16 +47,17 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ **Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore -**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend proxy stream เท่านั้น +**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง) **Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่ ## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0) ```bash -docker compose up -d # infra: PostgreSQL + MinIO + Keycloak +docker compose up -d # infra: PostgreSQL + MinIO + Keycloak(https:8443) +python3 scripts/dev-token.py # token PKCE จริงสำหรับทดสอบ backend/Swagger (admin-cli ไม่มี aud ใช้ไม่ได้) -# backend +# backend (http://localhost:5075 + https://localhost:7232 — dev เปิด https อัตโนมัติด้วย dev cert) cd backend/WordVersioning.Api dotnet run # API + swagger dotnet ef migrations add @@ -62,11 +66,11 @@ dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เ # add-in (https://localhost:3000) cd addin +npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว — ทำก่อน docker compose up npm run build # webpack build -npm start # dev server — sideload ผ่าน manifest +npm start # dev server — sideload ผ่าน manifest (symlink) npm test npx jest src/services/metadataService.test.ts # รัน test เดี่ยว -npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว # web portal (http://localhost:5173 — ห้ามใช้ 3000) cd web diff --git a/README.md b/README.md index cb774b1..7229681 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Word Versioning Add-in +# e-Court Documents Versioning (Word Add-in) ระบบจัดการ version history ของเอกสาร Word — สร้างเอกสารจาก template ผ่านเว็บ ติดตามทุกการแก้ไขผ่าน add-in ใน Word และ sync ข้อมูลสำคัญกลับฐานข้อมูลอัตโนมัติ @@ -35,11 +35,11 @@ | 2 | Backend core — version API (FR-5–7) + JWT auth (FR-8) | ✅ เสร็จ | | 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ | | 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ | -| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ⏳ รอ implement | +| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด | | 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement | | 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement | -**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in มี services layer พร้อมใช้ครบพร้อม unit tests 40 ตัว (รอ Phase 5 เอาไปต่อ UI) +**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in ครบทั้ง services layer และ task pane UI พร้อม unit tests 45 ตัว ## เอกสาร @@ -58,6 +58,11 @@ dotnet tool install --global dotnet-ef --version 8.0.11 ``` - Node.js LTS (add-in และ web) +- **Dev cert สำหรับ localhost** (ต้องทำ**ก่อน** `docker compose up` — Keycloak mount cert จากที่นี่): + ```bash + cd addin && npx office-addin-dev-certs install + ``` + ทำครั้งเดียวต่อเครื่อง: สร้าง CA + cert ที่ trust ไว้ใน keychain ใช้ทั้ง add-in dev server (https://localhost:3000) และ Keycloak (https://localhost:8443) ### 1) รัน infrastructure (PostgreSQL + MinIO + Keycloak) @@ -70,10 +75,12 @@ docker compose up -d | บริการ | URL | เข้าด้วย | |---|---|---| -| Keycloak admin console | http://localhost:8080/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` | +| Keycloak admin console | https://localhost:8443/admin/ | `KC_BOOTSTRAP_ADMIN_USERNAME` / `KC_BOOTSTRAP_ADMIN_PASSWORD` จาก `.env` | | MinIO console | http://localhost:9001 | `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` จาก `.env` | | PostgreSQL | `localhost:5432` | `POSTGRES_USER` / `POSTGRES_PASSWORD` จาก `.env`, ฐานข้อมูล `wordversioning` | +> **ทำไม Keycloak ต้องเป็น HTTPS:** Office Dialog API ที่ add-in ใช้เปิดหน้า login **ปฏิเสธ http:// โดยสมบูรณ์** (error: "The HTTP protocol is not supported. Use HTTPS instead") — dev จึงให้ Keycloak เสิร์ฟ HTTPS ที่ port 8443 ด้วย cert ของ `office-addin-dev-certs` (CA ถูก trust ในเครื่องแล้ว Word/webview จึงเชื่อ) · ตัว issuer ใน token จะเป็น `https://localhost:8443/realms/word-versioning` ซึ่ง backend ตั้ง `Authentication:Authority` ตรงกัน + Keycloak จะ import realm `word-versioning` อัตโนมัติ (clients 3 ตัว + test user) จาก [keycloak/realm-export.json](keycloak/realm-export.json) — ไฟล์นี้คือ source of truth ของ realm ฝั่ง dev: แก้ realm ให้แก้ที่ไฟล์นี้แล้วรัน `docker compose rm -sf keycloak && docker compose up -d` เพื่อ import ใหม่ (การแก้ผ่าน admin console จะหายเมื่อ recreate container) **Test user สำหรับล็อกอิน:** `testuser` / `Test1234!` (เปลี่ยนได้ใน realm-export.json) @@ -85,9 +92,11 @@ MinIO จะมี bucket `docx-versions` และ `docx-templates` (private ```bash cd backend/WordVersioning.Api dotnet ef database update # ครั้งแรก / เมื่อมี migration ใหม่ -dotnet run # Swagger ที่ http://localhost:5075/swagger +dotnet run # รันทั้ง http://localhost:5075 และ https://localhost:7232 (Swagger ใช้ https ก็ได้) ``` +> **ทำไม dev ต้องมี endpoint HTTPS ด้วย:** task pane ของ add-in เป็นหน้า `https://localhost:3000` และ **webview ของ Word (WKWebView บน Mac) บล็อก fetch จากหน้า https ไป http แบบเงียบๆ** — error ที่เห็นคือ "Load failed" — ส่วน CORS ก็ไม่ช่วยเพราะ request ไม่เคยถึง server · dev จึงเปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกับ Keycloak (`~/.office-addin-dev-certs`, ปิดได้ผ่าน `DevHttps:Enabled=false` ใน `appsettings.Development.json`) · add-in ชี้ API ที่ https ตัวนี้อัตโนมัติ + ค่าเชื่อมต่อ (DB/MinIO/Keycloak) อยู่ใน `appsettings.json` + `appsettings.Development.json` — override ผ่าน env ได้ทั้งหมด เช่น `ConnectionStrings__Default`, `MinIO__Endpoint`, `Authentication__Authority` **API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer `): @@ -96,7 +105,8 @@ dotnet run # Swagger ที่ http://localhost:5075/swagger # Documents / versions (FR-5–7, 11) POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields) -GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO) +GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO) +GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO) # Generate จาก template (FR-10) POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata @@ -117,12 +127,12 @@ DELETE /api/templates/{id} # deactivate (s ### ขอ token ทดสอบ (dev) -ทุก client บังคับ PKCE (ปิด direct grants ตาม spec) — วิธีเร็วสุดตอนทดสอบคือขอผ่าน client `admin-cli` ของ realm: +ทุก client ใน realm บังคับ PKCE (ปิด direct grants ตาม spec) และ token ของ `admin-cli` **ไม่มี claim `aud`** จึงเรียก backend ไม่ผ่าน (401) — ใช้สคริปต์นี้เดิน PKCE flow จริงแทน (login ด้วย test user ให้อัตโนมัติ): ```bash -curl -s -X POST http://localhost:8080/realms/word-versioning/protocol/openid-connect/token \ - -d "client_id=admin-cli&grant_type=password&username=testuser&password=Test1234!" \ -| jq -r .access_token +python3 scripts/dev-token.py # ปริ้น access token +python3 scripts/dev-token.py --clip # หรือคัดลอกเข้า clipboard +TOKEN=$(python3 scripts/dev-token.py) # เก็บใส่ตัวแปร ``` แล้วลอง upload: @@ -186,13 +196,13 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \ แล้วดู `GET /api/documents/$DOC_ID/versions` — แต่ละ version มี snapshot fields ของตัวเอง หากลบ content control ออกจาก Word ก่อน upload field นั้นจะเห็น `"missing": true` โดยค่าเดิมยังคงอยู่ (ไม่ error) -### 3) Add-in (services layer พร้อม — UI ตามมาใน Phase 5) +### 3) Add-in (ครบทั้ง services layer และ task pane UI) ```bash cd addin npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost) npm run build # webpack build + type-check ทั้ง program -npm test # Jest — unit tests ของ services layer (40 ตัว) +npm test # Jest — unit tests (45 ตัว) npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์) npm run validate # ตรวจ manifest.xml npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml @@ -206,21 +216,35 @@ npm start # dev server https://localhost:3000 — sid | `metadataService.ts` | อ่าน/stamp custom properties (`SavedByAddIn` ฯลฯ), Custom XML Part แบบ delete-then-add (fail เงียบๆ ตาม known bug บน Mac), `readContentControlFields()` (FR-11) | | `fileService.ts` | ดึงไฟล์ปัจจุบันผ่าน File/Slice API ประกอบเป็น Blob — เรียก `closeAsync` เสมอ | | `apiClient.ts` | fetch wrapper แนบ Bearer, โดน 401 → refresh + retry ครั้งเดียว, ยัง fail → `AuthRequiredError` ให้ UI กลับหน้า sign-in | -| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion | +| `versioningService.ts` | uploadVersion (แนบ `fields`), getVersions, downloadVersion + getDownloadLink (presigned) | +| `versionFlow.ts` | orchestration ของ Start Versioning / Save & Version ตามลำดับ 7 ขั้นตอนของ spec | หน้า login ของ add-in คือ [addin/src/taskpane/auth-callback.html](addin/src/taskpane/auth-callback.html) — redirect target ของ Keycloak (ตรง `https://localhost:3000/auth-callback.html` ใน realm-export.json) **Config ฝั่ง add-in** ฉีดตอน build ผ่าน webpack DefinePlugin (อ่านจาก env — default ชี้ dev ในพื้นที่): ```bash -KEYCLOAK_AUTHORITY=http://localhost:8080/realms/word-versioning \ -API_BASE_URL=http://localhost:5075 \ +KEYCLOAK_AUTHORITY=https://localhost:8443/realms/word-versioning \ +API_BASE_URL=https://localhost:7232 \ npm run build ``` (ตัวแปรที่เหลือ: `KEYCLOAK_CLIENT_ID` — default `word-addin-officejs`) -**ข้อจำกัดชั่วคราว:** task pane ยังเป็นหน้า demo ของ scaffold — Phase 5 จะเอา services ชุดนี้ไป wire เป็น UI จริง (sign-in → detect → Save & Version → history) +**Task pane UI (Phase 5)** — state machine `checking → signed-out → untracked → tracking`: + +- **Sign-in gate (FR-1)** — ยังไม่ login → ปุ่ม Sign in (เปิด Keycloak ผ่าน Office Dialog API) · login แล้วตรวจ property `SavedByAddIn` ในไฟล์: ตรง → โหลด version history ทันที (ไฟล์ที่สร้างจากเว็บเข้า state นี้เลย เห็น v1 โดยไม่ต้องกดอะไร) / ไม่ตรง → ปุ่ม **Start Versioning This Document** +- **Save & Version (FR-2)** — confirm dialog → stamp properties → อัปเดต Custom XML Part → native save → ดึงไฟล์ + extract fields (FR-11) → upload → refresh history · ทุกขั้นมี loading + error ไม่มี unhandled rejection +- **Soft reminder (FR-3)** — ระหว่าง tracking ถามทุก 30 วิ ว่ามีแก้ไขที่ยังไม่ได้ version → banner เตือน (ไม่ block) +- **Version history (FR-4)** — รายการ v เวลา/ผู้สร้าง + ปุ่มดาวน์โหลด version นั้น (ขอ presigned URL อายุ 2 นาทีแล้วเปิดในเบราว์เซอร์ของเครื่อง — webview ของ Word ทิ้ง blob download แบบเงียบๆ ไฟล์จึงต้องมาจากเบราว์เซอร์จริง แล้วหาได้ในโฟลเดอร์ Downloads ของเบราว์เซอร์) +- **Session หมดอายุ (AC-9)** — 401 → silent refresh → ยังไม่ได้ → กลับหน้า sign-in พร้อมข้อความ + +**Sideload เพื่อทดสอบใน Word จริง** (ต้องรัน infra + backend + `npm start` ก่อน): + +- **Word Desktop (Mac/Windows)**: `cd addin && npm start` (ใช้ office-addin-debugging sideload ให้อัตโนมัติ) หรือ insert manual: Word → Insert → My Add-ins → Upload My Add-in → เลือก `addin/manifest.xml` +- **Word Online**: ไฟล์ใดก็ได้ใน OneDrive/SharePoint → Insert → Office Add-ins → Upload My Add-in → เลือก `addin/manifest.xml` +- ไล่ทดสอบตาม acceptance criteria: login ผ่าน dialog → เปิดไฟล์ใหม่เห็นปุ่ม Start Versioning → กดแล้วได้ v1 → แก้ไขแล้ว Save & Version → history เพิ่ม + ดาวน์โหลด version เก่าเปิดได้ +- ⚠️ **ห้าม `cp` ทับ manifest ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/`** — `npm start` สร้าง symlink ไว้ การทับทำให้ครั้งถัดไปขึ้น `EEXIST` — ถาวรแก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word (ถ้าเจอ EEXIST แล้ว: ลบไฟล์ใน wef ทิ้งแล้ว `npm start` ใหม่) ### 4) Web Portal (ยังเป็น scaffold) @@ -232,6 +256,22 @@ logic จริง (login, template UI, dynamic form) มาตาม Phase 6 > คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน +## Troubleshooting (จากการทดสอบจริงบน Word for Mac) + +ปัญหาที่เจอตอน sideload test รอบแรกๆ พร้อมสาเหตุ/วิธีแก้ — แก้ไขในโค้ดแล้วทั้งหมด เก็บไว้เป็นบทเรียน: + +| อาการ | สาเหตุ | วิธีแก้ | +|---|---|---| +| กด Sign in แล้ว "The HTTP protocol is not supported. Use HTTPS instead" | Office Dialog API **ปฏิเสธ http:// โดยสมบูรณ์** แต่ Keycloak dev เดิมรัน http://8080 | Keycloak เสิร์ฟ HTTPS ที่ `https://localhost:8443` ด้วย cert ของ office-addin-dev-certs (docker-compose mount `~/.office-addin-dev-certs` เข้า container) | +| Login ผ่านแต่ทุก API call ขึ้น "Load failed" | **WKWebView บล็อก fetch จากหน้า https ไป http (mixed content)** — task pane เป็น `https://localhost:3000` แต่ API เป็น `http://localhost:5075` (CORS ไม่เกี่ยว — ตรวจแล้วเรียบร้อย) | backend เปิด endpoint `https://localhost:7232` ด้วย cert ชุดเดียวกัน (`DevHttps:*` ใน `appsettings.Development.json`) — add-in ชี้อันนี้อัตโนมัติ | +| กด Start Versioning แล้ว "รูปแบบข้อมูลของ slice ไม่รองรับ" | host แต่ละตัวส่ง `slice.data` คนละรูป (Win = ArrayBuffer/view, **Mac = base64 string หรือ number[]**) | `fileService.sliceDataToBytes()` normalize ครบทั้ง 4 รูปแบบ | +| กดดาวน์โหลด version แล้ว**ไม่มีไฟล์โผล่ที่ไหนเลย** | **webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ** | ขอ presigned URL อายุ 2 นาทีจาก `download-link` แล้วเปิดใน**เบราว์เซอร์ของเครื่อง** (`openBrowserWindow`) — ไฟล์ลงโฟลเดอร์ Downloads ของเบราว์เซอร์ | +| แก้ webpack.config.js แล้วค่าเก่ายังติดอยู่ | webpack **ไม่ restart เองเมื่อ config เปลี่ยน** + dev-server ตัวเก่าถือ port 3000 อยู่ (ตัวใหม่ขึ้นไม่ได้) | kill dev-server เดิม (`lsof -ti :3000 | xargs kill`) แล้ว `npm start` ใหม่ทุกครั้งที่แก้ config | +| `npm start` ขึ้น `EEXIST ... wef/...manifest.xml` | มีการ `cp` ทับ symlink ของ sideload จนกลายเป็นไฟล์ธรรมดา | ลบไฟล์นั้นใน wef folder ทิ้งแล้ว `npm start` ใหม่ (ดูคำเตือนในหัวข้อ Sideload) | +| curl token จาก `admin-cli` แล้วเรียก backend ได้ 401 | token ของ admin-cli **ไม่มี claim `aud`** (ไม่ได้อยู่ใน realm-export จึงไม่มี audience mapper) | ใช้ `python3 scripts/dev-token.py` (เดิน PKCE จริงด้วย client `word-versioning-web`) | + +**กติกาที่สรุปได้:** ใน dev ทุก service ที่ task pane (หน้า https) จะเรียก **ต้องเป็น HTTPS ด้วย cert จาก office-addin-dev-certs ทั้งหมด** — Keycloak (8443) และ backend (7232) + ## เทคโนโลยี - **Add-in**: Office.js (WordApi 1.5+), TypeScript, React, Fluent UI, webpack — scaffold ด้วย `yo office` diff --git a/addin/manifest.json b/addin/manifest.json deleted file mode 100644 index 504f16b..0000000 --- a/addin/manifest.json +++ /dev/null @@ -1,143 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/json-schemas/teams/v1.17/MicrosoftTeams.schema.json", - "id": "f2b103f1-1ab1-4e1b-8f0b-072aa3d4e19d", - "manifestVersion": "1.17", - "version": "1.0.0", - "name": { - "short": "Contoso Task Pane Add-in", - "full": "Contoso Task Pane Add-in" - }, - "description": { - "short": "A template to get started.", - "full": "This is the template to get started." - }, - "developer": { - "name": "Contoso", - "websiteUrl": "https://www.contoso.com", - "privacyUrl": "https://www.contoso.com/privacy", - "termsOfUseUrl": "https://www.contoso.com/servicesagreement" - }, - "icons": { - "outline": "assets/outline.png", - "color": "assets/color.png" - }, - "accentColor": "#230201", - "localizationInfo": { - "defaultLanguageTag": "en-us", - "additionalLanguages": [] - }, - "authorization": { - "permissions": { - "resourceSpecific": [ - { - "name": "Mailbox.ReadWrite.User", - "type": "Delegated" - } - ] - } - }, - "validDomains": ["contoso.com"], - "extensions": [ - { - "requirements": { - "scopes": ["mail"], - "capabilities": [ - { "name": "Mailbox", "minVersion": "1.3" } - ] - }, - "runtimes": [ - { - "requirements": { - "capabilities": [ - { "name": "Mailbox", "minVersion": "1.3" } - ] - }, - "id": "TaskPaneRuntime", - "type": "general", - "code": { - "page": "https://localhost:3000/taskpane.html" - }, - "lifetime": "short", - "actions": [ - { - "id": "TaskPaneRuntimeShow", - "type":"openPage", - "pinnable": false, - "view": "dashboard" - } - ] - }, - { - "id": "CommandsRuntime", - "type": "general", - "code": { - "page": "https://localhost:3000/commands.html", - "script": "https://localhost:3000/commands.js" - }, - "lifetime": "short", - "actions": [ - { - "id": "action", - "type": "executeFunction" - } - ] - } - ], - "ribbons": [ - { - "contexts": [ - "mailCompose" - ], - "tabs": [ - { - "builtInTabId": "TabDefault", - "groups": [ - { - "id": "msgComposeGroup", - "label": "Contoso Add-in", - "icons": [ - { "size": 16, "url": "https://localhost:3000/assets/icon-16.png" }, - { "size": 32, "url": "https://localhost:3000/assets/icon-32.png" }, - { "size": 80, "url": "https://localhost:3000/assets/icon-80.png" } - ], - "controls": [ - { - "id": "msgComposeOpenPaneButton", - "type": "button", - "label": "Show Task Pane", - "icons": [ - { "size": 16, "url": "https://localhost:3000/assets/icon-16.png" }, - { "size": 32, "url": "https://localhost:3000/assets/icon-32.png" }, - { "size": 80, "url": "https://localhost:3000/assets/icon-80.png" } - ], - "supertip": { - "title": "Show Task Pane", - "description": "Opens a task pane." - }, - "actionId": "TaskPaneRuntimeShow" - }, - { - "id": "ActionButton", - "type": "button", - "label": "Perform an action", - "icons": [ - { "size": 16, "url": "https://localhost:3000/assets/icon-16.png" }, - { "size": 32, "url": "https://localhost:3000/assets/icon-32.png" }, - { "size": 80, "url": "https://localhost:3000/assets/icon-80.png" } - ], - "supertip": { - "title": "Perform an action", - "description": "Perform an action when clicked." - }, - "actionId": "action" - } - ] - } - ] - } - ] - } - ] - } - ] - } diff --git a/addin/manifest.xml b/addin/manifest.xml index 7005ecc..f6b5a5f 100644 --- a/addin/manifest.xml +++ b/addin/manifest.xml @@ -4,8 +4,8 @@ 1.0.0.0 Contoso en-US - - + + @@ -22,6 +22,8 @@ + + @@ -82,12 +84,12 @@ - - + + - + diff --git a/addin/src/config.ts b/addin/src/config.ts index 96b2a12..4b69761 100644 --- a/addin/src/config.ts +++ b/addin/src/config.ts @@ -5,12 +5,12 @@ */ export const config = { keycloak: { - /** เช่น http://localhost:8080/realms/word-versioning */ + /** เช่น https://localhost:8443/realms/word-versioning (ต้องเป็น HTTPS เสมอ — Office Dialog API ปฏิเสธ http) */ authority: __KEYCLOAK_AUTHORITY__, /** public client ของ add-in (PKCE S256 บังคับ) */ clientId: __KEYCLOAK_CLIENT_ID__, }, - /** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น http://localhost:5075 */ + /** base URL ของ backend API (ไม่มี slash ท้าย) — เช่น https://localhost:7232 (ต้องเป็น HTTPS — webview บล็อก http) */ apiBaseUrl: __API_BASE_URL__, /** redirect URI ที่ลงทะเบียนไว้กับ Keycloak — หน้าเล็กใน add-in เอง (รันใน Office Dialog) */ get redirectUri(): string { diff --git a/addin/src/services/apiClient.test.ts b/addin/src/services/apiClient.test.ts index f7ac6c4..23dea58 100644 --- a/addin/src/services/apiClient.test.ts +++ b/addin/src/services/apiClient.test.ts @@ -2,7 +2,7 @@ import * as authService from "./authService"; import { apiFetch, apiGetJson, AuthRequiredError, readErrorMessage } from "./apiClient"; import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; -const API_URL = "http://localhost:5075"; +const API_URL = "https://localhost:7232"; beforeEach(() => { authService.signOut(); diff --git a/addin/src/services/authService.test.ts b/addin/src/services/authService.test.ts index 6c9cc5b..2550f7a 100644 --- a/addin/src/services/authService.test.ts +++ b/addin/src/services/authService.test.ts @@ -2,7 +2,8 @@ import { createHash } from "crypto"; import * as authService from "./authService"; import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; -const TOKEN_ENDPOINT = "http://localhost:8080/realms/word-versioning/protocol/openid-connect/token"; +const TOKEN_ENDPOINT = + "https://localhost:8443/realms/word-versioning/protocol/openid-connect/token"; function base64Url(buffer: Buffer): string { return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); @@ -110,7 +111,7 @@ describe("buildAuthorizationUrl", () => { authService.buildAuthorizationUrl({ codeChallenge: "the-challenge", state: "the-state" }) ); - expect(url.origin).toBe("http://localhost:8080"); + expect(url.origin).toBe("https://localhost:8443"); expect(url.pathname).toBe("/realms/word-versioning/protocol/openid-connect/auth"); expect(url.searchParams.get("client_id")).toBe("word-addin-officejs"); expect(url.searchParams.get("redirect_uri")).toBe("http://localhost/auth-callback.html"); @@ -138,11 +139,11 @@ describe("decodeJwtExpiry", () => { describe("login (Office Dialog + token exchange)", () => { it("เต็ม flow: URL มี PKCE → dialog ส่ง code+state → แลก token ด้วย verifier ที่ pair กัน → isTokenValid", async () => { const office = installOfficeDialogMock(); - let exchangedBody: URLSearchParams | null = null; + const exchanged: { body?: URLSearchParams } = {}; const fetchMock = installFetchMock(); fetchMock.mockImplementation(async (input: unknown, init?: { body?: string }) => { expect(String(input)).toBe(TOKEN_ENDPOINT); - exchangedBody = new URLSearchParams(String(init?.body)); + exchanged.body = new URLSearchParams(String(init?.body)); return mockResponse({ status: 200, body: { @@ -169,10 +170,10 @@ describe("login (Office Dialog + token exchange)", () => { expect(office.dialog.close).toHaveBeenCalled(); // verifier ที่ใช้แลก token ต้อง hash ได้ = challenge ใน URL (pair กันจริง) - const verifier = exchangedBody?.get("code_verifier") ?? ""; - expect(exchangedBody?.get("grant_type")).toBe("authorization_code"); - expect(exchangedBody?.get("code")).toBe("the-auth-code"); - expect(exchangedBody?.get("client_id")).toBe("word-addin-officejs"); + const verifier = exchanged.body?.get("code_verifier") ?? ""; + expect(exchanged.body?.get("grant_type")).toBe("authorization_code"); + expect(exchanged.body?.get("code")).toBe("the-auth-code"); + expect(exchanged.body?.get("client_id")).toBe("word-addin-officejs"); expect(base64Url(createHash("sha256").update(verifier).digest())).toBe(challenge); }); diff --git a/addin/src/services/authService.ts b/addin/src/services/authService.ts index 187790b..209a54b 100644 --- a/addin/src/services/authService.ts +++ b/addin/src/services/authService.ts @@ -216,6 +216,32 @@ export function decodeJwtExpiry(token: string): number | null { } } +/** อ่านชื่อผู้ใช้จาก access token ปัจจุบัน (`preferred_username` แล้ว fallback `sub`) — ใช้ใส่ใน XML history */ +export function getUsername(): string | undefined { + if (!tokenState.accessToken) { + return undefined; + } + const parts = tokenState.accessToken.split("."); + if (parts.length !== 3) { + return undefined; + } + try { + const payload = JSON.parse(base64UrlDecode(parts[1])) as { + preferred_username?: unknown; + sub?: unknown; + }; + if (typeof payload.preferred_username === "string" && payload.preferred_username) { + return payload.preferred_username; + } + if (typeof payload.sub === "string" && payload.sub) { + return payload.sub; + } + return undefined; + } catch { + return undefined; + } +} + // --------------------------------------------------------------------------- // State transitions // --------------------------------------------------------------------------- diff --git a/addin/src/services/errors.ts b/addin/src/services/errors.ts new file mode 100644 index 0000000..7c78432 --- /dev/null +++ b/addin/src/services/errors.ts @@ -0,0 +1,10 @@ +/** ข้อความ error ที่อ่านได้สำหรับแสดงใน task pane — ไม่เคย throw (กติกา: ทุก Office.js/API call ต้อง catch) */ +export function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === "string" && error) { + return error; + } + return fallback; +} diff --git a/addin/src/services/fileService.test.ts b/addin/src/services/fileService.test.ts index 046d1b9..baef35a 100644 --- a/addin/src/services/fileService.test.ts +++ b/addin/src/services/fileService.test.ts @@ -9,10 +9,28 @@ interface FileMockState { /** mock Office.context.document.getFileAsync + File.getSliceAsync/closeAsync */ function installOfficeFileMock( slices: Uint8Array[], - options: { failSliceIndex?: number; failClose?: boolean; sliceDataForm?: "buffer" | "view" } = {} + options: { + failSliceIndex?: number; + failClose?: boolean; + /** รูปร่างของ slice.data ที่ host จริงส่งกลับ — ต่างกันตามแพลตฟอร์ม (Win=ArrayBuffer, Mac=string/array) */ + sliceDataForm?: "buffer" | "view" | "array" | "base64"; + } = {} ): FileMockState { const state: FileMockState = { getFileCalls: 0, requestedSlices: [], closeCalls: 0 }; + const toSliceData = (bytes: Uint8Array): unknown => { + switch (options.sliceDataForm) { + case "view": + return bytes; + case "array": + return Array.from(bytes); + case "base64": + return Buffer.from(bytes).toString("base64"); + default: + return bytes.buffer; + } + }; + const file = { sliceCount: slices.length, getSliceAsync: (index: number, callback: (result: unknown) => void) => { @@ -20,9 +38,10 @@ function installOfficeFileMock( if (options.failSliceIndex === index) { callback({ status: "failed", error: { message: `อ่าน slice ${index} พัง (mock)` } }); } else { - // host จริงอาจส่งมาเป็น ArrayBuffer หรือ view — ทดสอบทั้งคู่ - const data = options.sliceDataForm === "view" ? slices[index] : slices[index].buffer; - callback({ status: "succeeded", value: { index, data, size: slices[index].byteLength } }); + callback({ + status: "succeeded", + value: { index, data: toSliceData(slices[index]), size: slices[index].byteLength }, + }); } }, closeAsync: (callback?: (result: unknown) => void) => { @@ -81,6 +100,26 @@ describe("getCurrentDocumentFile", () => { expect(state.closeCalls).toBe(1); }); + it("slice มาเป็น byte array ธรรมดา number[] (Word บน Mac) ก็ประกอบได้", async () => { + const state = installOfficeFileMock([bytes(10, 20), bytes(30, 40)], { sliceDataForm: "array" }); + + const blob = await getCurrentDocumentFile(); + + expect(blob.size).toBe(4); + expect(state.closeCalls).toBe(1); + }); + + it("slice มาเป็น base64 string ก็ decode แล้วประกอบได้", async () => { + const state = installOfficeFileMock([bytes(200, 100, 50), bytes(255)], { + sliceDataForm: "base64", + }); + + const blob = await getCurrentDocumentFile(); + + expect(blob.size).toBe(4); + expect(state.closeCalls).toBe(1); + }); + it("อ่าน slice กลางที่พัง → reject พร้อม error แต่ยังเรียก closeAsync เสมอ", async () => { const state = installOfficeFileMock([bytes(1), bytes(2), bytes(3)], { failSliceIndex: 1 }); diff --git a/addin/src/services/fileService.ts b/addin/src/services/fileService.ts index f017c41..c1dcd55 100644 --- a/addin/src/services/fileService.ts +++ b/addin/src/services/fileService.ts @@ -35,20 +35,48 @@ function getFileAsync(): Promise { }); } -/** host ส่ง slice มาเป็น ArrayBuffer หรือ view — รับทั้งคู่แล้วคืนเป็น BlobPart ตรงๆ */ +/** + * host แต่ละตัวส่งข้อมูล slice มาคนละรูปแบบ (เอกสาร Office.js ระบบแค่ "byte array"): + * - Word บน Windows ส่วนใหญ่ = ArrayBuffer / typed-array view + * - Word บน Mac = base64 string หรือ byte array ธรรมดา (number[]) + * ปรับเป็น Uint8Array ให้หมดก่อนประกอบเป็น Blob + */ +export function sliceDataToBytes(data: unknown): Uint8Array { + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView; + return new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength); + } + if (typeof data === "string") { + // base64 (บาง host ใส่ data-url prefix หรือขึ้นบรรทัดมาด้วย — ตัดทิ้งก่อน decode) + const base64 = data.replace(/^data:[^,]*?;base64,/, "").replace(/\s/g, ""); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index++) { + bytes[index] = binary.charCodeAt(index); + } + return bytes; + } + if (Array.isArray(data) && (data.length === 0 || typeof data[0] === "number")) { + return Uint8Array.from(data); + } + throw new Error(`รูปแบบข้อมูลของ slice ไม่รองรับ (ได้รับ ${typeof data})`); +} + +/** อ่าน slice หนึ่งชิ้นแล้ว normalize เป็น Uint8Array */ function getSliceAsync(file: Office.File, sliceIndex: number): Promise { return new Promise((resolve, reject) => { try { file.getSliceAsync(sliceIndex, (result) => { if (result.status === Office.AsyncResultStatus.Succeeded) { - const data: unknown = result.value.data; - if (data instanceof ArrayBuffer) { - resolve(data); - } else if (ArrayBuffer.isView(data)) { - const view = data as ArrayBufferView; - resolve(new Uint8Array(view.buffer as ArrayBuffer, view.byteOffset, view.byteLength)); - } else { - reject(new Error("รูปแบบข้อมูลของ slice ไม่รองรับ (ต้องเป็น byte array)")); + try { + resolve(sliceDataToBytes(result.value.data)); + } catch (error) { + reject( + error instanceof Error ? error : new Error(`อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`) + ); } } else { reject(officeError(result, `อ่าน slice ที่ ${sliceIndex} ไม่สำเร็จ`)); diff --git a/addin/src/services/versionFlow.test.ts b/addin/src/services/versionFlow.test.ts new file mode 100644 index 0000000..9f49f24 --- /dev/null +++ b/addin/src/services/versionFlow.test.ts @@ -0,0 +1,216 @@ +import * as authService from "./authService"; +import { ADDIN_SIGNATURE_GUID, buildVersionHistoryXml } from "./metadataService"; +import { saveAndVersion, startVersioning } from "./versionFlow"; +import { installWordMock } from "../test/wordMock"; +import { installFetchMock, makeFakeJwt, mockResponse } from "../test/httpMock"; + +/** mock Office.context.document (url สำหรับชื่อไฟล์ + getFileAsync สำหรับ fileService) */ +function installOfficeDocumentMock(url: string | undefined): void { + const slice = new Uint8Array([1, 2, 3, 4]); + (globalThis as unknown as Record).Office = { + FileType: { Compressed: "compressed", Text: "text" }, + AsyncResultStatus: { Succeeded: "succeeded", Failed: "failed" }, + context: { + document: { + url, + getFileAsync: ( + _fileType: unknown, + _options: unknown, + callback: (result: unknown) => void + ) => { + callback({ + status: "succeeded", + value: { + sliceCount: 1, + getSliceAsync: (index: number, cb: (result: unknown) => void) => + cb({ status: "succeeded", value: { index, data: slice.buffer } }), + closeAsync: (cb?: (result: unknown) => void) => cb?.({ status: "succeeded" }), + }, + }); + }, + }, + }, + }; +} + +function seedSession(): void { + authService.setSessionForTest( + makeFakeJwt(Date.now() + 600_000, "user-sub", { preferred_username: "tester" }), + "refresh-1", + Date.now() + 600_000 + ); +} + +function parseUploadCall(call: [string, { body?: FormData }]): { url: URL; form: FormData } { + return { url: new URL(call[0]), form: call[1].body as FormData }; +} + +beforeEach(() => { + seedSession(); +}); + +afterEach(() => { + authService.signOut(); +}); + +describe("startVersioning (FR-1 / AC-5)", () => { + it("stamp รอบแรกครบ → save → upload v1 พร้อม fields + ชื่อไฟล์จาก URL → เขียน XML part v1", async () => { + const state = installWordMock({ + contentControls: [ + { tag: "employee_name", text: "สมชาย ใจดี" }, + { tag: "salary", text: "50000" }, + ], + }); + installOfficeDocumentMock("C:\\Users\\tester\\Documents\\สัญญาจ้าง.docx"); + const fetchMock = installFetchMock(); + fetchMock.mockImplementationOnce(async () => + mockResponse({ status: 201, body: { versionId: "backend-version-1", versionNumber: 1 } }) + ); + + const result = await startVersioning(); + + // ผลลัพธ์ผูกกันทั้งชุด + expect(result).toEqual({ + documentId: result.documentId, + versionId: "backend-version-1", + versionNumber: 1, + }); + expect(result.documentId).toMatch(/^[0-9a-f-]{36}$/i); + + // ครบทั้ง 4 properties + SavedByAddIn = GUID ของ add-in + expect(state.properties.SavedByAddIn).toBe(ADDIN_SIGNATURE_GUID); + expect(state.properties.DocumentTrackingId).toBe(result.documentId); + expect(state.properties.LastVersionId).toMatch(/^[0-9a-f-]{36}$/i); + expect(state.properties.LastVersionTimestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/); + // เรียก native save หนึ่งครั้ง (FR-2 ขั้นตอนที่ 4) + expect(state.saveCalls).toBe(1); + + // upload ไปที่ documentId ที่ generate + แนบ fields + clientVersionId + ชื่อไฟล์จาก URL + const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); + expect(`${url.origin}${url.pathname}`).toBe( + `https://localhost:7232/api/documents/${result.documentId}/versions` + ); + expect(String(form.get("clientVersionId"))).toBe(state.properties.LastVersionId); + expect(JSON.parse(String(form.get("fields")))).toEqual({ + employee_name: "สมชาย ใจดี", + salary: "50000", + }); + expect(String((form.get("file") as File).name)).toBe("สัญญาจ้าง.docx"); + + // XML part มี entry v1 ผูกกับ clientVersionId เดียวกับ LastVersionId + expect(state.xmlParts).toHaveLength(1); + expect(state.xmlParts[0].xml).toContain('id="'); + expect(state.xmlParts[0].xml).toContain('number="1"'); + expect(state.xmlParts[0].xml).toContain('user="tester"'); + }); + + it("URL ไม่มี (Word Online) → ชื่อไฟล์ fallback", async () => { + installWordMock({}); + installOfficeDocumentMock(undefined); + const fetchMock = installFetchMock(); + fetchMock.mockImplementationOnce(async () => + mockResponse({ status: 201, body: { versionId: "v", versionNumber: 1 } }) + ); + + await startVersioning(); + + const { form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); + expect(String((form.get("file") as File).name)).toMatch(/\.docx$/); + }); +}); + +describe("saveAndVersion (FR-2 / AC-7, 13)", () => { + it("stamp → append XML entry (เดาเลขถัดไป) → save → upload พร้อม fields ล่าสุด", async () => { + const state = installWordMock({ + properties: { + SavedByAddIn: ADDIN_SIGNATURE_GUID, + DocumentTrackingId: "11111111-1111-1111-1111-111111111111", + LastVersionId: "old-client-version-id", + }, + xmlParts: [ + buildVersionHistoryXml([ + { + id: "old-client-version-id", + number: 1, + timestamp: "2026-08-19T09:00:00Z", + user: "tester", + }, + ]), + ], + contentControls: [{ tag: "salary", text: "60000" }], + }); + installOfficeDocumentMock(undefined); + const fetchMock = installFetchMock(); + fetchMock.mockImplementationOnce(async () => + mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } }) + ); + + const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1); + + expect(result).toEqual({ versionId: "backend-version-2", versionNumber: 2 }); + + // properties: tracking id คงเดิม, LastVersionId เปลี่ยนเป็น clientVersionId ใหม่ + expect(state.properties.DocumentTrackingId).toBe("11111111-1111-1111-1111-111111111111"); + const newLastVersionId = state.properties.LastVersionId; + expect(newLastVersionId).not.toBe("old-client-version-id"); + expect(state.saveCalls).toBe(1); + + // XML part: 2 entries เรียง v2 ก่อน v1 + expect(state.xmlParts).toHaveLength(1); + const xml = state.xmlParts[0].xml; + expect(xml).toContain('number="2"'); + expect(xml).toContain('number="1"'); + expect(xml.indexOf('number="2"')).toBeLessThan(xml.indexOf('number="1"')); + + // upload ไปยัง document เดิม + fields ล่าสุด + const { url, form } = parseUploadCall(fetchMock.mock.calls[0] as [string, { body?: FormData }]); + expect(url.pathname).toBe("/api/documents/11111111-1111-1111-1111-111111111111/versions"); + expect(String(form.get("clientVersionId"))).toBe(newLastVersionId); + expect(JSON.parse(String(form.get("fields")))).toEqual({ salary: "60000" }); + }); + + it("backend ตอบเลข version ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรงเลขจริง", async () => { + const state = installWordMock({ + properties: { + SavedByAddIn: ADDIN_SIGNATURE_GUID, + DocumentTrackingId: "11111111-1111-1111-1111-111111111111", + LastVersionId: "old", + }, + xmlParts: [buildVersionHistoryXml([{ id: "old", number: 1, timestamp: "t", user: "u" }])], + }); + installOfficeDocumentMock(undefined); + const fetchMock = installFetchMock(); + fetchMock.mockImplementationOnce(async () => + mockResponse({ status: 201, body: { versionId: "backend-version-5", versionNumber: 5 } }) + ); + + const result = await saveAndVersion("11111111-1111-1111-1111-111111111111", 1); + + expect(result.versionNumber).toBe(5); + // entry ใหม่ถูกแก้จาก 2 (ที่เดา) เป็น 5 (ของจริง) + const xml = state.xmlParts[0].xml; + expect(xml).toContain('number="5"'); + expect(xml).not.toContain('number="2"'); + expect(xml).toContain('number="1"'); + }); + + it("XML part เขียนไม่ได้ (known bug บน Mac) → flow ยังสำเร็จต่อ (AC-10)", async () => { + installWordMock({ + properties: { + SavedByAddIn: ADDIN_SIGNATURE_GUID, + DocumentTrackingId: "11111111-1111-1111-1111-111111111111", + }, + failXmlAdd: true, + }); + installOfficeDocumentMock(undefined); + const fetchMock = installFetchMock(); + fetchMock.mockImplementationOnce(async () => + mockResponse({ status: 201, body: { versionId: "backend-version-2", versionNumber: 2 } }) + ); + + await expect(saveAndVersion("11111111-1111-1111-1111-111111111111", 1)).resolves.toEqual({ + versionId: "backend-version-2", + versionNumber: 2, + }); + }); +}); diff --git a/addin/src/services/versionFlow.ts b/addin/src/services/versionFlow.ts new file mode 100644 index 0000000..b7faf78 --- /dev/null +++ b/addin/src/services/versionFlow.ts @@ -0,0 +1,159 @@ +/* global Word, Office */ + +import * as authService from "./authService"; +import { getCurrentDocumentFile } from "./fileService"; +import { + generateGuid, + readContentControlFields, + readVersionHistoryFromXml, + stampTrackedProperties, + writeVersionHistoryToXml, + XmlVersionEntry, +} from "./metadataService"; +import { uploadVersion } from "./versioningService"; + +/** + * Orchestration ของ flow หลักสองอันของ add-in (แยกจาก UI เพื่อ unit test ได้): + * + * - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track + * - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด: + * stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save() + * → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history + * + * หมายเหตุค่า id ใน metadata: `LastVersionId` และ `` ใน XML part เก็บ + * **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload — + * versionId ของ backend จะได้มาหลัง upload ซึ่งไฟล์ถูก capture ไปแล้ว + * (backend คือ source of truth ของ id จริงอยู่ดี) + */ + +export interface StartVersioningResult { + documentId: string; + versionId: string; + versionNumber: number; +} + +export interface SaveAndVersionResult { + versionId: string; + versionNumber: number; +} + +/** เลข version ถัดไปตามที่ client เดา (history ล่าสุด + 1) — ใช้เขียน XML part ก่อนรู้ค่าจริงจาก backend */ +function predictNextVersionNumber(latestVersionNumber: number): number { + return latestVersionNumber + 1; +} + +/** ชื่อไฟล์จาก URL ของ document ถ้าดึงได้ (desktop มักเป็น path จริง) — ใช้เป็นชื่อตอน upload */ +function guessDocumentFileName(): string | undefined { + try { + const url = Office.context.document.url; + if (!url) { + return undefined; + } + const name = url.split(/[\\/]/).pop(); + if (!name) { + return undefined; + } + return name.toLowerCase().endsWith(".docx") ? name : `${name}.docx`; + } catch { + return undefined; + } +} + +/** `context.document.save(Word.SaveBehavior.save)` — trigger native save ตาม FR-2 ขั้นตอนที่ 4 */ +async function saveDocument(): Promise { + await Word.run(async (context) => { + context.document.save(Word.SaveBehavior.save); + await context.sync(); + }); +} + +async function uploadCurrentFile( + documentId: string, + clientVersionId: string, + fields: Record +): Promise<{ versionId: string; versionNumber: number }> { + const file = await getCurrentDocumentFile(); + return uploadVersion(documentId, file, clientVersionId, fields, guessDocumentFileName()); +} + +/** FR-1 — เริ่ม track เอกสารปัจจุบัน: stamp รอบแรก + upload เป็น version 1 */ +export async function startVersioning( + onProgress?: (message: string) => void +): Promise { + const documentId = generateGuid(); + const clientVersionId = generateGuid(); + const timestamp = new Date().toISOString(); + + // 1) stamp รอบแรก — ได้ทั้ง SavedByAddIn และ DocumentTrackingId ใหม่ + onProgress?.("กำลังบันทึก metadata ของเอกสาร…"); + await stampTrackedProperties({ + documentTrackingId: documentId, + lastVersionId: clientVersionId, + lastVersionTimestamp: timestamp, + }); + + // 2) XML part (secondary — fail ได้เงียบๆ) + onProgress?.("กำลังอัปเดต version history ในไฟล์…"); + await writeVersionHistoryToXml([ + { id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" }, + ]); + + // 3) native save แล้ว capture ไฟล์ + fields + onProgress?.("กำลังบันทึกไฟล์…"); + await saveDocument(); + onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); + const fields = await readContentControlFields(); + + // 4) upload version แรก + onProgress?.("กำลังอัปโหลด version 1…"); + const result = await uploadCurrentFile(documentId, clientVersionId, fields); + return { documentId, ...result }; +} + +/** FR-2 — Save & Version ของเอกสารที่ track อยู่แล้ว (เรียกหลังผู้ใช้กดยืนยันใน dialog แล้ว) */ +export async function saveAndVersion( + documentId: string, + latestVersionNumber: number, + onProgress?: (message: string) => void +): Promise { + const clientVersionId = generateGuid(); + const timestamp = new Date().toISOString(); + const predictedNumber = predictNextVersionNumber(latestVersionNumber); + const user = authService.getUsername() ?? "unknown"; + + // 1) stamp properties (SavedByAddIn + DocumentTrackingId ถูก stamp ไว้แล้วจากรอบแรก — เขียนทับไม่เสียหาย) + onProgress?.("กำลังบันทึก metadata ของ version ใหม่…"); + await stampTrackedProperties({ + lastVersionId: clientVersionId, + lastVersionTimestamp: timestamp, + }); + + // 2) XML part: เพิ่ม entry ของ version ใหม่ไว้ต้นสุด=ใหม่→เก่า ตามรูปใน spec (เดาเลขก่อน upload) — fail ได้เงียบๆ + onProgress?.("กำลังอัปเดต version history ในไฟล์…"); + const entries = await readVersionHistoryFromXml(); + entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user }); + await writeVersionHistoryToXml(entries); + + // 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11) + onProgress?.("กำลังบันทึกไฟล์…"); + await saveDocument(); + onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); + const fields = await readContentControlFields(); + + // 4) upload + onProgress?.("กำลังอัปโหลด version ใหม่…"); + const result = await uploadCurrentFile(documentId, clientVersionId, fields); + + // 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort) + if (result.versionNumber !== predictedNumber) { + const corrected = await readVersionHistoryFromXml(); + const entry = corrected.find((item: XmlVersionEntry) => item.id === clientVersionId); + if (entry) { + entry.number = result.versionNumber; + corrected.sort((a, b) => b.number - a.number); // คงลำดับใหม่→เก่าหลังแก้เลข + await writeVersionHistoryToXml(corrected); + } + } + + return result; +} diff --git a/addin/src/services/versioningService.ts b/addin/src/services/versioningService.ts index feea35b..16e3e4b 100644 --- a/addin/src/services/versioningService.ts +++ b/addin/src/services/versioningService.ts @@ -42,6 +42,26 @@ export function buildDownloadUrl(documentId: string, versionId: string): string return `${config.apiBaseUrl}/api/documents/${documentId}/versions/${versionId}/download`; } +export interface VersionDownloadLink { + url: string; + /** ISO-8601 เวลาที่ URL หมดอายุ */ + expiresAt: string; +} + +/** + * FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้ + * (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน) + */ +export async function getDownloadLink( + documentId: string, + versionId: string +): Promise { + return apiGetJson( + `/api/documents/${documentId}/versions/${versionId}/download-link`, + "ขอลิงก์ดาวน์โหลดไม่สำเร็จ" + ); +} + /** FR-7 — ดาวน์โหลดไฟล์ของ version หนึ่งเป็น Blob (ต้องแนบ Bearer จึงต้อง fetch แทน link ตรง) */ export async function downloadVersion(documentId: string, versionId: string): Promise { const response = await apiFetch(buildDownloadUrl(documentId, versionId)); diff --git a/addin/src/taskpane/components/App.tsx b/addin/src/taskpane/components/App.tsx index c875174..8403129 100644 --- a/addin/src/taskpane/components/App.tsx +++ b/addin/src/taskpane/components/App.tsx @@ -1,45 +1,430 @@ -import * as React from "react"; -import Header from "./Header"; -import HeroList, { HeroListItem } from "./HeroList"; -import TextInsertion from "./TextInsertion"; -import { makeStyles } from "@fluentui/react-components"; -import { Ribbon24Regular, LockOpen24Regular, DesignIdeas24Regular } from "@fluentui/react-icons"; -import { insertText } from "../taskpane"; +/* global Word, Office */ -interface AppProps { - title: string; -} +import * as React from "react"; +import { + Button, + MessageBar, + MessageBarBody, + MessageBarActions, + Spinner, + Text, + makeStyles, + tokens, +} from "@fluentui/react-components"; +import { DocumentArrowUp24Regular, Save24Regular, SignOut24Regular } from "@fluentui/react-icons"; +import * as authService from "../../services/authService"; +import { AuthRequiredError } from "../../services/apiClient"; +import { getErrorMessage } from "../../services/errors"; +import * as metadataService from "../../services/metadataService"; +import { downloadVersion, getDownloadLink, getVersions } from "../../services/versioningService"; +import { saveAndVersion, startVersioning } from "../../services/versionFlow"; +import { VersionRecord } from "../../models/versionRecord"; +import ConfirmVersionDialog from "./ConfirmVersionDialog"; +import VersionHistoryPanel from "./VersionHistoryPanel"; + +/** + * Task pane หลัก — state machine ตาม Phase 5: + * checking → (ไม่มี token) signed-out + * → (มี token + ไม่ใช่ tracked document) untracked + * → (มี token + tracked document) tracking + * + * กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9) + * error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection + */ + +type Phase = "checking" | "signed-out" | "untracked" | "tracking"; + +/** FR-3 — ถามเอกสารทุก 30 วินาทีว่ามีแก้ไขที่ยังไม่ได้ save ไหม */ +const UNSAVED_CHECK_INTERVAL_MS = 30_000; const useStyles = makeStyles({ root: { + display: "flex", + flexDirection: "column", minHeight: "100vh", + padding: tokens.spacingVerticalM, + gap: tokens.spacingVerticalS, + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingBottom: tokens.spacingVerticalS, + borderBottom: `1px solid ${tokens.colorNeutralStroke1}`, + }, + section: { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: tokens.spacingVerticalS, + paddingTop: tokens.spacingVerticalM, + }, + checking: { + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: tokens.spacingVerticalM, + paddingTop: tokens.spacingVerticalXL, }, }); -const App: React.FC = (props: AppProps) => { +const App: React.FC = () => { const styles = useStyles(); - // The list items are static and won't change at runtime, - // so this should be an ordinary const, not a part of state. - const listItems: HeroListItem[] = [ - { - icon: , - primaryText: "Achieve more with Office integration", + + const [phase, setPhase] = React.useState("checking"); + const [bootError, setBootError] = React.useState(null); + const [username, setUsername] = React.useState(null); + + const [documentId, setDocumentId] = React.useState(null); + const [versions, setVersions] = React.useState([]); + const [historyLoading, setHistoryLoading] = React.useState(false); + const [historyError, setHistoryError] = React.useState(null); + + const [signingIn, setSigningIn] = React.useState(false); + const [loginError, setLoginError] = React.useState(null); + + const [starting, setStarting] = React.useState(false); + const [saving, setSaving] = React.useState(false); + const [progressMessage, setProgressMessage] = React.useState(null); + const [confirmOpen, setConfirmOpen] = React.useState(false); + const [flowError, setFlowError] = React.useState(null); + const [flowSuccess, setFlowSuccess] = React.useState(null); + + const [downloadingVersionId, setDownloadingVersionId] = React.useState(null); + const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false); + + /** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */ + const handleAuthRequired = React.useCallback((error: unknown): boolean => { + if (error instanceof AuthRequiredError) { + setPhase("signed-out"); + setDocumentId(null); + setVersions([]); + setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่"); + return true; + } + return false; + }, []); + + const refreshHistory = React.useCallback( + async (id: string) => { + setHistoryLoading(true); + setHistoryError(null); + try { + setVersions(await getVersions(id)); + } catch (error) { + if (!handleAuthRequired(error)) { + setHistoryError(getErrorMessage(error, "โหลด version history ไม่สำเร็จ")); + } + } finally { + setHistoryLoading(false); + } }, - { - icon: , - primaryText: "Unlock features and functionality", - }, - { - icon: , - primaryText: "Create and visualize like a pro", - }, - ]; + [handleAuthRequired] + ); + + /** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history */ + const detect = React.useCallback(async () => { + setPhase("checking"); + setBootError(null); + try { + const token = await authService.getAccessToken(); + if (!token) { + setPhase("signed-out"); + return; + } + authService.startAutoRefresh(); + setUsername(authService.getUsername() ?? null); + + const tracked = await metadataService.isTrackedDocument(); + if (!tracked) { + setPhase("untracked"); + return; + } + const properties = await metadataService.readTrackedProperties(); + const id = properties.documentTrackingId; + if (!id) { + // mark ว่า track แต่ไม่มี id (ไฟล์เสียหาย) — ให้เริ่มใหม่แบบ untracked + setPhase("untracked"); + return; + } + setDocumentId(id); + setPhase("tracking"); + await refreshHistory(id); + } catch (error) { + setBootError(getErrorMessage(error, "ตรวจสอบเอกสารไม่สำเร็จ")); + } + }, [refreshHistory]); + + React.useEffect(() => { + void detect(); + }, [detect]); + + /** FR-3 — poll `document.saved` เฉพาะตอน tracking */ + React.useEffect(() => { + if (phase !== "tracking") { + setHasUnsavedChanges(false); + return; + } + const interval = setInterval(() => { + void (async () => { + try { + const unsaved = await Word.run(async (context) => { + const document = context.document; + document.load("saved"); + await context.sync(); + return !document.saved; + }); + setHasUnsavedChanges(unsaved); + } catch { + // อ่านไม่ได้รอบนี้ก็ข้าม — banner เป็นแค่ soft reminder + } + })(); + }, UNSAVED_CHECK_INTERVAL_MS); + return () => clearInterval(interval); + }, [phase]); + + const handleSignIn = async () => { + setSigningIn(true); + setLoginError(null); + try { + const result = await authService.login(); + if (result.ok) { + await detect(); + } else { + setLoginError(result.error); + } + } catch (error) { + setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ")); + } finally { + setSigningIn(false); + } + }; + + const handleSignOut = () => { + authService.signOut(); + setUsername(null); + setDocumentId(null); + setVersions([]); + setLoginError(null); + setPhase("signed-out"); + }; + + /** Start Versioning — stamp รอบแรก + upload v1 (FR-1/AC-5) */ + const handleStartVersioning = async () => { + setStarting(true); + setFlowError(null); + setFlowSuccess(null); + try { + const result = await startVersioning(setProgressMessage); + setDocumentId(result.documentId); + setPhase("tracking"); + setFlowSuccess(`เริ่มติดตามเอกสารนี้แล้ว — สร้างเป็น version 1`); + await refreshHistory(result.documentId); + } catch (error) { + if (!handleAuthRequired(error)) { + setFlowError(getErrorMessage(error, "เริ่มติดตามเอกสารไม่สำเร็จ")); + } + } finally { + setProgressMessage(null); + setStarting(false); + } + }; + + /** Save & Version (FR-2) — ตามลำดับ spec: confirm → stamp → XML → save → file+fields → upload */ + const handleSaveAndVersion = async () => { + if (!documentId) { + return; + } + setSaving(true); + setFlowError(null); + setFlowSuccess(null); + try { + const latestVersionNumber = versions[0]?.versionNumber ?? 0; + const result = await saveAndVersion(documentId, latestVersionNumber, setProgressMessage); + setFlowSuccess(`สร้าง version ${result.versionNumber} เรียบร้อย`); + await refreshHistory(documentId); + } catch (error) { + if (!handleAuthRequired(error)) { + setFlowError(getErrorMessage(error, "สร้าง version ไม่สำเร็จ")); + } + } finally { + setProgressMessage(null); + setSaving(false); + } + }; + + /** + * FR-4 — ดาวน์โหลด version ที่เลือก: ขอ presigned URL อายุสั้นจาก backend แล้วเปิดใน + * เบราว์เซอร์ของเครื่อง (webview ของ Word — โดยเฉพาะ WKWebView บน Mac — ทิ้ง blob download + * แบบเงียบๆ ไฟล์ไม่เคยถูกเขียนที่ไหนเลย) · host ไหนไม่มี openBrowserWindow จะย้อนกลับไปใช้ blob + */ + const handleDownload = async (version: VersionRecord) => { + if (!documentId) { + return; + } + setDownloadingVersionId(version.versionId); + setFlowError(null); + try { + const link = await getDownloadLink(documentId, version.versionId); + if (typeof Office.context.ui.openBrowserWindow === "function") { + Office.context.ui.openBrowserWindow(link.url); + setFlowSuccess(`กำลังดาวน์โหลด v${version.versionNumber} ผ่านเบราว์เซอร์ของคุณ — ดูไฟล์ที่โฟลเดอร์ Downloads`); + } else { + const blob = await downloadVersion(documentId, version.versionId); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = `document_v${version.versionNumber}.docx`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 60_000); + } + } catch (error) { + if (!handleAuthRequired(error)) { + setFlowError(getErrorMessage(error, "ดาวน์โหลด version ไม่สำเร็จ")); + } + } finally { + setDownloadingVersionId(null); + } + }; return (
-
- - +
+ e-Court Documents Versioning + {phase !== "signed-out" && phase !== "checking" && username ? ( + + ) : null} +
+ + {phase === "checking" ? ( +
+ + {bootError ? ( + + {bootError} + + + + + ) : null} +
+ ) : null} + + {phase === "signed-out" ? ( +
+ เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning + + ใช้บัญชีของ realm word-versioning — หน้าต่าง login จะเปิดแยกต่างหาก + + + {loginError ? ( + + {loginError} + + ) : null} +
+ ) : null} + + {phase === "untracked" ? ( +
+ เอกสารนี้ยังไม่ได้ติดตาม version + + กดปุ่มด้านล่างเพื่อเริ่ม track — ระบบจะ stamp metadata ลงไฟล์และอัปโหลดเป็น version 1 + (ไฟล์ที่สร้างจากเว็บ portal จะถูกตรวจพบและข้ามขั้นตอนนี้อัตโนมัติ) + + + {progressMessage ? ( +
+ + {progressMessage} +
+ ) : null} + {flowError ? ( + + {flowError} + + ) : null} +
+ ) : null} + + {phase === "tracking" ? ( +
+ {hasUnsavedChanges ? ( + + + มีการแก้ไขที่ยังไม่ได้ version — กด Save & Version เพื่อบันทึกเป็น version ใหม่ + + + ) : null} + + {flowSuccess ? ( + + {flowSuccess} + + ) : null} + {flowError ? ( + + {flowError} + + ) : null} + + + {saving && progressMessage ? ( +
+ + {progressMessage} +
+ ) : null} + + + tracking id: {documentId?.slice(0, 8)}… + + + void handleDownload(version)} + onRetry={() => documentId && void refreshHistory(documentId)} + /> +
+ ) : null} + + void handleSaveAndVersion()} + onDismiss={() => setConfirmOpen(false)} + />
); }; diff --git a/addin/src/taskpane/components/ConfirmVersionDialog.tsx b/addin/src/taskpane/components/ConfirmVersionDialog.tsx new file mode 100644 index 0000000..79c8cbf --- /dev/null +++ b/addin/src/taskpane/components/ConfirmVersionDialog.tsx @@ -0,0 +1,55 @@ +import * as React from "react"; +import { + Button, + Dialog, + DialogActions, + DialogBody, + DialogContent, + DialogSurface, + DialogTitle, + Spinner, +} from "@fluentui/react-components"; + +interface ConfirmVersionDialogProps { + open: boolean; + /** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่นทั้งสอง disable + แสดง spinner */ + busy: boolean; + onConfirm: () => void; + onDismiss: () => void; +} + +/** FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" */ +export const ConfirmVersionDialog: React.FC = ({ + open, + busy, + onConfirm, + onDismiss, +}) => { + return ( + !data.open && !busy && onDismiss()} modalType="alert"> + + + สร้าง version ใหม่? + + ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้ + {busy ? ( +
+ กำลังดำเนินการ… +
+ ) : null} +
+ + + + +
+
+
+ ); +}; + +export default ConfirmVersionDialog; diff --git a/addin/src/taskpane/components/Header.tsx b/addin/src/taskpane/components/Header.tsx deleted file mode 100644 index f3d6c59..0000000 --- a/addin/src/taskpane/components/Header.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import * as React from "react"; -import { Image, tokens, makeStyles } from "@fluentui/react-components"; - -export interface HeaderProps { - title: string; - logo: string; - message: string; -} - -const useStyles = makeStyles({ - welcome__header: { - display: "flex", - flexDirection: "column", - alignItems: "center", - paddingBottom: "30px", - paddingTop: "100px", - backgroundColor: tokens.colorNeutralBackground3, - }, - message: { - fontSize: tokens.fontSizeHero900, - fontWeight: tokens.fontWeightRegular, - fontColor: tokens.colorNeutralBackgroundStatic, - }, -}); - -const Header: React.FC = (props: HeaderProps) => { - const { title, logo, message } = props; - const styles = useStyles(); - - return ( -
- {title} -

{message}

-
- ); -}; - -export default Header; diff --git a/addin/src/taskpane/components/HeroList.tsx b/addin/src/taskpane/components/HeroList.tsx deleted file mode 100644 index 46caee3..0000000 --- a/addin/src/taskpane/components/HeroList.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import * as React from "react"; -import { tokens, makeStyles } from "@fluentui/react-components"; - -export interface HeroListItem { - icon: React.JSX.Element; - primaryText: string; -} - -export interface HeroListProps { - message: string; - items: HeroListItem[]; -} - -const useStyles = makeStyles({ - list: { - marginTop: "20px", - }, - listItem: { - paddingBottom: "20px", - display: "flex", - }, - icon: { - marginRight: "10px", - }, - itemText: { - fontSize: tokens.fontSizeBase300, - fontColor: tokens.colorNeutralBackgroundStatic, - }, - welcome__main: { - width: "100%", - display: "flex", - flexDirection: "column", - alignItems: "center", - }, - message: { - fontSize: tokens.fontSizeBase500, - fontColor: tokens.colorNeutralBackgroundStatic, - fontWeight: tokens.fontWeightRegular, - paddingLeft: "10px", - paddingRight: "10px", - }, -}); - -const HeroList: React.FC = (props: HeroListProps) => { - const { items, message } = props; - const styles = useStyles(); - - const listItems = items.map((item, index) => ( -
  • - {item.icon} - {item.primaryText} -
  • - )); - return ( -
    -

    {message}

    -
      {listItems}
    -
    - ); -}; - -export default HeroList; diff --git a/addin/src/taskpane/components/TextInsertion.tsx b/addin/src/taskpane/components/TextInsertion.tsx deleted file mode 100644 index abcf80b..0000000 --- a/addin/src/taskpane/components/TextInsertion.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import * as React from "react"; -import { useState } from "react"; -import { Button, Field, Textarea, tokens, makeStyles } from "@fluentui/react-components"; - -/* global HTMLTextAreaElement */ - -interface TextInsertionProps { - insertText: (_text: string) => void; -} - -const useStyles = makeStyles({ - instructions: { - fontWeight: tokens.fontWeightSemibold, - marginTop: "20px", - marginBottom: "10px", - }, - textPromptAndInsertion: { - display: "flex", - flexDirection: "column", - alignItems: "center", - }, - textAreaField: { - marginLeft: "20px", - marginTop: "30px", - marginBottom: "20px", - marginRight: "20px", - maxWidth: "50%", - }, -}); - -const TextInsertion: React.FC = (props: TextInsertionProps) => { - const [text, setText] = useState("Some text."); - - const handleTextInsertion = async () => { - await props.insertText(text); - }; - - const handleTextChange = async (event: React.ChangeEvent) => { - setText(event.target.value); - }; - - const styles = useStyles(); - - return ( -
    - -