diff --git a/CLAUDE.md b/CLAUDE.md index fc8cb60..3d964f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 # 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) diff --git a/README.md b/README.md index 9995360..9f9bc6c 100644 --- a/README.md +++ b/README.md @@ -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 ตัว) ``` เปิด แล้วกดเข้าสู่ระบบด้วย `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 diff --git a/backend/WordVersioning.Api.IntegrationTests/FieldEditorTests.cs b/backend/WordVersioning.Api.IntegrationTests/FieldEditorTests.cs new file mode 100644 index 0000000..9594b1d --- /dev/null +++ b/backend/WordVersioning.Api.IntegrationTests/FieldEditorTests.cs @@ -0,0 +1,181 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; + +namespace WordVersioning.Api.IntegrationTests; + +/// FR-14 — editor ต่อ field: เลือกได้ตาม type, ไม่ส่ง = default ของ type, +/// ค่าเท่ากับ default normalize เป็น null ใน DB, ค่าไม่รู้จัก/ไม่เข้ากับ type → 400 (AC-17) +[Collection(IntegrationTestCollection.Name)] +public sealed class FieldEditorTests(IntegrationTestFixture fixture) +{ + private async Task<(Guid TemplateId, List 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(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); + } + + /// ไม่ส่ง editor → response ได้ default ของ type แต่ DB เก็บ null ทั้งหมด (null = default) + [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(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 ซ้ำซ้อน + } + + /// ส่ง editor ตรงกับ default ("textbox" ของ text) → response echo ค่าเดิมแต่ DB normalize เป็น null + [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(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); + } + + /// editor ถูก allow-list ทั่วไปแต่ไม่เข้ากับ type ของ field นั้น → 400 + [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); + } + + /// tag ที่เพิ่มจากการ replace ไฟล์ได้ editor default (textbox) โดย DB เก็บ null + [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(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); + } +} diff --git a/backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs b/backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs index 9222517..e30e21b 100644 --- a/backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs +++ b/backend/WordVersioning.Api.IntegrationTests/TemplateTests.cs @@ -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] diff --git a/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs b/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs index 03fba40..5702f76 100644 --- a/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs +++ b/backend/WordVersioning.Api.IntegrationTests/TestDtos.cs @@ -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 Fields); diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs index 1e284cd..6057074 100644 --- a/backend/WordVersioning.Api/Controllers/TemplatesController.cs +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -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 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(); } diff --git a/backend/WordVersioning.Api/Data/AppDbContext.cs b/backend/WordVersioning.Api/Data/AppDbContext.cs index d9387b9..2513fe2 100644 --- a/backend/WordVersioning.Api/Data/AppDbContext.cs +++ b/backend/WordVersioning.Api/Data/AppDbContext.cs @@ -69,6 +69,7 @@ public class AppDbContext(DbContextOptions 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) diff --git a/backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.Designer.cs b/backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.Designer.cs new file mode 100644 index 0000000..739eb79 --- /dev/null +++ b/backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.Designer.cs @@ -0,0 +1,260 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddInSignature") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TemplateId"); + + b.ToTable("Documents"); + }); + + modelBuilder.Entity("WordVersioning.Api.Models.DocumentFieldValue", b => + { + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("FieldKey") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SourceVersionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("DocumentId", "FieldKey"); + + b.ToTable("DocumentFieldValues"); + }); + + modelBuilder.Entity("WordVersioning.Api.Models.DocumentVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("DocumentId") + .HasColumnType("uuid"); + + b.Property("FieldsSnapshotJson") + .HasColumnType("text"); + + b.Property("Message") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("VersionNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DocumentId", "VersionNumber") + .IsUnique(); + + b.ToTable("DocumentVersions"); + }); + + modelBuilder.Entity("WordVersioning.Api.Models.Template", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ObjectKey") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Templates"); + }); + + modelBuilder.Entity("WordVersioning.Api.Models.TemplateField", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Editor") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("IsRequired") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("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 + } + } +} diff --git a/backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.cs b/backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.cs new file mode 100644 index 0000000..d896c5d --- /dev/null +++ b/backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace WordVersioning.Api.Migrations +{ + /// + public partial class AddTemplateFieldEditor : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Editor", + table: "TemplateFields", + type: "character varying(16)", + maxLength: 16, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Editor", + table: "TemplateFields"); + } + } +} diff --git a/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs b/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs index e01a879..461a7b2 100644 --- a/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/WordVersioning.Api/Migrations/AppDbContextModelSnapshot.cs @@ -163,6 +163,10 @@ namespace WordVersioning.Api.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Editor") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + b.Property("IsRequired") .HasColumnType("boolean"); diff --git a/backend/WordVersioning.Api/Models/TemplateDtos.cs b/backend/WordVersioning.Api/Models/TemplateDtos.cs index 8e63e03..1c15bf6 100644 --- a/backend/WordVersioning.Api/Models/TemplateDtos.cs +++ b/backend/WordVersioning.Api/Models/TemplateDtos.cs @@ -37,6 +37,9 @@ public class TemplateFieldDefinition /// text | number | date public string Type { get; set; } = "text"; + /// editor ต่อ field (FR-14) — textbox|textarea ตาม type · ไม่ส่ง/ว่าง = default ของ type + 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, diff --git a/backend/WordVersioning.Api/Models/TemplateField.cs b/backend/WordVersioning.Api/Models/TemplateField.cs index 9a5815e..f7ef266 100644 --- a/backend/WordVersioning.Api/Models/TemplateField.cs +++ b/backend/WordVersioning.Api/Models/TemplateField.cs @@ -18,10 +18,25 @@ public class TemplateField /// text | number | date public string Type { get; set; } = "text"; + /// รูปแบบช่องกรอกในฟอร์มสร้างเอกสาร (FR-14): textbox|textarea สำหรับ text · number · date + /// — presentation metadata เท่านั้น ไม่กระทบ validation ของค่า (ยึด Type) · null = default ของ Type + public string? Editor { get; set; } + public bool IsRequired { get; set; } /// ลำดับตอนแสดงฟอร์ม (default = ลำดับที่ปรากฏในไฟล์ตอน detect) public int SortOrder { get; set; } public static readonly string[] ValidTypes = ["text", "number", "date"]; + + /// editor ที่เลือกได้ต่อ type (FR-14) — ตัวแรกของแต่ละรายการคือ default + public static readonly Dictionary EditorsByType = new() + { + ["text"] = ["textbox", "textarea"], + ["number"] = ["number"], + ["date"] = ["date"], + }; + + public static string DefaultEditor(string type) => + EditorsByType.TryGetValue(type, out var editors) ? editors[0] : "textbox"; } diff --git a/docs/acceptance-review.md b/docs/acceptance-review.md index f288b04..ec355cc 100644 --- a/docs/acceptance-review.md +++ b/docs/acceptance-review.md @@ -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) diff --git a/docs/plans/field-editor.md b/docs/plans/field-editor.md new file mode 100644 index 0000000..ff9bfcd --- /dev/null +++ b/docs/plans/field-editor.md @@ -0,0 +1,83 @@ +# Field Editor — FR-14 / AC-17 / Spec v6 + +## Context + +ฟอร์มสร้างเอกสารบนเว็บ (FR-10) render ทุก field เป็น `` บรรทัดเดียว — 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("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 `