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

@ -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";
}