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

@ -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('')
})
})
})