Phase 10: field editor (FR-14)

เพิ่ม editor ต่อ template field — ผู้ดูแลเลือกรูปแบบช่องกรอกในฟอร์มสร้างเอกสารได้:
text → textbox/textarea, number/date → control ตาม type (presentation metadata
ล้วน — validation ยังยึด Type, ค่า field เป็น string ทั้ง flow, ไม่แตะ generate/sync/add-in)

- Backend: TemplateField.Editor (nullable, null = default ของ type) +
  migration AddTemplateFieldEditor + validate ตาม type ใน PUT templates
  (ค่าไม่รู้จัก/ไม่เข้ากับ type → 400, normalize-on-write, API คืน resolved เสมอ)
- Web: util fieldEditor.ts + editor select หน้าแก้ template (desktop+mobile,
  ซ่อนเมื่อ type มีทางเดียว) + ฟอร์มสร้างเอกสาร render ตาม editor +
  แก้ prefill re-generate ของ field date จาก ISO timestamp เต็ม (input[type=date]
  รับเฉพาะ yyyy-MM-dd เดิมแสดงว่าง)
- Docs: spec v6 (FR-14/AC-17) + plan Phase 10 + sub-plan field-editor.md +
  acceptance-review AC-17 + README/CLAUDE.md
- Tests: backend 60/60 (FieldEditorTests ใหม่ 8 กรณี) · web 46/46 + build ผ่าน

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-08-22 09:17:15 +07:00
parent 7b20f12ede
commit 9bb4a48c00
23 changed files with 865 additions and 45 deletions

View file

@ -14,13 +14,13 @@ 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–9 เสร็จ** (implement ครบ + tests ครบชุด: addin 50 / web 36 / backend integration 52 — รันจริงบน PostgreSQL+MinIO+Keycloak ผ่าน Testcontainers + ไล่ AC 1–16 ครบที่ `docs/acceptance-review.md`) — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง (ดูรายการท้าย acceptance-review.md)
**สถานะปัจจุบัน: Phase 0–10 เสร็จ** (implement ครบ + tests ครบชุด: addin 50 / web 46 / backend integration 60 — รันจริงบน PostgreSQL+MinIO+Keycloak ผ่าน Testcontainers + ไล่ AC 1–17 ครบที่ `docs/acceptance-review.md`) — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง (ดูรายการท้าย acceptance-review.md)
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–13, acceptance criteria 1–16, data models, platform constraints)
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 10 phases (0–9) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
- `docs/acceptance-review.md` — ผลตรวจ AC 1–16 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–14, acceptance criteria 1–17, data models, platform constraints)
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 11 phases (0–10) พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
- `docs/acceptance-review.md` — ผลตรวจ AC 1–17 พร้อมหลักฐาน (test ไหนคุ้มข้อไหน) + รายการที่เหลือต้องยืนยันด้วยมือ
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
@ -55,7 +55,9 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
**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 เก่ายังอ้างอยู่
**Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/editor/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่
**Field editor (FR-14):** `TemplateField.Editor` (nullable, null = default ของ type: text→textbox, number→number, date→date; text เลือก `textarea` ได้) — **presentation metadata ล้วน**: คุมแค่ control ที่ฟอร์มสร้างเอกสาร render (`web/app/utils/fieldEditor.ts`), validation ของค่ายังยึด `Type`, ค่า field เป็น string ทั้ง flow ไม่แตะ generate/sync/docx/add-in · ค่า = default → DB เก็บ null แต่ API คืน resolved เสมอ
## คำสั่ง (ใช้ได้หลัง scaffold ใน Phase 0)
@ -71,7 +73,7 @@ dotnet ef migrations add <Name>
# backend tests (integration — ต้องมี Docker รันอยู่: Testcontainers ยก Postgres+MinIO+Keycloak เองทุก run ~1 นาที)
cd backend
dotnet test # slnx ครอบทั้ง API + tests (52 tests)
dotnet test # slnx ครอบทั้ง API + tests (60 tests)
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว
# add-in (https://localhost:3000)

View file

@ -46,15 +46,17 @@
| 8 | ไล่ acceptance criteria เต็มชุด | ✅ เสร็จ — ผลครบ AC-1 ถึง 15 ที่ [docs/acceptance-review.md](docs/acceptance-review.md) |
| — | Web Portal redesign — design system (token layer สี/ฟอนต์/radius) + layout responsive ทุกหน้า | ✅ เสร็จ (นอก spec — แผนที่ [docs/plans/web-portal-redesign.md](docs/plans/web-portal-redesign.md)) |
| 9 | Version message — commit message ต่อ version ทุก flow (FR-13) | ✅ เสร็จ (เพิ่มทีหลัง v5 — แผนที่ [docs/plans/version-message.md](docs/plans/version-message.md)) |
| 10 | Field editor — เลือกรูปแบบช่องกรอกต่อ field (FR-14) | ✅ เสร็จ (เพิ่มทีหลัง v6 — แผนที่ [docs/plans/field-editor.md](docs/plans/field-editor.md)) |
**ใช้ได้แล้วตอนนี้:** ครบทั้ง 3 ส่วน — backend API ครบทุก endpoint (version + template + generate + field sync + รายการเอกสาร/detail + commit message), add-in ครบทั้ง services และ task pane UI ทดสอบจริงบน Word for Mac แล้ว, web portal ใช้งานได้ทุกหน้า (login → จัดการ template → สร้างเอกสาร → ดูรายการ/detail → re-generate) บน design system + responsive layout ใหม่ · **tests ครบชุด 138 ตัว** (backend integration 52 รันบน infra จริง + addin 50 + web 36) · ไล่ AC 1–16 ครบแล้ว — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้ (Word Online/Windows, click-through บน browser จริง — ดูท้าย [acceptance-review.md](docs/acceptance-review.md))
**ใช้ได้แล้วตอนนี้:** ครบทั้ง 3 ส่วน — backend API ครบทุก endpoint (version + template + generate + field sync + รายการเอกสาร/detail + commit message + field editor), add-in ครบทั้ง services และ task pane UI ทดสอบจริงบน Word for Mac แล้ว, web portal ใช้งานได้ทุกหน้า (login → จัดการ template → สร้างเอกสาร → ดูรายการ/detail → re-generate) บน design system + responsive layout ใหม่ · **tests ครบชุด 156 ตัว** (backend integration 60 รันบน infra จริง + addin 50 + web 46) · ไล่ AC 1–17 ครบแล้ว — เหลือยืนยันด้วยมือเฉพาะงานที่ทำแทนไม่ได้ (Word Online/Windows, click-through บน browser จริง — ดูท้าย [acceptance-review.md](docs/acceptance-review.md))
## เอกสาร
- [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–13), data models, acceptance criteria (1–16)
- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 10 phases (0–9) พร้อม definition of done
- [docs/acceptance-review.md](docs/acceptance-review.md) — ผลตรวจ acceptance criteria ทั้ง 16 ข้อ พร้อมปัญหาที่เจอและรายการที่เหลือต้องยืนยันด้วยมือ
- [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–14), data models, acceptance criteria (1–17)
- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 11 phases (0–10) พร้อม definition of done
- [docs/acceptance-review.md](docs/acceptance-review.md) — ผลตรวจ acceptance criteria ทั้ง 17 ข้อ พร้อมปัญหาที่เจอและรายการที่เหลือต้องยืนยันด้วยมือ
- [docs/plans/version-message.md](docs/plans/version-message.md) — แผน Phase 9: commit message ต่อ version (FR-13)
- [docs/plans/field-editor.md](docs/plans/field-editor.md) — แผน Phase 10: editor ต่อ field (FR-14)
- [docs/plans/web-portal-redesign.md](docs/plans/web-portal-redesign.md) — แผน redesign web portal (design system + responsive, นอก spec)
สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0)
@ -128,7 +130,7 @@ GET /api/templates # รายก
GET /api/templates/{id} # รายละเอียด + field definitions
GET /api/templates/{id}/documents # รายการเอกสารที่สร้างจาก template นี้ (FR-12)
POST /api/templates # upload .docx → detect fields อัตโนมัติ
PUT /api/templates/{id} # แก้ metadata + field definitions (label/type/required/ลำดับ)
PUT /api/templates/{id} # แก้ metadata + field definitions (label/type/editor/required/ลำดับ)
PUT /api/templates/{id}/file # แทนที่ไฟล์ → re-detect (บอก tag ที่เพิ่ม/หาย)
DELETE /api/templates/{id} # deactivate (soft-delete)
```
@ -266,7 +268,7 @@ npm run build
```bash
cd web && npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชน add-in)
cd web && npm test # Vitest — unit tests (36 ตัว)
cd web && npm test # Vitest — unit tests (46 ตัว)
```
เปิด <http://localhost:5173> แล้วกดเข้าสู่ระบบด้วย `testuser` / `Test1234!` (ผ่าน Keycloak — client `word-versioning-web` ใช้ PKCE เหมือนกันทั้งระบบ) หน้าที่มีให้ใช้:
@ -276,12 +278,12 @@ cd web && npm test # Vitest — unit tests (36 ตัว)
| `/` หน้าหลัก | card รายการ template ที่ active → เข้าฟอร์มสร้างเอกสาร หรือดูเอกสารที่สร้างแล้วของ template นั้น |
| `/templates` | ตาราง template ทั้งหมด (แสดงตัวที่ deactivate ได้) — แก้ไข / activate / deactivate / ดูรายการเอกสาร |
| `/templates/new` | upload template → เห็น field list ที่ระบบ detect อัตโนมัติทันที |
| `/templates/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) |
| `/templates/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/editor (FR-14 — `text` เลือก textbox/textarea ได้, number/date ใช้ control ตาม type)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) |
| `/templates/{id}/documents` | **รายการเอกสารที่สร้างจาก template** — จำนวน version, กิจกรรมล่าสุด, เข้าไปดู detail |
| `/documents/create/{templateId}` | dynamic form ตาม field definitions (validate ฝั่ง client + backend) + ช่อง **คำอธิบาย version** ทางเลือก (FR-13) → สร้างเป็น v1 + ดาวน์โหลด |
| `/documents/create/{templateId}` | dynamic form ตาม field definitions — control ต่อ field ตาม editor (FR-14: textarea สำหรับข้อความยาว · ช่องตัวเลข · date picker) + validate ฝั่ง client + backend + ช่อง **คำอธิบาย version** ทางเลือก (FR-13) → สร้างเป็น v1 + ดาวน์โหลด |
| `/documents/{documentId}` | **detail เอกสาร** — ค่า fields ปัจจุบัน + version history พร้อมคำอธิบายของแต่ละ version (กดขยายแถวเห็นค่า fields ตอนนั้น + ดาวน์โหลดต่อ version) + ปุ่ม **⟳ re-generate** |
**สร้าง template แรกจากไฟล์จริง:** เขียน .docx ใน Word แล้วแทรก content controls ในจุดที่จะเป็น field (Developer tab → Rich Text Content Control → Properties ตั้ง **Tag** ให้ unique ต่อไฟล์ เช่น `employee_name`, `salary`) → หน้า `/templates/new` upload ไฟล์ → ระบบ detect tags ออกมาเป็นรายการ fields → กด "ตั้งค่าต่อ" ไปใส่ label/type/required ให้เรียบร้อย → กลับมาหน้าหลักเพื่อสร้างเอกสาร
**สร้าง template แรกจากไฟล์จริง:** เขียน .docx ใน Word แล้วแทรก content controls ในจุดที่จะเป็น field (Developer tab → Rich Text Content Control → Properties ตั้ง **Tag** ให้ unique ต่อไฟล์ เช่น `employee_name`, `salary`) → หน้า `/templates/new` upload ไฟล์ → ระบบ detect tags ออกมาเป็นรายการ fields → กด "ตั้งค่าต่อ" ไปใส่ label/type/editor/required ให้เรียบร้อย (field ข้อความยาวเช่นที่อยู่/รายละเอียด เลือก editor เป็น `textarea` ได้ — FR-14) → กลับมาหน้าหลักเพื่อสร้างเอกสาร
**Re-generate (สร้างเอกสารใหม่จากข้อมูลเดิม):** ที่หน้า detail ของเอกสาร กด **"⟳ สร้างเอกสารใหม่จากข้อมูลนี้"** → เข้าฟอร์มสร้างเอกสารโดย prefill ค่าปัจจุบันของทุก field ไว้ให้แก้ไขก่อน (ช่องคำอธิบาย version ว่าง — เป็นเอกสารใหม่ได้ข้อความใหม่) → กดสร้าง → ได้**เอกสารใหม่เป็น v1** (documentId ใหม่) โดยเอกสารต้นทางและ version history ของมันไม่ถูกแตะ · template ที่ deactivate ไปแล้วจะกดไม่ได้ (ปุ่มถูก disable พร้อมเหตุผล)
@ -293,19 +295,19 @@ Theme toggle (light/dark) อยู่มุมขวาบนของทุก
## Tests
ชุด test ทั้งหมดของระบบ — รวม **138 ตัว** ครอบทุก flow ตาม acceptance criteria:
ชุด test ทั้งหมดของระบบ — รวม **156 ตัว** ครอบทุก flow ตาม acceptance criteria:
```bash
# 1) backend integration (52 ตัว) — ต้องมี Docker รันอยู่
# 1) backend integration (60 ตัว) — ต้องมี Docker รันอยู่
cd backend
dotnet test
dotnet test --filter "FullyQualifiedName~FieldSyncTests" # รันเฉพาะชุด (Auth/UploadVersion/Template/Generate/FieldSync/VersionMessage)
dotnet test --filter "FullyQualifiedName~FieldSyncTests" # รันเฉพาะชุด (Auth/UploadVersion/Template/Generate/FieldSync/VersionMessage/FieldEditor)
# 2) add-in unit (50 ตัว) — mock Word.run / Office.context.ui / fetch / Keycloak
cd addin && npm test
npx jest src/services/versionFlow.test.ts # รันเดี่ยว
# 3) web unit (36 ตัว)
# 3) web unit (46 ตัว)
cd web && npm test
npx vitest run test/useTheme.spec.ts # รันเดี่ยว
```
@ -318,6 +320,7 @@ npx vitest run test/useTheme.spec.ts # รันเดี
- **GenerateTests** — generate แล้วค่าครบทุก field + multi-run รวมเป็น run เดียวรักษา formatting + stamp `SavedByAddIn`/`DocumentTrackingId` ลงไฟล์ + v1 + field values ใน DB + required/type validation + FR-12 (detail merge orphan สองทาง, รายการเอกสารต่อ template) (AC-12, 15)
- **FieldSyncTests** — Save & Version พร้อม `fields`: latest values อัปเดต + snapshot ต่อ version (AC-13) · ลบ content control → request สำเร็จ ค่าเดิมคงอยู่ + mark missing (AC-14)
- **VersionMessageTests** — commit message ต่อ version (FR-13/AC-16): เก็บ + echo ใน history · ว่าง/whitespace → null · trim · เกิน 2048 → 400 ไม่มี side effect · ยาวพอดี 2048 ผ่าน · message ต่อ version เป็นของตัวเอง · generate v1 พร้อม message · generate oversize → 400 ก่อนแตะ MinIO
- **FieldEditorTests** — editor ต่อ field (FR-14/AC-17): PUT editor=textarea → persist + echo · ไม่ส่ง → resolve default ตาม type + DB เก็บ null · ค่า = default → normalize เป็น null · editor ไม่รู้จัก/ไม่เข้ากับ type → 400 · tag ใหม่จาก replace ได้ default
ไฟล์ .docx ที่ใช้ตอน test สร้างใน memory ด้วย OpenXML (`TestDocx.cs`) — ไม่มี binary ติดใน repo · realm ของ Keycloak ทดสอบเป็นสำเนาของ dev (`Fixtures/realm-word-versioning-test.json`) ที่เปิด password grant ให้ client `word-versioning-web` เฉพาะตอน test เพื่อขอ token ได้โดยไม่ต้องเดิน PKCE

View file

@ -0,0 +1,181 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
namespace WordVersioning.Api.IntegrationTests;
/// <summary>FR-14 — editor ต่อ field: เลือกได้ตาม type, ไม่ส่ง = default ของ type,
/// ค่าเท่ากับ default normalize เป็น null ใน DB, ค่าไม่รู้จัก/ไม่เข้ากับ type → 400 (AC-17)</summary>
[Collection(IntegrationTestCollection.Name)]
public sealed class FieldEditorTests(IntegrationTestFixture fixture)
{
private async Task<(Guid TemplateId, List<TemplateFieldItem> Fields)> CreateTemplateAsync(HttpClient client) =>
await TemplateTests.CreateDefaultTemplateAsync(client, $"editor {Guid.NewGuid():N}");
[Fact]
public async Task Update_TextFieldEditorTextarea_PersistsAndEchoed()
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateTemplateAsync(client);
var updated = new
{
fields = fields.Select(f => new
{
key = f.Key,
label = f.Key,
type = "text",
editor = f.Key == "employee_name" ? "textarea" : null as string,
isRequired = false,
sortOrder = f.SortOrder,
}),
};
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", updated, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var body = await put.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
Assert.Equal("textarea", body!.Fields.Single(f => f.Key == "employee_name").Editor);
await using var db = fixture.CreateDb();
var row = await db.TemplateFields.AsNoTracking().SingleAsync(f => f.TemplateId == templateId && f.Key == "employee_name");
Assert.Equal("textarea", row.Editor);
}
/// <summary>ไม่ส่ง editor → response ได้ default ของ type แต่ DB เก็บ null ทั้งหมด (null = default)</summary>
[Fact]
public async Task Update_OmittedEditor_ResolvesDefaultPerType_StoresNull()
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateTemplateAsync(client);
var updated = new
{
fields = fields.Select(f => new
{
key = f.Key,
label = f.Key,
type = f.Key == "salary" ? "number" : f.Key == "start_date" ? "date" : "text",
isRequired = false,
sortOrder = f.SortOrder,
}),
};
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", updated, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var body = await put.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
Assert.Equal("textbox", body!.Fields.Single(f => f.Key == "employee_name").Editor);
Assert.Equal("number", body.Fields.Single(f => f.Key == "salary").Editor);
Assert.Equal("date", body.Fields.Single(f => f.Key == "start_date").Editor);
await using var db = fixture.CreateDb();
var rows = await db.TemplateFields.AsNoTracking().Where(f => f.TemplateId == templateId).ToListAsync();
Assert.All(rows, r => Assert.Null(r.Editor)); // DB ไม่เก็บค่า default ซ้ำซ้อน
}
/// <summary>ส่ง editor ตรงกับ default ("textbox" ของ text) → response echo ค่าเดิมแต่ DB normalize เป็น null</summary>
[Fact]
public async Task Update_EditorEqualToDefault_NormalizedToNull()
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateTemplateAsync(client);
var updated = new
{
fields = fields.Select(f => new
{
key = f.Key,
label = f.Key,
type = "text",
editor = "textbox",
isRequired = false,
sortOrder = f.SortOrder,
}),
};
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", updated, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var body = await put.Content.ReadFromJsonAsync<TemplateDetailResult>(IntegrationTestFixture.Json);
Assert.All(body!.Fields, f => Assert.Equal("textbox", f.Editor));
await using var db = fixture.CreateDb();
var rows = await db.TemplateFields.AsNoTracking().Where(f => f.TemplateId == templateId).ToListAsync();
Assert.All(rows, r => Assert.Null(r.Editor));
}
[Fact]
public async Task Update_UnknownEditor_Returns400()
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateTemplateAsync(client);
var updated = new
{
fields = fields.Select(f => new
{
key = f.Key,
label = f.Key,
type = "text",
editor = f.Key == "employee_name" ? "dropdown" : null as string,
isRequired = false,
sortOrder = f.SortOrder,
}),
};
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", updated, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode);
}
/// <summary>editor ถูก allow-list ทั่วไปแต่ไม่เข้ากับ type ของ field นั้น → 400</summary>
[Theory]
[InlineData("number", "textarea")]
[InlineData("text", "date")]
[InlineData("date", "textbox")]
public async Task Update_EditorIncompatibleWithType_Returns400(string type, string editor)
{
using var client = fixture.CreateClient();
var (templateId, fields) = await CreateTemplateAsync(client);
var updated = new
{
fields = fields.Select(f => new
{
key = f.Key,
label = f.Key,
type = f.Key == "employee_name" ? type : "text",
editor = f.Key == "employee_name" ? editor : null as string,
isRequired = false,
sortOrder = f.SortOrder,
}),
};
using var put = await client.PutAsJsonAsync($"api/templates/{templateId}", updated, IntegrationTestFixture.Json);
Assert.Equal(HttpStatusCode.BadRequest, put.StatusCode);
}
/// <summary>tag ที่เพิ่มจากการ replace ไฟล์ได้ editor default (textbox) โดย DB เก็บ null</summary>
[Fact]
public async Task ReplaceFile_AddedTagGetsDefaultEditor()
{
using var client = fixture.CreateClient();
var (templateId, _) = await CreateTemplateAsync(client);
using var put = await client.PutAsync(
$"api/templates/{templateId}/file",
new MultipartFormDataContent
{
{ new ByteArrayContent(TestDocx.BuildReplacementTemplateDocx())
{ Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(TestDocx.DocxContentType) } },
"file", "replaced.docx" },
});
Assert.Equal(HttpStatusCode.OK, put.StatusCode);
var body = await put.Content.ReadFromJsonAsync<ReplaceFileResult>(IntegrationTestFixture.Json);
var note = body!.Fields.Single(f => f.Key == "note");
Assert.Equal("note", note.Key); // added tag
Assert.Equal("textbox", note.Editor);
await using var db = fixture.CreateDb();
var row = await db.TemplateFields.AsNoTracking().SingleAsync(f => f.TemplateId == templateId && f.Key == "note");
Assert.Null(row.Editor);
}
}

View file

@ -30,11 +30,12 @@ public sealed class TemplateTests(IntegrationTestFixture fixture)
["employee_name", "position", "salary", "start_date"],
fields.Select(f => f.Key).ToList());
// defaults: label = tag, type = text, required = false (FR-9)
// defaults: label = tag, type = text, required = false (FR-9), editor = default ของ type (FR-14)
Assert.All(fields, f =>
{
Assert.Equal(f.Key, f.Label);
Assert.Equal("text", f.Type);
Assert.Equal("textbox", f.Editor);
Assert.False(f.IsRequired);
});
@ -85,6 +86,7 @@ public sealed class TemplateTests(IntegrationTestFixture fixture)
key = f.Key,
label = $"ป้ายกำกับ {f.Key}",
type = f.Key == "salary" ? "number" : f.Key == "start_date" ? "date" : "text",
editor = f.Key == "employee_name" ? "textarea" : null as string, // ส่งเฉพาะ text field (FR-14)
isRequired = f.Key == "employee_name",
sortOrder = fields.Count - i, // กลับลำดับ
}),
@ -99,6 +101,12 @@ public sealed class TemplateTests(IntegrationTestFixture fixture)
Assert.Equal("number", body.Fields.First(f => f.Key == "salary").Type);
Assert.Equal("date", body.Fields.First(f => f.Key == "start_date").Type);
Assert.True(body.Fields.First(f => f.Key == "employee_name").IsRequired);
// editor: ที่ส่งคือ textarea, ที่ไม่ส่ง resolve เป็น default ของ type (FR-14)
Assert.Equal("textarea", body.Fields.First(f => f.Key == "employee_name").Editor);
Assert.Equal("textbox", body.Fields.First(f => f.Key == "position").Editor);
Assert.Equal("number", body.Fields.First(f => f.Key == "salary").Editor);
Assert.Equal("date", body.Fields.First(f => f.Key == "start_date").Editor);
}
[Fact]

View file

@ -11,7 +11,7 @@ public record GenerateResult(Guid DocumentId, Guid VersionId, int VersionNumber,
public record VersionItem(Guid VersionId, int VersionNumber, DateTimeOffset CreatedAt, string CreatedByUserId, string ContentHash, JsonElement? Fields, string? Message);
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, bool IsRequired, int SortOrder);
public record TemplateFieldItem(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
public record CreateTemplateResult(Guid Id, List<TemplateFieldItem> Fields);

View file

@ -196,9 +196,18 @@ public class TemplatesController(
if (string.IsNullOrWhiteSpace(def.Label))
return BadRequest($"label ของ `{def.Key}` ห้ามว่าง");
// editor (FR-14) — ไม่ส่ง/ว่าง = default ของ type · ค่าที่เท่ากับ default เก็บเป็น null (normalize-on-write)
var editor = string.IsNullOrWhiteSpace(def.Editor)
? TemplateField.DefaultEditor(def.Type)
: def.Editor.Trim();
if (!TemplateField.EditorsByType[def.Type].Contains(editor))
return BadRequest(
$"editor ของ `{def.Key}` ต้องเป็น {string.Join("/", TemplateField.EditorsByType[def.Type])} สำหรับ type {def.Type} (ได้ `{editor}`)");
var row = template.Fields.First(f => f.Key == def.Key);
row.Label = def.Label.Trim();
row.Type = def.Type;
row.Editor = editor == TemplateField.DefaultEditor(def.Type) ? null : editor;
row.IsRequired = def.IsRequired;
row.SortOrder = def.SortOrder;
}
@ -353,6 +362,7 @@ public class TemplatesController(
private static List<TemplateFieldDto> FieldDtos(Template template) => template.Fields
.OrderBy(f => f.SortOrder)
.Select(f => new TemplateFieldDto(f.Id, f.Key, f.Label, f.Type, f.IsRequired, f.SortOrder))
.Select(f => new TemplateFieldDto(f.Id, f.Key, f.Label, f.Type,
f.Editor ?? TemplateField.DefaultEditor(f.Type), f.IsRequired, f.SortOrder))
.ToList();
}

View file

@ -69,6 +69,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
entity.Property(x => x.Key).HasMaxLength(256);
entity.Property(x => x.Label).HasMaxLength(512);
entity.Property(x => x.Type).HasMaxLength(16);
entity.Property(x => x.Editor).HasMaxLength(16);
entity.HasOne(x => x.Template)
.WithMany(t => t.Fields)

View file

@ -0,0 +1,260 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using WordVersioning.Api.Data;
#nullable disable
namespace WordVersioning.Api.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260821233511_AddTemplateFieldEditor")]
partial class AddTemplateFieldEditor
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AddInSignature")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<Guid?>("TemplateId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TemplateId");
b.ToTable("Documents");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentFieldValue", b =>
{
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("FieldKey")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("SourceVersionId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("UpdatedByUserId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text");
b.HasKey("DocumentId", "FieldKey");
b.ToTable("DocumentFieldValues");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByUserId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("DocumentId")
.HasColumnType("uuid");
b.Property<string>("FieldsSnapshotJson")
.HasColumnType("text");
b.Property<string>("Message")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<int>("VersionNumber")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("DocumentId", "VersionNumber")
.IsUnique();
b.ToTable("DocumentVersions");
});
modelBuilder.Entity("WordVersioning.Api.Models.Template", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasMaxLength(2048)
.HasColumnType("character varying(2048)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("ObjectKey")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("character varying(1024)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("Templates");
});
modelBuilder.Entity("WordVersioning.Api.Models.TemplateField", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Editor")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<bool>("IsRequired")
.HasColumnType("boolean");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("Label")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<Guid>("TemplateId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.HasKey("Id");
b.HasIndex("TemplateId", "Key")
.IsUnique();
b.ToTable("TemplateFields");
});
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
{
b.HasOne("WordVersioning.Api.Models.Template", "Template")
.WithMany("Documents")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("Template");
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentFieldValue", b =>
{
b.HasOne("WordVersioning.Api.Models.Document", null)
.WithMany()
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b =>
{
b.HasOne("WordVersioning.Api.Models.Document", "Document")
.WithMany("Versions")
.HasForeignKey("DocumentId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Document");
});
modelBuilder.Entity("WordVersioning.Api.Models.TemplateField", b =>
{
b.HasOne("WordVersioning.Api.Models.Template", "Template")
.WithMany("Fields")
.HasForeignKey("TemplateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Template");
});
modelBuilder.Entity("WordVersioning.Api.Models.Document", b =>
{
b.Navigation("Versions");
});
modelBuilder.Entity("WordVersioning.Api.Models.Template", b =>
{
b.Navigation("Documents");
b.Navigation("Fields");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace WordVersioning.Api.Migrations
{
/// <inheritdoc />
public partial class AddTemplateFieldEditor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Editor",
table: "TemplateFields",
type: "character varying(16)",
maxLength: 16,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Editor",
table: "TemplateFields");
}
}
}

View file

@ -163,6 +163,10 @@ namespace WordVersioning.Api.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Editor")
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<bool>("IsRequired")
.HasColumnType("boolean");

View file

@ -37,6 +37,9 @@ public class TemplateFieldDefinition
/// <summary>text | number | date</summary>
public string Type { get; set; } = "text";
/// <summary>editor ต่อ field (FR-14) — textbox|textarea ตาม type · ไม่ส่ง/ว่าง = default ของ type</summary>
public string? Editor { get; set; }
public bool IsRequired { get; set; }
public int SortOrder { get; set; }
@ -49,7 +52,7 @@ public class ReplaceTemplateFileRequest
public IFormFile File { get; set; } = null!;
}
public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, bool IsRequired, int SortOrder);
public record TemplateFieldDto(Guid Id, string Key, string Label, string Type, string Editor, bool IsRequired, int SortOrder);
public record TemplateListItem(
Guid Id,

View file

@ -18,10 +18,25 @@ public class TemplateField
/// <summary>text | number | date</summary>
public string Type { get; set; } = "text";
/// <summary>รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร (FR-14): textbox|textarea สำหรับ text · number · date
/// — presentation metadata เท่านั้น ไม่กระทบ validation ของค่า (ยึด Type) · null = default ของ Type</summary>
public string? Editor { get; set; }
public bool IsRequired { get; set; }
/// <summary>ลำดับตอนแสดงฟอร์ม (default = ลำดับที่ปรากฏในไฟล์ตอน detect)</summary>
public int SortOrder { get; set; }
public static readonly string[] ValidTypes = ["text", "number", "date"];
/// <summary>editor ที่เลือกได้ต่อ type (FR-14) — ตัวแรกของแต่ละรายการคือ default</summary>
public static readonly Dictionary<string, string[]> EditorsByType = new()
{
["text"] = ["textbox", "textarea"],
["number"] = ["number"],
["date"] = ["date"],
};
public static string DefaultEditor(string type) =>
EditorsByType.TryGetValue(type, out var editors) ? editors[0] : "textbox";
}

View file

@ -1,10 +1,10 @@
# ตรวจ Acceptance Criteria 1–16 (Phase 8 + 9)
# ตรวจ Acceptance Criteria 1–17 (Phase 8 + 9 + 10)
> วันที่ตรวจ: 2026-08-20 · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
> วันที่ตรวจ: 2026-08-20 (AC 1–16) · 2026-08-22 (AC-17) · อ้างอิง AC จาก [`specs/spec-officejs.md`](../specs/spec-officejs.md) ส่วน 10
>
> สัญลักษณ์: ✅ ผ่าน (มี automated test คุ้ม) · 🖐️ ผ่านจากการทดสอบด้วยมือ · 🔶 ผ่านบางส่วน/รอยืนยันด้วยมือบน platform ที่เหลือ · ❌ ไม่ผ่าน
สรุปรวม: **AC ทั้ง 16 ข้อผ่านในระดับที่ตรวจอัตโนมัติได้ครบ** (backend ครอบทุก flow ด้วย integration tests บน PostgreSQL + MinIO + Keycloak จริงผ่าน Testcontainers) — ส่วนที่เหลือคือการยืนยันด้วยมือบน browser/Word จริง (โดยเฉพาะ Word Online + Windows) ตามรายการท้ายเอกสาร
สรุปรวม: **AC ทั้ง 17 ข้อผ่านในระดับที่ตรวจอัตโนมัติได้ครบ** (backend ครอบทุก flow ด้วย integration tests บน PostgreSQL + MinIO + Keycloak จริงผ่าน Testcontainers) — ส่วนที่เหลือคือการยืนยันด้วยมือบน browser/Word จริง (โดยเฉพาะ Word Online + Windows) ตามรายการท้ายเอกสาร
## ผลทีละข้อ
@ -71,13 +71,18 @@
- ✅ Web: `VersionListItem.message` + build ผ่าน (type-only ตาม convention ไม่มี component test)
- 🔶 dialog + textarea ใน task pane (Save & Version / Start Versioning) และการแสดงใน `VersionHistoryPanel` ต้องยืนยันด้วยมือบน Word จริง
### AC-17 — (v6) admin เลือก editor ต่อ field ได้ (text → textbox/textarea) → ฟอร์มสร้างเอกสารใช้ control ตาม editor · number/date ใช้ control ตาม type · editor ผิด → 400 · ไม่ส่ง → default — ✅ (backend + web unit) + 🔶 (browser รอมือ)
- ✅ `FieldEditorTests` (backend): PUT editor=textarea → persist + echo · ไม่ส่ง → resolve default ตาม type + DB เก็บ null · ส่งค่า = default → normalize เป็น null · editor ไม่รู้จัก → 400 · ไม่เข้ากับ type (3 คู่) → 400 · tag ใหม่จาก replace ได้ default
- ✅ `fieldEditor.spec.ts` (web): `defaultEditor`/`resolveEditor`/`editorOptions`/`dateInputValue` (prefill re-generate ของ field date จาก timestamp เต็ม)
- 🔶 Click-through บน browser: admin editor select (desktop + mobile) · ฟอร์มสร้างเอกสาร render textarea/number/date ตาม editor
## ชุด test ที่คุ้ม AC (สรุป)
| ชุด | จำนวน | ครอบ |
|---|---|---|
| backend integration (Testcontainers: Postgres + MinIO + Keycloak จริง) | 52 | AC-3, 5, 6, 7 (backend), 8, 11–16 |
| backend integration (Testcontainers: Postgres + MinIO + Keycloak จริง) | 60 | AC-3, 5, 6, 7 (backend), 8, 11–17 |
| addin unit (Jest) | 50 | AC-2, 4, 7 (client flow), 9, 10, 16 (ส่วน form) |
| web unit (Vitest) | 36 | AC-11/12 (validation ฝั่งฟอร์ม), theme, snapshot rendering |
| web unit (Vitest) | 46 | AC-11/12 (validation ฝั่งฟอร์ม), theme, snapshot rendering, AC-17 (ส่วน editor util) |
## ปัญหาที่เจอระหว่างไล่ AC (และการแก้)
@ -97,3 +102,4 @@
- [ ] Click-through บน browser: สร้าง/แก้ template → สร้างเอกสาร → ดาวน์โหลด → เปิดใน Word ยืนยัน add-in เห็น v1 ทันที (AC-11, 12) + ดาวน์โหลด version ผ่าน presigned link บน Mac อีกครั้ง (AC-8)
- [ ] Theme toggle จำค่าหลัง reload บน browser จริง (Phase 6 DoD ที่เหลือ)
- [ ] (v5) ใน Word จริง: textarea ใน dialog ของทั้ง Save & Version และ Start Versioning + ข้อความโชว์ใน `VersionHistoryPanel` (AC-16) · และบน browser: ฟอร์มสร้างเอกสารพร้อม message + แสดงใน history ทั้ง desktop/mobille
- [ ] (v6) บน browser: หน้าแก้ template เลือก editor `textarea` (desktop + mobile) · ฟอร์มสร้างเอกสาร render textarea/ช่องตัวเลข/date picker ตาม editor · re-generate เอกสารที่ field date เก็บ timestamp เต็มแล้วช่อง date แสดงวันที่ถูกต้อง (AC-17)

View file

@ -0,0 +1,83 @@
# Field Editor — FR-14 / AC-17 / Spec v6
## Context
ฟอร์มสร้างเอกสารบนเว็บ (FR-10) render ทุก field เป็น `<input>` บรรทัดเดียว — field ข้อความยาว (เช่น รายละเอียดคดี, ที่อยู่) กรอกในช่องแคบไม่สะดวก ผู้ใช้ต้องการให้ผู้ดูแลเลือก **editor (รูปแบบช่องกรอก) ต่อ field** ได้ ขอบเขต (ยืนยันกับผู้ใช้แล้ว): **ไม่เพิ่ม dropdown/checkbox** — แค่:
- `text` → `textbox` (default) หรือ `textarea`
- `number` → `number` (ช่องตัวเลข — ทางเดียว)
- `date` → `date` (date picker — ทางเดียว)
**ข้อตกลง fixed:**
- `Editor` เป็น **presentation metadata แยกจาก `Type`** — validation ของค่า field (generate/sync) ยึด `Type` เท่านั้น ค่า field เป็น string ทั้ง flow (กติกา #5/#6)
- คอลัมน์ **nullable ไม่ backfill** — `null` = "default ของ Type" (มิเรอร์ pattern `AddVersionMessage`) ทำให้ path auto-detect (POST create / PUT file) ไม่ต้องแต่
- **Normalize-on-write**: ค่าที่ resolve แล้วเท่ากับ default → เก็บ `null` (DB ไม่มีค่า default ซ้ำซ้อน อนาคตเปลี่ยน default มีผลทุก row) · API คืนค่า resolved เสมอ
- UI admin **ซ่อน select เมื่อ type มี editor ทางเดียว** (แสดง `—` muted แทน select หลอก) — backend ยัง validate ค่า `number`/`date` ให้ (round-trip ปลอดภัย)
- **Non-goal:** ไม่แตะ add-in (ไม่มี UI แก้ค่า field) · ไม่แตะ `DocumentFieldItem`/หน้า detail (display-only) · ไม่แตะ docx/generate path
- ตามกติกา repo: **แก้ spec ก่อน** แล้ว sync plan
## ขั้นที่ 0 — บันทึกแผน + แก้เอกสาร requirement ก่อน
1. บันทึกแผนนี้ที่ `docs/plans/field-editor.md` (ธรรมเนียมเดิม)
2. **[specs/spec-officejs.md](../../specs/spec-officejs.md)**: changelog v6 บรรทัดบนสุด · FR-9 bullet admin config เพิ่ม `editor (FR-14)` · `### FR-14: Field editor` หลัง FR-13 · section 7 `TemplateField` POCO เพิ่ม `public string? Editor { get; set; }` · section 10 เพิ่ม AC-17 (v6) · section 11 เพิ่ม task items `(v6)` ทั้ง backend + web list
3. **[docs/plans/officejs-implementation-plan.md](officejs-implementation-plan.md)**: header ข้อตกลง v5→v6 · `## Phase 10: Field editor — FR-14 (เพิ่มทีหลัง, v6)` + checklist + แถว mapping `| 10 (ทีหลัง, v6) | FR-14 | AC-17 |` + AC-17 ในรายการ AC เพิ่มเติม
4. **[docs/acceptance-review.md](../acceptance-review.md)**: "AC ทั้ง 16 ข้อ" → 17 + section AC-17 (🔶 browser click-through)
## ขั้นที่ 1 — Backend (`backend/WordVersioning.Api`)
1. [Models/TemplateField.cs](../../backend/WordVersioning.Api/Models/TemplateField.cs): `public string? Editor { get; set; }` หลัง `Type` + static `EditorsByType` (`text → [textbox, textarea]`, `number → [number]`, `date → [date]`) + `DefaultEditor(type)` (unknown type → `"textbox"`)
2. [Data/AppDbContext.cs](../../backend/WordVersioning.Api/Data/AppDbContext.cs): ใน block TemplateField เพิ่ม `entity.Property(x => x.Editor).HasMaxLength(16);` (มิเรอร์ Type)
3. Migration: `dotnet ef migrations add AddTemplateFieldEditor` → expect `AddColumn<string>("Editor", "TemplateFields", nullable, maxLength 16)` เดี่ยว ไม่ backfill (pattern เดียวกับ `AddVersionMessage`)
4. [Models/TemplateDtos.cs](../../backend/WordVersioning.Api/Models/TemplateDtos.cs): `TemplateFieldDefinition.Editor` (string? หลัง Type — ไม่ส่ง/ว่าง = default) · `TemplateFieldDto` แทรก `string Editor` หลัง `Type` (คืนค่า resolved เสมอ)
5. [Controllers/TemplatesController.cs](../../backend/WordVersioning.Api/Controllers/TemplatesController.cs):
- `Update` field loop หลัง type check: resolve null/whitespace → `DefaultEditor(def.Type)` · ไม่อยู่ใน `EditorsByType[def.Type]` → 400 ข้อความไทย (ครอบทั้งค่าไม่รู้จักและไม่เข้ากับ type) · persist normalize-on-write: `row.Editor = editor == DefaultEditor(def.Type) ? null : editor`
- `Create`/`ReplaceFile`: **ไม่แตะ** (Editor null = default ถูกต้องอยู่แล้ว)
- `FieldDtos`: `Editor = f.Editor ?? TemplateField.DefaultEditor(f.Type)` (จุด resolve เดียว)
6. **ไม่แตะ** `DocumentsController` (validation ยึด Type), `VersionDtos.cs`, docx path, add-in
## ขั้นที่ 2 — Web (`web`)
1. [types/api.ts](../../web/app/types/api.ts): `export type FieldEditor = 'textbox' | 'textarea' | 'number' | 'date'` · `TemplateFieldDto` เพิ่ม `editor: FieldEditor | string` หลัง `type` (string-tolerant เหมือน `type`)
2. **สร้าง `app/utils/fieldEditor.ts`** (pure util auto-import เหมือน `fieldValidation`): `EDITORS_BY_TYPE` · `defaultEditor(type)` · `editorOptions(type)` (text = 2 ตัว, number/date = 1 ตัว) · `resolveEditor(field)` (valid-for-type → ตัวมันเอง, null/unknown/incompatible → default — degrade อ่อนโยน) · `dateInputValue(value)` (ISO timestamp → `yyyy-MM-dd` 10 หลักแรก, อื่นๆ คงเดิม)
3. [templates/[id]/edit.vue](../../web/app/pages/templates/[id]/edit.vue): payload mapping เพิ่ม `editor: f.editor` · type `<select>` ทั้ง 2 ที่ เพิ่ม `@change="f.editor = defaultEditor(f.type)"` · editor select แสดงเฉพาะ `editorOptions(f.type).length > 1` (mobile card + desktop `<th>/<td>`) ไม่งั้น `—` muted · hint ไทยใต้ subtitle
4. [documents/create/[templateId].vue](../../web/app/pages/documents/create/[templateId].vue): field loop แทน ternary ด้วย `resolveEditor(f)` switch — `textarea` → `<textarea rows="4">` · `number` → input number step any (preserve) · `date` → input date (preserve) · else textbox · prefill `?from=` ของ field editor `date` ผ่าน `dateInputValue` (input[type=date] รับเฉพาะ yyyy-MM-dd — ค่า timestamp เต็มจาก Word sync ทำให้ช่องแสดงว่าง)
5. [templates/new.vue](../../web/app/pages/templates/new.vue): copy ปุ่มลิงก์ `label/type/required` → `label/type/editor/required`
## ขั้นที่ 3 — Tests
**Backend — ไฟล์ใหม่ `FieldEditorTests.cs`** (pattern `VersionMessageTests` — `fixture.CreateDb()` + `AsNoTracking` + ใช้ `TemplateTests.CreateDefaultTemplateAsync`):
1. `Update_TextFieldEditorTextarea_PersistsAndEchoed` (response + DB row = "textarea")
2. `Update_OmittedEditor_ResolvesDefaultPerType_StoresNull` (text/number/date → echo textbox/number/date, DB null ทั้งหมด)
3. `Update_EditorEqualToDefault_NormalizedToNull` (ส่ง "textbox" ชัดๆ → echo "textbox", DB null)
4. `Update_UnknownEditor_Returns400`
5. `[Theory] Update_EditorIncompatibleWithType_Returns400` ((number,"textarea"), (text,"date"), (date,"textbox"))
6. `ReplaceFile_AddedTagGetsDefaultEditor` (echo "textbox", DB null)
Mirror DTO: `TestDtos.cs` `TemplateFieldItem` เพิ่ม `string Editor` · extend `TemplateTests` — `Create_DetectsAllSdtTags_WithDefaults` assert `Editor == "textbox"` + `Update_SavesFieldDefinitions_AndMetadata` เพิ่ม editor ใน payload
**Web — สร้าง `test/fieldEditor.spec.ts`**: `defaultEditor` ทุก type + unknown · `resolveEditor` (explicit/undefined/incompatible/string-tolerant) · `editorOptions` (text=2, number/date=1) · `dateInputValue` (timestamp→date, plain คงเดิม, ค่าอื่นคงเดิม) · `fieldValidation.spec.ts` ไม่แตะ
## Verification
```
# ลำดับ: docs → backend → web
cd backend/WordVersioning.Api && dotnet ef migrations add AddTemplateFieldEditor # ตรวจ Up/Down
cd backend && dotnet test --filter FullyQualifiedName~FieldEditorTests # ต้องมี Docker (Testcontainers)
cd backend && dotnet test
cd web && npx vitest run test/fieldEditor.spec.ts && npm test && npm run build
```
Manual E2E (หลัง `docker compose up -d` + backend + `npm run dev`):
1. หน้าแก้ template: field type `text` → เลือก editor `textarea` ได้ · type `number`/`date` → แสดง `—` · เปลี่ยน type จาก text เป็น number → editor กลับเป็น default
2. ฟอร์มสร้างเอกสาร: field textarea → กว้างหลายบรรทัด · number → ช่องตัวเลข · date → date picker (ตามเดิม)
3. Re-generate เอกสารที่ field date เก็บ timestamp เต็ม → ช่อง date ต้องแสดงวันที่ ไม่ใช่ว่างเปล่า
## Risks / gotchas
- `TemplateFieldDto` เป็น positional record — แทรก `Editor` หลัง `Type` กระทบจุด construct เดียว (`FieldDtos`) + test mirror (`TestDtos.TemplateFieldItem`) — ตรวจ compile
- Validation editor ต้องมาหลัง type check (allow-list keyed ตาม type — ลำดับใน loop เดิมรับประกันอยู่แล้ว)
- editor select ใช้ label `editor` lowercase ตาม convention `label`/`type`/`required` ของหน้า — option แสดง raw value เหมือน type select
- อย่าลืมอัปเดตตัวเลข test count ใน docs (52/36) หลังรันจริง
## สถานะ: ✅ implement เสร็จ (2026-08-22)
ผลรันจริงทั้งชุด: backend `dotnet test` **60/60** (เพิ่ม `FieldEditorTests` 8 กรณี) · web `npm test` **46/46** + build ผ่าน (เพิ่ม `fieldEditor.spec.ts` 10 กรณี) · addin ไม่ถูกแตะ (50/50 คงเดิม) — รายละเอียด checkbox Phase 10 + หมายเหตุจากการทำจริงอยู่ที่ [officejs-implementation-plan.md](officejs-implementation-plan.md) · ผล AC-17 ที่ [acceptance-review.md](../acceptance-review.md) · เหลือยืนยันด้วยมือ: browser click-through ของ admin select + textarea/date rendering ในฟอร์มสร้างเอกสาร

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: **Phase 0–9 เสร็จ** (backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
> สถานะ: **Phase 0–10 เสร็จ** (backend + add-in + web portal + tests ครบชุด + ไล่ AC ครบ — รายละเอียดผล AC ทุกข้อที่ [`docs/acceptance-review.md`](../acceptance-review.md)) · เหลือยืนยันด้วยมือเฉพาะบน platform ที่ทำแทนไม่ได้: Word Online/Windows, click-through บน browser จริง, silent refresh จริง
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
---
@ -23,7 +23,7 @@
- หน้าจอสร้างเอกสารครั้งแรก = **เว็บแยก (standalone)** — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที
- Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template)
- Field sync = **เฉพาะ fields จาก template** (ผูกกับ content controls) ไม่เก็บ full text
- **FR-9 ถึง FR-13 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ปัจจุบันเป็นฉบับ v5 (ครอบ FR-1–13 + AC-1–16: v2 เพิ่ม template/portal/field sync, v3 เพิ่มข้อจำกัดแพลตฟอร์มจากการทดสอบจริง, v4 เพิ่มรายการเอกสารต่อ template + detail + re-generate, v5 เพิ่ม commit message ต่อ version) ให้ถือ spec เป็น source of truth ของ requirements
- **FR-9 ถึง FR-14 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ปัจจุบันเป็นฉบับ v6 (ครอบ FR-1–14 + AC-1–17: v2 เพิ่ม template/portal/field sync, v3 เพิ่มข้อจำกัดแพลตฟอร์มจากการทดสอบจริง, v4 เพิ่มรายการเอกสารต่อ template + detail + re-generate, v5 เพิ่ม commit message ต่อ version, v6 เพิ่ม editor ต่อ field) ให้ถือ spec เป็น source of truth ของ requirements
---
@ -523,6 +523,27 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
---
## Phase 10: Field editor — FR-14 (เพิ่มทีหลัง, v6)
**เป้า:** ผู้ดูแลเลือกรูปแบบช่องกรอก (editor) ต่อ field ได้ — `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — เป็น presentation metadata ล้วน (ค่า field เป็น string ทั้ง flow, validation ยึด `Type` เท่านั้น) — แผนเต็มที่ [`docs/plans/field-editor.md`](field-editor.md)
- [x] Backend: `TemplateField.Editor` (string?, varchar 16 — null = default ของ Type) + `EditorsByType`/`DefaultEditor` + `AppDbContext` config + migration `AddTemplateFieldEditor` (nullable column เดี่ยว ไม่ backfill)
- [x] Backend: `TemplateFieldDefinition.Editor` (ทางเลือก — ไม่ส่ง/ว่าง = default) + validate ตาม type (ไม่รู้จัก/ไม่เข้ากับ type → 400) + normalize-on-write (ค่า = default → เก็บ null) + `TemplateFieldDto.Editor` คืนค่า resolved เสมอ
- [x] Web: util `fieldEditor.ts` (`EDITORS_BY_TYPE`/`defaultEditor`/`editorOptions`/`resolveEditor`/`dateInputValue`) + `FieldEditor` type ใน `api.ts`
- [x] Web: หน้าแก้ field definitions เพิ่ม editor select (แสดงเฉพาะ type `text` ที่มีทางเลือก >1, number/date แสดง `—`) + reset editor เป็น default เมื่อเปลี่ยน type + payload `editor`
- [x] Web: ฟอร์มสร้างเอกสาร render ตาม `resolveEditor` (textarea/number/date/textbox — preserve number/date control เดิม) + prefill re-generate ของ field date ผ่าน `dateInputValue` (input[type=date] รับเฉพาะ yyyy-MM-dd)
- [x] Tests: `FieldEditorTests` (backend 6 กรณี) + `fieldEditor.spec.ts` (web) + extend `TemplateTests` default/echo
หมายเหตุจากการทำจริง (2026-08-22):
- `applyDetail` ของ edit.vue resolve editor ตอนโหลด (`{ ...f, editor: resolveEditor(f) }`) — ฟอร์มถือค่า valid เสมอแม้ API เก่า/ค่าหลุด
- `@change` ของ type select อ่านค่าจาก `e.target` ตรงๆ (`onTypeChange`) ไม่พึ่งลำดับ handler ของ v-model
**DoD:** `dotnet test` + web `npm test`/`npm run build` เขียวทั้งชุด · generate/field sync path ไม่เปลี่ยนพฤติกรรม
> ✅ ตรวจผ่านจริง (2026-08-22): `dotnet test` **60/60** · web `npm test` **46/46** + build ผ่าน · addin ไม่ถูกแตะ (50/50 คงเดิม)
---
## 4. ตาราง mapping: Phase ↔ FR ↔ Acceptance Criteria
| Phase | FR ที่ implement | Acceptance criteria ที่ตรวจได้ |
@ -538,6 +559,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
| 8 | — | ทบทวน AC-1 ถึง AC-14 ทั้งหมด |
| 9 (ทีหลัง, v5) | FR-13 | AC-16 |
| 10 (ทีหลัง, v6) | FR-14 | AC-17 |
**AC เพิ่มเติม (นอก spec เดิม):**
- **AC-11:** upload template .docx ที่มี content controls → ระบบ detect fields อัตโนมัติ + บันทึก field definitions ได้
@ -546,6 +568,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- **AC-14:** ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
- **AC-15:** (v4) หน้ารายการเอกสารของ template + หน้า detail (ค่าทุก field + version history ขยายเห็นค่าต่อ version + ดาวน์โหลด) + re-generate ได้เอกสารใหม่ v1 โดยเอกสารต้นทางไม่มี version ใหม่ — รายละเอียดตาม FR-12
- **AC-16:** (v5) พิมพ์คำอธิบายตอนสร้าง version ได้ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า · เว้นว่างได้ — รายละเอียดตาม FR-13
- **AC-17:** (v6) admin เลือก editor ของ field type `text` เป็น `textarea` ได้ → ฟอร์มสร้างเอกสารแสดงเป็น textarea · number/date ใช้ control ตาม type · editor ไม่ถูกต้อง → 400 · ไม่ส่ง → default ของ type · ค่า field และการ generate ไม่เปลี่ยน — รายละเอียดตาม FR-14
---
@ -578,7 +601,7 @@ docker compose up -d
cd backend/WordVersioning.Api
dotnet run # รัน + swagger ที่ http://localhost:5xxx
dotnet ef migrations add <Name>
cd .. && dotnet test # integration tests (52 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที)
cd .. && dotnet test # integration tests (60 ตัว) — ต้องมี Docker รันอยู่ (Testcontainers ยก infra เอง ~1 นาที)
# add-in (https://localhost:3000)
cd addin
@ -592,5 +615,5 @@ npx office-addin-dev-certs install # ครั้งแรกครั้ง
cd web
npm install
npm run dev
npm test # vitest (36 ตัว)
npm test # vitest (46 ตัว)
```

View file

@ -1,5 +1,6 @@
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
> ฉบับแก้ไข v6 (2026-08-22): เพิ่ม FR-14 — editor ต่อ field (รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร): `text` เลือก `textbox`/`textarea` ได้, `number`/`date` ใช้ control ตาม type — presentation metadata เท่านั้น ค่า field ยังเป็น string ทั้ง flow
> ฉบับแก้ไข v5 (2026-08-20): เพิ่ม FR-13 — คำอธิบายต่อ version ("commit message") พิมพ์ได้ตอนสร้าง version ทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) + แสดงใน version history ทุกหน้า
> ฉบับแก้ไข v4 (2026-08-20): เพิ่ม FR-12 — รายการเอกสารต่อ template + document detail + re-generate (Web Portal)
> ฉบับแก้ไข v3 (2026-08-19): เพิ่มข้อจำกัดแพลตฟอร์มที่พบจากการทดสอบจริงบน Word for Mac (dialog บังคับ HTTPS, mixed content, รูปแบบ slice.data หลายแบบ, webview ทิ้ง blob download) + กลไกดาวน์โหลด version ผ่าน presigned link
@ -222,7 +223,7 @@ GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned
### FR-9: Template management (เพิ่มเติม v2)
- CRUD template: upload ไฟล์ .docx template (ที่มี content controls), ตั้ง name/description/active flag
- ตอน upload/replaces ไฟล์ ให้ backend **parse content controls (SDT tags) ออกมาเป็น field list อัตโนมัติ** ด้วย OpenXML
- Admin ตั้งค่าต่อ field ผ่าน Web Portal: label, type (`text`/`number`/`date`), required, ลำดับ
- Admin ตั้งค่าต่อ field ผ่าน Web Portal: label, type (`text`/`number`/`date`), required, ลำดับ, editor (FR-14)
- ลบ template = **soft-delete (deactivate) เท่านั้น** เพราะ document เก่ายังอ้างอยู่ — เอกสารที่สร้างแล้วใช้ template ที่ deactivate ต่อได้
- ไฟล์ template เก็บใน MinIO bucket `docx-templates` (private)
@ -266,6 +267,15 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- Web Portal: ฟอร์มสร้างเอกสาร (FR-10) มี textarea ทางเลือก — re-generate ตาม FR-12 **ไม่ prefill ข้อความ** (เป็นเอกสารใหม่ ได้ข้อความใหม่)
- **ไม่ฝังลงไฟล์ .docx** (custom properties หรือ custom XML part) — backend คือ source of truth ของ version history ตามกติกา และเลี่ยงปัญหา backward-compat กับไฟล์เก่า
### FR-14: Field editor — รูปแบบช่องกรอกต่อ field (เพิ่มเติม v6)
- ผู้ดูแลเลือก **editor** (รูปแบบ control ในฟอร์มสร้างเอกสารของ Web Portal) ต่อ field ได้ โดยเป็น metadata ที่แยกจาก `Type` (ซึ่งคุม validation):
- `text` → `textbox` (default) หรือ `textarea` — สำหรับข้อความหลายบรรทัด
- `number` → `number` (ช่องตัวเลข — ทางเดียว)
- `date` → `date` (date picker — ทางเดียว)
- `PUT /api/templates/{id}`: `editor` เป็นทางเลือก — ไม่ส่ง/ว่าง = default ของ type · ค่าที่ไม่รู้จักหรือไม่เข้ากับ type → 400 · เก็บค่าที่เท่ากับ default เป็น `null` (normalize-on-write) แต่ API คืนค่า resolved เสมอ
- field ที่ detect อัตโนมัติตอน upload/replace ได้ editor = default ของ type (`textbox`)
- **presentation metadata ล้วน** — ไม่กระทบ validation หรือ generate (validation ยึด `Type` เท่านั้น) ค่า field เป็น string ทั้ง flow ไม่แตะ `DocumentFieldValue`/`FieldsSnapshotJson`/ไฟล์ docx/add-in
---
## 7. Data Model (Backend)
@ -312,6 +322,7 @@ public class TemplateField
public string Key { get; set; } // = content control (SDT) tag — unique ต่อ template
public string Label { get; set; } // ชื่อที่แสดงใน form
public string Type { get; set; } // text | number | date
public string? Editor { get; set; } // รูปแบบช่องกรอก (FR-14): textbox|textarea สำหรับ text · number · date — null = default ของ Type
public bool IsRequired { get; set; }
public int SortOrder { get; set; }
}
@ -388,6 +399,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
14. (v2) ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
15. (v4) เข้าหน้ารายการเอกสารของ template บนเว็บ → เห็นเอกสารที่สร้างจาก template นั้นครบ พร้อมจำนวน version และเวลาล่าสุด · เข้าหน้า detail → เห็นค่าทุก field ปัจจุบัน + version history (ขยายเห็นค่าต่อ version, ดาวน์โหลดได้) · กด re-generate แล้วสร้างเอกสารใหม่เป็น v1 โดย**เอกสารต้นทางไม่มี version ใหม่**
16. (v5) พิมพ์คำอธิบายตอนสร้าง version ได้จากทุก flow (Save & Version, Start Versioning, สร้างจากเว็บ) → ข้อความถูกเก็บกับ version นั้นใน DB และแสดงใน version history ทุกหน้า (web ตาราง + การ์ดมือถือ, add-in panel) · เว้นว่างได้ (ไม่ส่ง/ช่องว่างล้วน = ไม่มีข้อความ ไม่ error)
17. (v6) admin เลือก editor ของ field type `text` เป็น `textarea` ได้ผ่านหน้าจัดการ template → ฟอร์มสร้างเอกสารแสดงเป็น textarea · field `number`/`date` ใช้ control ตาม type (ช่องตัวเลข / date picker) · ส่ง editor ที่ไม่รู้จักหรือไม่เข้ากับ type → 400 · ไม่ส่ง editor → ได้ default ของ type · ค่า field และการ generate ไม่เปลี่ยนจากเดิม
---
@ -418,6 +430,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
12. (v2) ขยาย upload endpoint ให้รับ `fields` แล้ว sync `DocumentFieldValue` + snapshot ต่อ version ตาม missing-field policy (FR-11)
13. (v2) เขียน integration test ครอบ template detect / generate / field sync (รวมกรณี field หาย → ค่าเดิม + mark missing)
14. (v5) เพิ่ม `DocumentVersion.Message` + migration `AddVersionMessage` + รับ/คืน `message` ทั้ง upload/generate/history + ตรวจความยาว 2048 (FR-13)
15. (v6) เพิ่ม `TemplateField.Editor` + migration `AddTemplateFieldEditor` + validate editor ตาม type ใน `PUT /api/templates` + คืน editor ใน `TemplateFieldDto` (FR-14)
### Web Portal (เพิ่มเติม v2)
1. Scaffold Nuxt.js + TailwindCSS — dev port 5173, `darkMode: 'class'`, `useTheme` (light/dark toggle + persist + system default)
@ -427,3 +440,4 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
5. เขียน unit test ของ form validation + useTheme (Vitest)
6. (v4/FR-12) หน้ารายการเอกสารของ template + หน้า detail ของเอกสาร (ค่า fields ปัจจุบัน + version history พร้อม snapshot ต่อ version + ดาวน์โหลดต่อ version) + re-generate ผ่านฟอร์ม prefilled ค่าปัจจุบัน
7. (v5) ฟอร์มสร้างเอกสารเพิ่ม textarea ทางเลือก (commit message) + แสดง `message` ใน version history (FR-13)
8. (v6) หน้าแก้ field definitions เพิ่มตัวเลือก editor ต่อ field (เลือกได้เฉพาะ type `text`) + ฟอร์มสร้างเอกสาร render ตาม editor (textarea/ช่องตัวเลข/date picker/textbox) + util `fieldEditor` (FR-14)

View file

@ -7,7 +7,7 @@
```bash
npm install
npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชนกับ add-in dev server)
npm test # Vitest — 36 ตัว
npm test # Vitest — 46 ตัว
npm run build # production build (.output/)
```
@ -22,8 +22,8 @@ app/
composables/ # useAuth (PKCE), useApi (Bearer + refresh), useTheme (light/dark)
pages/ # ทุกหน้า: หน้าหลัก / templates (list/new/edit/documents) / documents (create/detail) / auth/callback
types/api.ts # DTO mirror ของ backend
utils/ # oidc, fieldValidation, snapshot — pure functions ไว้ unit test
test/ # Vitest (happy-dom) — fieldValidation / useTheme / snapshot
utils/ # oidc, fieldValidation, fieldEditor, snapshot — pure functions ไว้ unit test
test/ # Vitest (happy-dom) — fieldValidation / fieldEditor / useTheme / snapshot
tailwind.config.ts # สี/fontSize/radius/shadow expose จาก CSS variables (rgb(var(--c-x) / <alpha-value>))
```

View file

@ -54,7 +54,11 @@ async function load() {
fromDoc.value = { id: src.id, fileName: src.fileName }
// prefill เฉพาะ key ที่มีใน defs ปัจจุบัน — key ที่หายไปหลัง replace ไฟล์โชว์ไม่ได้ (generate ก็ ignore)
prefill.value = Object.fromEntries(
detail.value.fields.map((f) => [f.key, src.fields.find((s) => s.key === f.key)?.value ?? '']),
detail.value.fields.map((f) => {
const value = src.fields.find((s) => s.key === f.key)?.value ?? ''
// field วันที่: input[type=date] รับเฉพาะ yyyy-MM-dd — ค่าจาก sync ของ Word อาจเป็น ISO timestamp เต็ม (FR-14)
return [f.key, resolveEditor(f) === 'date' ? dateInputValue(value) : value]
}),
)
}
} catch {
@ -212,11 +216,37 @@ onMounted(load)
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ f.key }}</span>
</label>
<input
<!-- render ตาม editor ของ field (FR-14): textarea สำหรับข้อความยาว · number/date ตาม type -->
<textarea
v-if="resolveEditor(f) === 'textarea'"
:id="`field-${f.key}`"
v-model="values[f.key]"
:type="f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'"
:step="f.type === 'number' ? 'any' : undefined"
class="input"
:class="issues[f.key] ? 'border-error' : ''"
rows="4"
/>
<input
v-else-if="resolveEditor(f) === 'number'"
:id="`field-${f.key}`"
v-model="values[f.key]"
type="number"
step="any"
class="input"
:class="issues[f.key] ? 'border-error' : ''"
/>
<input
v-else-if="resolveEditor(f) === 'date'"
:id="`field-${f.key}`"
v-model="values[f.key]"
type="date"
class="input"
:class="issues[f.key] ? 'border-error' : ''"
/>
<input
v-else
:id="`field-${f.key}`"
v-model="values[f.key]"
type="text"
class="input"
:class="issues[f.key] ? 'border-error' : ''"
/>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
/** FR-9 UI — แก้ไข template: metadata + field definitions (label/type/required/ลำดับ) + แทนที่ไฟล์ */
/** FR-9 UI — แก้ไข template: metadata + field definitions (label/type/editor/required/ลำดับ) + แทนที่ไฟล์ */
import type { FieldType, ReplaceTemplateFileResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
@ -36,7 +36,12 @@ function applyDetail(d: TemplateDetail) {
name.value = d.name
description.value = d.description ?? ''
isActive.value = d.isActive
fields.value = d.fields.map((f) => ({ ...f }))
fields.value = d.fields.map((f) => ({ ...f, editor: resolveEditor(f) }))
}
/** เปลี่ยน type → reset editor เป็น default ของ type ใหม่ (FR-14) — อ่านค่าจาก select ตรงๆ ไม่พึ่งลำดับ v-model) */
function onTypeChange(f: (typeof fields.value)[number], e: Event) {
f.editor = defaultEditor((e.target as HTMLSelectElement).value)
}
async function load() {
@ -76,6 +81,7 @@ async function save() {
key: f.key,
label: f.label.trim(),
type: f.type,
editor: f.editor,
isRequired: f.isRequired,
sortOrder: f.sortOrder,
})),
@ -185,6 +191,9 @@ onMounted(load)
<p class="mt-1 text-xs text-muted">
<span class="font-mono">key</span> มาจาก content control tag ในไฟล์ — เพิ่ม/ลบ key ต้องแทนที่ไฟล์ด้านล่าง
</p>
<p class="mt-1 text-xs text-muted">
<span class="font-mono">editor</span> = รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร (เลือกได้เฉพาะ type text)
</p>
</div>
<!-- มือถือ: card ต่อ field (v-model ชุดเดียวกับตาราง) -->
@ -198,7 +207,13 @@ onMounted(load)
<div class="grid grid-cols-2 gap-3">
<div>
<label :for="`m-type-${f.key}`" class="label">type</label>
<select :id="`m-type-${f.key}`" v-model="f.type" :aria-label="`type ของ ${f.key}`" class="input">
<select
:id="`m-type-${f.key}`"
v-model="f.type"
:aria-label="`type ของ ${f.key}`"
class="input"
@change="onTypeChange(f, $event)"
>
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</div>
@ -213,6 +228,12 @@ onMounted(load)
/>
</div>
</div>
<div v-if="editorOptions(f.type).length > 1">
<label :for="`m-editor-${f.key}`" class="label">editor</label>
<select :id="`m-editor-${f.key}`" v-model="f.editor" :aria-label="`editor ของ ${f.key}`" class="input">
<option v-for="e in editorOptions(f.type)" :key="e" :value="e">{{ e }}</option>
</select>
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="f.isRequired" type="checkbox" :aria-label="`required ของ ${f.key}`" class="h-4 w-4 accent-primary" />
required
@ -231,6 +252,7 @@ onMounted(load)
<th class="px-4 py-3">key</th>
<th class="px-4 py-3">label</th>
<th class="px-4 py-3">type</th>
<th class="px-4 py-3">editor</th>
<th class="px-4 py-3 text-center">required</th>
<th class="px-4 py-3">ลำดับ</th>
</tr>
@ -247,10 +269,26 @@ onMounted(load)
/>
</td>
<td class="px-4 py-2">
<select v-model="f.type" :aria-label="`type ของ ${f.key}`" class="input px-2 py-1">
<select
v-model="f.type"
:aria-label="`type ของ ${f.key}`"
class="input px-2 py-1"
@change="onTypeChange(f, $event)"
>
<option v-for="t in FIELD_TYPES" :key="t" :value="t">{{ t }}</option>
</select>
</td>
<td class="px-4 py-2">
<select
v-if="editorOptions(f.type).length > 1"
v-model="f.editor"
:aria-label="`editor ของ ${f.key}`"
class="input px-2 py-1"
>
<option v-for="e in editorOptions(f.type)" :key="e" :value="e">{{ e }}</option>
</select>
<span v-else class="text-muted">—</span>
</td>
<td class="px-4 py-2 text-center">
<input
v-model="f.isRequired"

View file

@ -104,7 +104,7 @@ function resetForm() {
อัปโหลดอีกไฟล์
</button>
<NuxtLink :to="`/templates/${created.id}/edit`" class="btn btn-primary w-full sm:w-auto">
ตั้งค่า label/type/required ต่อ →
ตั้งค่า label/type/editor/required ต่อ →
</NuxtLink>
</div>
</template>

View file

@ -2,12 +2,17 @@
export type FieldType = 'text' | 'number' | 'date'
/** รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร (FR-14) — presentation metadata เท่านั้น ไม่กระทบ validation */
export type FieldEditor = 'textbox' | 'textarea' | 'number' | 'date'
export interface TemplateFieldDto {
id: string
/** = SDT tag ในไฟล์ template — ใช้เป็น key ของ values ตอน generate */
key: string
label: string
type: FieldType | string
/** editor ต่อ field (FR-14) — backend resolve default ตาม type ให้เสมอ */
editor: FieldEditor | string
isRequired: boolean
sortOrder: number
}

View file

@ -0,0 +1,43 @@
/**
* Editor (รูปแบบช่องกรอก) ต่อ template field (FR-14) — pure util ให้ unit test ได้
* มิเรอร์ allow-list ของ backend (TemplateField.EditorsByType): text เลือก textbox/textarea ได้,
* number/date มีทางเดียวตาม type — เป็น presentation metadata เท่านั้น (validation ของค่ายังยึด type)
*/
import type { FieldEditor } from '~/types/api'
export const EDITORS_BY_TYPE: Record<string, FieldEditor[]> = {
text: ['textbox', 'textarea'],
number: ['number'],
date: ['date'],
}
/** editor เริ่มต้นของ type (ตัวแรกของรายการ) — type ที่ไม่รู้จัก → textbox */
export function defaultEditor(type: string): FieldEditor {
return EDITORS_BY_TYPE[type]?.[0] ?? 'textbox'
}
/** ตัวเลือก editor ที่เลือกได้ตาม type — ใช้ซ่อน select เมื่อมีทางเดียว (number/date) */
export function editorOptions(type: string): FieldEditor[] {
return EDITORS_BY_TYPE[type] ?? ['textbox']
}
/**
* หา editor ที่จะใช้ render จริงจาก field — editor ที่ถูกต้องตาม type → ใช้ค่านั้น,
* ไม่มี/ไม่รู้จัก/ไม่เข้ากับ type → default ของ type (degrade อ่อนโยน ไม่ throw)
*/
export function resolveEditor(field: { type: string; editor?: string | null }): FieldEditor {
const options = EDITORS_BY_TYPE[field.type] ?? ['textbox']
return options.includes(field.editor as FieldEditor)
? (field.editor as FieldEditor)
: options[0]
}
/**
* ค่าวันที่สำหรับ input[type=date] — รับเฉพาะรูป yyyy-MM-dd เท่านั้น
* ค่าจาก field sync ของ Word อาจเป็น ISO timestamp เต็ม (backend parse ได้ตาม FR-10) → ตัดเอาส่วนวันที่
* ค่าอื่นที่ไม่ใช่รูป ISO → คงเดิม ให้ validation ของฟอร์มจัดการต่อ
*/
export function dateInputValue(value: string): string {
return /^\d{4}-\d{2}-\d{2}/.test(value) ? value.slice(0, 10) : value
}

View file

@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { dateInputValue, defaultEditor, editorOptions, resolveEditor } from '../app/utils/fieldEditor'
describe('fieldEditor — editor ต่อ field (FR-14)', () => {
describe('defaultEditor', () => {
it('ทุก type มี default ตาม allow-list ของ backend', () => {
expect(defaultEditor('text')).toBe('textbox')
expect(defaultEditor('number')).toBe('number')
expect(defaultEditor('date')).toBe('date')
})
it('type ที่ไม่รู้จัก → textbox (degrade อ่อนโยน)', () => {
expect(defaultEditor('unknown')).toBe('textbox')
})
})
describe('editorOptions', () => {
it('text เลือกได้ 2 แบบ — number/date มีทางเดียว (ซ่อน select)', () => {
expect(editorOptions('text')).toEqual(['textbox', 'textarea'])
expect(editorOptions('number')).toEqual(['number'])
expect(editorOptions('date')).toEqual(['date'])
})
})
describe('resolveEditor — ใช้ตอน render ฟอร์มสร้างเอกสาร', () => {
it('editor ที่เข้ากับ type → ใช้ค่านั้น (text + textarea = ข้อความยาว)', () => {
expect(resolveEditor({ type: 'text', editor: 'textarea' })).toBe('textarea')
expect(resolveEditor({ type: 'text', editor: 'textbox' })).toBe('textbox')
})
it('ไม่มี editor / null → default ของ type (backend เก่าหรือค่า default ที่เก็บ null)', () => {
expect(resolveEditor({ type: 'text' })).toBe('textbox')
expect(resolveEditor({ type: 'text', editor: null })).toBe('textbox')
expect(resolveEditor({ type: 'date', editor: undefined })).toBe('date')
})
it('editor ไม่เข้ากับ type → default ของ type ไม่ throw (กันค่าหลุดจาก API)', () => {
expect(resolveEditor({ type: 'number', editor: 'textarea' })).toBe('number')
expect(resolveEditor({ type: 'text', editor: 'date' })).toBe('textbox')
})
it('type ที่ไม่รู้จัก → textbox', () => {
expect(resolveEditor({ type: 'other', editor: 'whatever' })).toBe('textbox')
})
})
describe('dateInputValue — prefill re-generate ของ field วันที่', () => {
it('ISO timestamp เต็ม (ค่าจาก field sync ของ Word) → เหลือ yyyy-MM-dd ให้ input[type=date] แสดงได้', () => {
expect(dateInputValue('2026-08-20T09:00:00Z')).toBe('2026-08-20')
expect(dateInputValue('2026-08-20T09:00:00+07:00')).toBe('2026-08-20')
})
it('yyyy-MM-dd อยู่แล้ว → คงเดิม', () => {
expect(dateInputValue('2026-08-20')).toBe('2026-08-20')
})
it('ค่าอื่น → คงเดิมให้ validation ของฟอร์มจัดการ', () => {
expect(dateInputValue('20 ส.ค. 2569')).toBe('20 ส.ค. 2569')
expect(dateInputValue('')).toBe('')
})
})
})