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:
parent
7b20f12ede
commit
9bb4a48c00
23 changed files with 865 additions and 45 deletions
|
|
@ -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>))
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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' : ''"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
43
web/app/utils/fieldEditor.ts
Normal file
43
web/app/utils/fieldEditor.ts
Normal 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
|
||||
}
|
||||
62
web/test/fieldEditor.spec.ts
Normal file
62
web/test/fieldEditor.spec.ts
Normal 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('')
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue