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
181
backend/WordVersioning.Api.IntegrationTests/FieldEditorTests.cs
Normal file
181
backend/WordVersioning.Api.IntegrationTests/FieldEditorTests.cs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace WordVersioning.Api.IntegrationTests;
|
||||
|
||||
/// <summary>FR-14 — editor ต่อ field: เลือกได้ตาม type, ไม่ส่ง = default ของ type,
|
||||
/// ค่าเท่ากับ default normalize เป็น null ใน DB, ค่าไม่รู้จัก/ไม่เข้ากับ type → 400 (AC-17)</summary>
|
||||
[Collection(IntegrationTestCollection.Name)]
|
||||
public sealed class FieldEditorTests(IntegrationTestFixture fixture)
|
||||
{
|
||||
private async Task<(Guid TemplateId, List<TemplateFieldItem> 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<TemplateDetailResult>(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);
|
||||
}
|
||||
|
||||
/// <summary>ไม่ส่ง editor → response ได้ default ของ type แต่ DB เก็บ null ทั้งหมด (null = default)</summary>
|
||||
[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<TemplateDetailResult>(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 ซ้ำซ้อน
|
||||
}
|
||||
|
||||
/// <summary>ส่ง editor ตรงกับ default ("textbox" ของ text) → response echo ค่าเดิมแต่ DB normalize เป็น null</summary>
|
||||
[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<TemplateDetailResult>(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);
|
||||
}
|
||||
|
||||
/// <summary>editor ถูก allow-list ทั่วไปแต่ไม่เข้ากับ type ของ field นั้น → 400</summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>tag ที่เพิ่มจากการ replace ไฟล์ได้ editor default (textbox) โดย DB เก็บ null</summary>
|
||||
[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<ReplaceFileResult>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<TemplateFieldItem> Fields);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
260
backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.Designer.cs
generated
Normal file
260
backend/WordVersioning.Api/Migrations/20260821233511_AddTemplateFieldEditor.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue