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
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue