diff --git a/Datra.Editor/Models/FieldCreationContext.cs b/Datra.Editor/Models/FieldCreationContext.cs index 360425b..009ed35 100644 --- a/Datra.Editor/Models/FieldCreationContext.cs +++ b/Datra.Editor/Models/FieldCreationContext.cs @@ -50,11 +50,27 @@ public class FieldCreationContext /// 루트 데이터 객체 public object? RootDataObject { get; set; } + /// + /// 현재 필드가 파생된 원본 멤버. + /// 배열/리스트/dictionary 요소처럼 Property/Member가 없는 파생 필드에서도 + /// attribute 기반 핸들러가 원래 선언부의 메타데이터를 볼 수 있게 한다. + /// + public MemberInfo? SourceMember { get; set; } + + /// 루트 편집 객체 기준의 사람이 읽을 수 있는 필드 경로. + public string? FieldPath { get; set; } + + /// 컬렉션 요소 키 (dictionary 요소인 경우) + public object? CollectionElementKey { get; set; } + /// 중첩 멤버인지 여부 public bool IsNestedMember => Member != null && Property == null; - /// 배열 요소인지 여부 - public bool IsArrayElement => Property == null && Member == null && CollectionElementIndex.HasValue; + /// 배열/리스트 요소인지 여부 + public bool IsArrayElement => Property == null && Member == null && CollectionElementIndex.HasValue && CollectionElementKey == null; + + /// 배열/리스트/dictionary 요소인지 여부 + public bool IsCollectionElement => CollectionElementIndex.HasValue || CollectionElementKey != null; /// /// 프로퍼티 기반 필드 생성용 생성자 @@ -76,6 +92,8 @@ public FieldCreationContext( OnValueChanged = onValueChanged; LocaleService = localeService; IsReadOnly = isReadOnly || !property.CanWrite; + SourceMember = property; + FieldPath = property.Name; } /// @@ -100,6 +118,8 @@ public FieldCreationContext( OnValueChanged = onValueChanged; LocaleService = localeService; IsReadOnly = isReadOnly; + SourceMember = member; + FieldPath = member.Name; } /// @@ -112,7 +132,9 @@ public FieldCreationContext( FieldLayoutMode layoutMode, Action? onValueChanged, ILocaleEditorService? localeService = null, - bool isReadOnly = false) + bool isReadOnly = false, + MemberInfo? sourceMember = null, + string? fieldPath = null) { FieldType = elementType; Target = value; @@ -122,6 +144,8 @@ public FieldCreationContext( OnValueChanged = onValueChanged; LocaleService = localeService; IsReadOnly = isReadOnly; + SourceMember = sourceMember; + FieldPath = fieldPath; } /// @@ -131,29 +155,29 @@ public FieldCreationContext WithValue(object? newValue) { if (Property != null) { - return new FieldCreationContext(Property, Target, newValue, LayoutMode, OnValueChanged, LocaleService, IsReadOnly) + return CopyMetadataTo(new FieldCreationContext(Property, Target, newValue, LayoutMode, OnValueChanged, LocaleService, IsReadOnly) { CollectionElementIndex = CollectionElementIndex, CollectionElement = CollectionElement, RootDataObject = RootDataObject - }; + }); } else if (Member != null) { - return new FieldCreationContext(Member, FieldType, ParentValue, newValue, LayoutMode, OnValueChanged, LocaleService, IsReadOnly) + return CopyMetadataTo(new FieldCreationContext(Member, FieldType, ParentValue, newValue, LayoutMode, OnValueChanged, LocaleService, IsReadOnly) { CollectionElementIndex = CollectionElementIndex, CollectionElement = CollectionElement, RootDataObject = RootDataObject - }; + }); } else { - return new FieldCreationContext(FieldType, newValue, CollectionElementIndex ?? 0, LayoutMode, OnValueChanged, LocaleService, IsReadOnly) + return CopyMetadataTo(new FieldCreationContext(FieldType, newValue, CollectionElementIndex ?? 0, LayoutMode, OnValueChanged, LocaleService, IsReadOnly) { CollectionElement = CollectionElement, RootDataObject = RootDataObject - }; + }); } } @@ -164,30 +188,38 @@ public FieldCreationContext WithLayoutMode(FieldLayoutMode newLayoutMode) { if (Property != null) { - return new FieldCreationContext(Property, Target, Value, newLayoutMode, OnValueChanged, LocaleService, IsReadOnly) + return CopyMetadataTo(new FieldCreationContext(Property, Target, Value, newLayoutMode, OnValueChanged, LocaleService, IsReadOnly) { CollectionElementIndex = CollectionElementIndex, CollectionElement = CollectionElement, RootDataObject = RootDataObject - }; + }); } else if (Member != null) { - return new FieldCreationContext(Member, FieldType, ParentValue, Value, newLayoutMode, OnValueChanged, LocaleService, IsReadOnly) + return CopyMetadataTo(new FieldCreationContext(Member, FieldType, ParentValue, Value, newLayoutMode, OnValueChanged, LocaleService, IsReadOnly) { CollectionElementIndex = CollectionElementIndex, CollectionElement = CollectionElement, RootDataObject = RootDataObject - }; + }); } else { - return new FieldCreationContext(FieldType, Value, CollectionElementIndex ?? 0, newLayoutMode, OnValueChanged, LocaleService, IsReadOnly) + return CopyMetadataTo(new FieldCreationContext(FieldType, Value, CollectionElementIndex ?? 0, newLayoutMode, OnValueChanged, LocaleService, IsReadOnly) { CollectionElement = CollectionElement, RootDataObject = RootDataObject - }; + }); } } + + private FieldCreationContext CopyMetadataTo(FieldCreationContext copy) + { + copy.SourceMember = SourceMember; + copy.FieldPath = FieldPath; + copy.CollectionElementKey = CollectionElementKey; + return copy; + } } } diff --git a/Datra.Editor/Schema/TypeClassifier.cs b/Datra.Editor/Schema/TypeClassifier.cs index cc067ab..7f81aee 100644 --- a/Datra.Editor/Schema/TypeClassifier.cs +++ b/Datra.Editor/Schema/TypeClassifier.cs @@ -134,7 +134,7 @@ private static bool IsGenericListLike(Type type) return GetElementType(type) != null; } - private static bool TryGetDictionaryArgs(Type type, out Type? keyType, out Type? valueType) + public static bool TryGetDictionaryArgs(Type type, out Type? keyType, out Type? valueType) { keyType = null; valueType = null; diff --git a/Datra.Tests/Datra.Tests.csproj b/Datra.Tests/Datra.Tests.csproj index e6e394c..21b5994 100644 --- a/Datra.Tests/Datra.Tests.csproj +++ b/Datra.Tests/Datra.Tests.csproj @@ -23,6 +23,7 @@ + @@ -49,4 +50,4 @@ - \ No newline at end of file + diff --git a/Datra.Tests/DatraWebEditorFieldHandlerTests.cs b/Datra.Tests/DatraWebEditorFieldHandlerTests.cs new file mode 100644 index 0000000..e32acc1 --- /dev/null +++ b/Datra.Tests/DatraWebEditorFieldHandlerTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Datra.Editor.Interfaces; +using Datra.Editor.Models; +using Datra.WebEditor.Abstractions; +using Datra.WebEditor.Handlers; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Xunit; + +namespace Datra.Tests +{ + public class DatraWebEditorFieldHandlerTests + { + [Fact] + public void RegisterDefaultHandlers_IncludesDictionaryHandler() + { + var registry = new BlazorFieldTypeRegistry(); + + registry.RegisterDefaultHandlers(); + + var handler = registry.FindBlazorHandler(typeof(Dictionary)); + Assert.IsType(handler); + } + + [Fact] + public void ListFieldHandler_PropagatesSourceMemberToElementHandler() + { + var registry = CreateRegistryWithCapturingHandler(out var capture); + var data = new TestEditorData { Effects = new List { "fx.spark" } }; + var prop = typeof(TestEditorData).GetProperty(nameof(TestEditorData.Effects))!; + var context = new FieldCreationContext(prop, data, data.Effects, FieldLayoutMode.Form, _ => { }); + + var handler = registry.FindBlazorHandler(prop.PropertyType, prop); + Assert.IsType(handler); + + Render(handler!.CreateField(context)); + + var elementContext = Assert.Single(capture.Contexts); + Assert.Same(prop, elementContext.SourceMember); + Assert.Equal(0, elementContext.CollectionElementIndex); + Assert.Equal("fx.spark", elementContext.CollectionElement); + Assert.Equal("Effects[0]", elementContext.FieldPath); + } + + [Fact] + public void ArrayFieldHandler_PropagatesSourceMemberToElementHandler() + { + var registry = CreateRegistryWithCapturingHandler(out var capture); + var data = new TestEditorData { Sprites = new[] { "sprite.hero" } }; + var prop = typeof(TestEditorData).GetProperty(nameof(TestEditorData.Sprites))!; + var context = new FieldCreationContext(prop, data, data.Sprites, FieldLayoutMode.Form, _ => { }); + + var handler = registry.FindBlazorHandler(prop.PropertyType, prop); + Assert.IsType(handler); + + Render(handler!.CreateField(context)); + + var elementContext = Assert.Single(capture.Contexts); + Assert.Same(prop, elementContext.SourceMember); + Assert.Equal(0, elementContext.CollectionElementIndex); + Assert.Equal("sprite.hero", elementContext.CollectionElement); + Assert.Equal("Sprites[0]", elementContext.FieldPath); + } + + [Fact] + public void DictionaryFieldHandler_PropagatesSourceMemberToValueHandlerOnly() + { + var registry = CreateRegistryWithCapturingHandler(out var capture); + var data = new TestEditorData + { + MaterialBySlot = new Dictionary + { + ["baseColor"] = "mat.wall" + } + }; + var prop = typeof(TestEditorData).GetProperty(nameof(TestEditorData.MaterialBySlot))!; + var context = new FieldCreationContext(prop, data, data.MaterialBySlot, FieldLayoutMode.Form, _ => { }); + + var handler = registry.FindBlazorHandler(prop.PropertyType, prop); + Assert.IsType(handler); + + Render(handler!.CreateField(context)); + + var valueContext = Assert.Single(capture.Contexts); + Assert.Same(prop, valueContext.SourceMember); + Assert.Equal(0, valueContext.CollectionElementIndex); + Assert.Equal("baseColor", valueContext.CollectionElementKey); + Assert.Equal("mat.wall", valueContext.CollectionElement); + Assert.Equal("MaterialBySlot[baseColor]", valueContext.FieldPath); + } + + private static BlazorFieldTypeRegistry CreateRegistryWithCapturingHandler(out CapturingAssetStringHandler capture) + { + var registry = new BlazorFieldTypeRegistry(); + capture = new CapturingAssetStringHandler(); + + registry.RegisterHandler(capture); + registry.RegisterHandler(new DictionaryFieldHandler(registry)); + registry.RegisterHandler(new ArrayFieldHandler(registry)); + registry.RegisterHandler(new ListFieldHandler(registry)); + registry.RegisterHandler(new StringFieldHandler()); + return registry; + } + + private static void Render(RenderFragment fragment) + { + var builder = new RenderTreeBuilder(); + fragment(builder); + } + + private sealed class CapturingAssetStringHandler : IBlazorFieldHandler + { + public List Contexts { get; } = new(); + public int Priority => 100; + + public bool CanHandle(Type type, MemberInfo? member = null) + => type == typeof(string) && member?.GetCustomAttribute() is not null; + + public RenderFragment CreateField(FieldCreationContext context) => builder => + { + Contexts.Add(context); + builder.AddContent(0, "asset"); + }; + } + + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)] + private sealed class TestAssetAttribute : Attribute + { + } + + private sealed class TestEditorData + { + [TestAsset] + public List Effects { get; set; } = new(); + + [TestAsset] + public string[] Sprites { get; set; } = Array.Empty(); + + [TestAsset] + public Dictionary MaterialBySlot { get; set; } = new(); + } + } +} diff --git a/Datra.Tests/FieldCreationContextTests.cs b/Datra.Tests/FieldCreationContextTests.cs index 197c5b0..8035f65 100644 --- a/Datra.Tests/FieldCreationContextTests.cs +++ b/Datra.Tests/FieldCreationContextTests.cs @@ -41,6 +41,23 @@ public void PropertyConstructor_SetsProperty() Assert.Same(prop, context.Property); } + [Fact] + public void PropertyConstructor_SetsSourceMemberAndFieldPath() + { + var prop = typeof(TestClass).GetProperty(nameof(TestClass.Name))!; + var target = new TestClass(); + + var context = new FieldCreationContext( + prop, + target, + "test value", + FieldLayoutMode.Form, + _ => { }); + + Assert.Same(prop, context.SourceMember); + Assert.Equal(nameof(TestClass.Name), context.FieldPath); + } + [Fact] public void PropertyConstructor_SetsTarget() { @@ -295,6 +312,24 @@ public void NestedMemberConstructor_PropertyIsNull() Assert.Null(context.Property); } + [Fact] + public void NestedMemberConstructor_SetsSourceMemberAndFieldPath() + { + var member = typeof(TestNestedStruct).GetField(nameof(TestNestedStruct.Value))!; + var parentValue = new TestNestedStruct { Value = 42 }; + + var context = new FieldCreationContext( + member, + typeof(int), + parentValue, + 42, + FieldLayoutMode.Form, + _ => { }); + + Assert.Same(member, context.SourceMember); + Assert.Equal(nameof(TestNestedStruct.Value), context.FieldPath); + } + #endregion #region Collection Element Constructor Tests @@ -377,6 +412,42 @@ public void CollectionElementConstructor_MemberIsNull() Assert.Null(context.Member); } + [Fact] + public void CollectionElementConstructor_CanCarrySourceMemberAndFieldPath() + { + var prop = typeof(TestClass).GetProperty(nameof(TestClass.Items))!; + + var context = new FieldCreationContext( + typeof(string), + "item1", + 3, + FieldLayoutMode.Table, + _ => { }, + sourceMember: prop, + fieldPath: "Items[3]"); + + Assert.Same(prop, context.SourceMember); + Assert.Equal("Items[3]", context.FieldPath); + Assert.True(context.IsCollectionElement); + } + + [Fact] + public void CollectionElementKey_DistinguishesDictionaryElementFromArrayElement() + { + var context = new FieldCreationContext( + typeof(string), + "item1", + 3, + FieldLayoutMode.Table, + _ => { }) + { + CollectionElementKey = "slot" + }; + + Assert.True(context.IsCollectionElement); + Assert.False(context.IsArrayElement); + } + #endregion #region WithValue Tests @@ -437,6 +508,31 @@ public void WithValue_CollectionContext_CreatesNewContextWithNewValue() Assert.Equal("original", context.Value); } + [Fact] + public void WithValue_PreservesSourceMetadata() + { + var prop = typeof(TestClass).GetProperty(nameof(TestClass.Items))!; + var context = new FieldCreationContext( + typeof(string), + "original", + 0, + FieldLayoutMode.Table, + _ => { }, + sourceMember: prop, + fieldPath: "Items[0]") + { + CollectionElementKey = "slot", + RootDataObject = new TestClass() + }; + + var newContext = context.WithValue("new value"); + + Assert.Same(prop, newContext.SourceMember); + Assert.Equal("Items[0]", newContext.FieldPath); + Assert.Equal("slot", newContext.CollectionElementKey); + Assert.Same(context.RootDataObject, newContext.RootDataObject); + } + #endregion #region WithLayoutMode Tests diff --git a/Datra.WebEditor/Handlers/ArrayFieldHandler.cs b/Datra.WebEditor/Handlers/ArrayFieldHandler.cs index ffc77c3..0042259 100644 --- a/Datra.WebEditor/Handlers/ArrayFieldHandler.cs +++ b/Datra.WebEditor/Handlers/ArrayFieldHandler.cs @@ -109,7 +109,7 @@ private void RenderItems(RenderTreeBuilder builder, int startSeq, FieldCreationC builder.AddContent(seq++, index.ToString()); builder.CloseElement(); - var handler = _registry.FindBlazorHandler(elementType); + var handler = _registry.FindBlazorHandler(elementType, context.SourceMember); builder.OpenElement(seq++, "div"); builder.AddAttribute(seq++, "class", "datra-list__field"); if (handler is not null) @@ -126,7 +126,13 @@ private void RenderItems(RenderTreeBuilder builder, int startSeq, FieldCreationC context.OnValueChanged?.Invoke(array); }, context.LocaleService, - context.IsReadOnly); + context.IsReadOnly, + context.SourceMember, + BuildElementPath(context, index)) + { + CollectionElement = item, + RootDataObject = context.RootDataObject + }; builder.AddContent(seq++, handler.CreateField(elementContext)); } else @@ -197,4 +203,10 @@ internal static string FormatElement(object? value) } return value.ToString() ?? string.Empty; } + + private static string BuildElementPath(FieldCreationContext context, int index) + { + var basePath = context.FieldPath ?? context.SourceMember?.Name; + return string.IsNullOrEmpty(basePath) ? $"[{index}]" : $"{basePath}[{index}]"; + } } diff --git a/Datra.WebEditor/Handlers/BlazorFieldTypeRegistry.cs b/Datra.WebEditor/Handlers/BlazorFieldTypeRegistry.cs index 91ecf33..6680113 100644 --- a/Datra.WebEditor/Handlers/BlazorFieldTypeRegistry.cs +++ b/Datra.WebEditor/Handlers/BlazorFieldTypeRegistry.cs @@ -28,6 +28,7 @@ public void RegisterDefaultHandlers() RegisterHandler(new LocaleRefFieldHandler()); // 100 — FixedLocale-attributed LocaleRef RegisterHandler(new DataRefFieldHandler()); // 40 — StringDataRef / IntDataRef RegisterHandler(new NestedTypeFieldHandler(this)); // 30 — complex class/struct + RegisterHandler(new DictionaryFieldHandler(this)); // 24 — IDictionary RegisterHandler(new ArrayFieldHandler(this)); // 22 — T[] 1-D arrays RegisterHandler(new ListFieldHandler(this)); // 20 — IList RegisterHandler(new EnumFieldHandler()); // 10 — Enum diff --git a/Datra.WebEditor/Handlers/DictionaryFieldHandler.cs b/Datra.WebEditor/Handlers/DictionaryFieldHandler.cs new file mode 100644 index 0000000..a7313af --- /dev/null +++ b/Datra.WebEditor/Handlers/DictionaryFieldHandler.cs @@ -0,0 +1,466 @@ +#nullable enable +using System; +using System.Collections; +using System.Collections.Generic; +using System.Globalization; +using System.Reflection; +using System.Text; +using Datra.Editor.Models; +using Datra.Editor.Schema; +using Datra.WebEditor.Abstractions; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Datra.WebEditor.Handlers; + +/// +/// Renders editors for generic dictionary fields. Keys are edited with plain type-based handlers; +/// values inherit the source member metadata so attribute-based handlers can customize dictionary +/// values in the same way they customize scalar fields. +/// +public sealed class DictionaryFieldHandler : IBlazorFieldHandler +{ + private readonly BlazorFieldTypeRegistry _registry; + + public DictionaryFieldHandler(BlazorFieldTypeRegistry registry) => _registry = registry; + + public int Priority => 24; + + public bool CanHandle(Type type, MemberInfo? member = null) + => TypeClassifier.Classify(type, member) == FieldKind.Dictionary; + + public RenderFragment CreateField(FieldCreationContext context) => builder => + { + if (!TypeClassifier.TryGetDictionaryArgs(context.FieldType, out var keyType, out var valueType) + || keyType is null + || valueType is null) + { + RenderMissing(builder, 0, context.FieldType.Name); + return; + } + + var entries = GetEntries(context.Value); + + if (context.LayoutMode == FieldLayoutMode.Table) + { + CollectionFieldRender.RenderCompact( + builder, _registry, context, valueType, entries.Count, + summary: BuildSummary(entries), + renderItems: (b, seq) => RenderItems(b, seq, context, keyType, valueType, entries), + onAdd: () => AddEntry(context, keyType, valueType)); + return; + } + + RenderExpanded(builder, context, keyType, valueType, entries); + }; + + private void RenderExpanded( + RenderTreeBuilder builder, + FieldCreationContext context, + Type keyType, + Type valueType, + IReadOnlyList entries) + { + builder.OpenElement(0, "div"); + builder.AddAttribute(1, "class", "datra-list datra-dictionary"); + + builder.OpenElement(2, "div"); + builder.AddAttribute(3, "class", "datra-list__header"); + + builder.OpenElement(4, "span"); + builder.AddAttribute(5, "class", "datra-list__count"); + builder.AddContent(6, entries.Count == 0 ? "empty" : $"{entries.Count} entr{(entries.Count == 1 ? "y" : "ies")}"); + builder.CloseElement(); + + if (!context.IsReadOnly) + { + builder.OpenElement(7, "button"); + builder.AddAttribute(8, "type", "button"); + builder.AddAttribute(9, "class", "datra-btn datra-btn--ghost datra-list__add"); + builder.AddAttribute(10, "onclick", EventCallback.Factory.Create(this, () => + AddEntry(context, keyType, valueType))); + builder.AddContent(11, "+ add"); + builder.CloseElement(); + } + + builder.CloseElement(); + + if (entries.Count > 0) + { + builder.OpenElement(12, "div"); + builder.AddAttribute(13, "class", "datra-list__items"); + RenderItems(builder, 14, context, keyType, valueType, entries); + builder.CloseElement(); + } + + builder.CloseElement(); + } + + private void RenderItems( + RenderTreeBuilder builder, + int startSeq, + FieldCreationContext context, + Type keyType, + Type valueType, + IReadOnlyList entries) + { + var keyHandler = _registry.FindBlazorHandler(keyType); + var valueHandler = _registry.FindBlazorHandler(valueType, context.SourceMember); + + var seq = startSeq; + for (var i = 0; i < entries.Count; i++) + { + var index = i; + var entry = entries[i]; + + builder.OpenElement(seq++, "div"); + builder.AddAttribute(seq++, "class", "datra-list__row datra-dictionary__row"); + + builder.OpenElement(seq++, "div"); + builder.AddAttribute(seq++, "class", "datra-dictionary__key"); + if (keyHandler is not null) + { + var keyContext = new FieldCreationContext( + keyType, + entry.Key, + index, + context.LayoutMode == FieldLayoutMode.Table ? FieldLayoutMode.Form : context.LayoutMode, + newKey => ChangeEntryKey(context, keyType, valueType, entry.Key, newKey), + context.LocaleService, + context.IsReadOnly, + sourceMember: null, + fieldPath: BuildDictionaryPath(context, entry.Key, "key")) + { + CollectionElementKey = entry.Key, + CollectionElement = entry.Key, + RootDataObject = context.RootDataObject + }; + builder.AddContent(seq++, keyHandler.CreateField(keyContext)); + } + else + { + RenderMissing(builder, seq++, keyType.Name); + } + builder.CloseElement(); + + builder.OpenElement(seq++, "span"); + builder.AddAttribute(seq++, "class", "datra-dictionary__arrow"); + builder.AddContent(seq++, "->"); + builder.CloseElement(); + + builder.OpenElement(seq++, "div"); + builder.AddAttribute(seq++, "class", "datra-list__field datra-dictionary__value"); + if (valueHandler is not null) + { + var valueContext = new FieldCreationContext( + valueType, + entry.Value, + index, + context.LayoutMode == FieldLayoutMode.Table ? FieldLayoutMode.Form : context.LayoutMode, + newValue => ChangeEntryValue(context, keyType, valueType, entry.Key, newValue), + context.LocaleService, + context.IsReadOnly, + context.SourceMember, + BuildDictionaryPath(context, entry.Key, null)) + { + CollectionElementKey = entry.Key, + CollectionElement = entry.Value, + RootDataObject = context.RootDataObject + }; + builder.AddContent(seq++, valueHandler.CreateField(valueContext)); + } + else + { + RenderMissing(builder, seq++, valueType.Name); + } + builder.CloseElement(); + + if (!context.IsReadOnly) + { + builder.OpenElement(seq++, "button"); + builder.AddAttribute(seq++, "type", "button"); + builder.AddAttribute(seq++, "class", "datra-btn datra-btn--danger datra-list__remove"); + builder.AddAttribute(seq++, "title", "remove"); + builder.AddAttribute(seq++, "onclick", EventCallback.Factory.Create(this, () => + RemoveEntry(context, keyType, valueType, entry.Key))); + builder.AddContent(seq++, "×"); + builder.CloseElement(); + } + + builder.CloseElement(); + } + } + + private static void AddEntry(FieldCreationContext context, Type keyType, Type valueType) + { + var dictionary = EnsureDictionary(context, keyType, valueType); + if (dictionary is null) return; + + var key = CreateUniqueKey(dictionary, keyType, valueType); + if (key is null && keyType.IsValueType) return; + + var value = DefaultValueFactory.CreateDefault(valueType); + if (TrySetValue(dictionary, keyType, valueType, key, value)) + context.OnValueChanged?.Invoke(dictionary); + } + + private static void ChangeEntryKey( + FieldCreationContext context, + Type keyType, + Type valueType, + object? oldKey, + object? newKey) + { + var dictionary = EnsureDictionary(context, keyType, valueType); + if (dictionary is null) return; + + var convertedOldKey = ConvertValue(oldKey, keyType); + var convertedNewKey = ConvertValue(newKey, keyType); + if (convertedNewKey is null && keyType.IsValueType) return; + if (Equals(convertedOldKey, convertedNewKey)) return; + if (ContainsKey(dictionary, keyType, valueType, convertedNewKey)) return; + + if (!TryGetValue(dictionary, keyType, valueType, convertedOldKey, out var value)) return; + if (!TryRemove(dictionary, keyType, valueType, convertedOldKey)) return; + if (TrySetValue(dictionary, keyType, valueType, convertedNewKey, value)) + context.OnValueChanged?.Invoke(dictionary); + } + + private static void ChangeEntryValue( + FieldCreationContext context, + Type keyType, + Type valueType, + object? key, + object? newValue) + { + var dictionary = EnsureDictionary(context, keyType, valueType); + if (dictionary is null) return; + + if (TrySetValue(dictionary, keyType, valueType, key, newValue)) + context.OnValueChanged?.Invoke(dictionary); + } + + private static void RemoveEntry(FieldCreationContext context, Type keyType, Type valueType, object? key) + { + var dictionary = context.Value; + if (dictionary is null) return; + + if (TryRemove(dictionary, keyType, valueType, key)) + context.OnValueChanged?.Invoke(dictionary); + } + + private static object? EnsureDictionary(FieldCreationContext context, Type keyType, Type valueType) + { + if (context.Value is not null) + return context.Value; + + var concreteType = context.FieldType.IsInterface || context.FieldType.IsAbstract + ? typeof(Dictionary<,>).MakeGenericType(keyType, valueType) + : context.FieldType; + + if (Activator.CreateInstance(concreteType) is not { } created) + return null; + + context.Value = created; + context.OnValueChanged?.Invoke(created); + return created; + } + + private static IReadOnlyList GetEntries(object? dictionary) + { + var entries = new List(); + if (dictionary is null) return entries; + + if (dictionary is IDictionary nonGeneric) + { + foreach (DictionaryEntry entry in nonGeneric) + entries.Add(new DictionaryEntrySnapshot(entry.Key, entry.Value)); + return entries; + } + + if (dictionary is IEnumerable enumerable) + { + foreach (var item in enumerable) + { + if (item is null) continue; + var itemType = item.GetType(); + var key = itemType.GetProperty("Key")?.GetValue(item); + var value = itemType.GetProperty("Value")?.GetValue(item); + entries.Add(new DictionaryEntrySnapshot(key, value)); + } + } + + return entries; + } + + private static bool TryGetValue( + object dictionary, + Type keyType, + Type valueType, + object? key, + out object? value) + { + value = null; + + if (dictionary is IDictionary nonGeneric) + { + var convertedKey = ConvertValue(key, keyType); + if (convertedKey is null) return false; + if (!nonGeneric.Contains(convertedKey)) return false; + value = nonGeneric[convertedKey]; + return true; + } + + var interfaceType = typeof(IDictionary<,>).MakeGenericType(keyType, valueType); + var method = interfaceType.GetMethod("TryGetValue"); + if (method is null) return false; + + var args = new[] { ConvertValue(key, keyType), null }; + var found = method.Invoke(dictionary, args) is true; + value = args[1]; + return found; + } + + private static bool ContainsKey(object dictionary, Type keyType, Type valueType, object? key) + { + var convertedKey = ConvertValue(key, keyType); + if (convertedKey is null) return false; + + if (dictionary is IDictionary nonGeneric) + return nonGeneric.Contains(convertedKey); + + var interfaceType = typeof(IDictionary<,>).MakeGenericType(keyType, valueType); + var method = interfaceType.GetMethod("ContainsKey"); + return method?.Invoke(dictionary, new[] { convertedKey }) is true; + } + + private static bool TrySetValue(object dictionary, Type keyType, Type valueType, object? key, object? value) + { + var convertedKey = ConvertValue(key, keyType); + if (convertedKey is null) + return false; + + var convertedValue = ConvertValue(value, valueType); + if (convertedValue is null && valueType.IsValueType) + convertedValue = DefaultValueFactory.CreateDefault(valueType); + + if (dictionary is IDictionary nonGeneric) + { + nonGeneric[convertedKey] = convertedValue; + return true; + } + + var interfaceType = typeof(IDictionary<,>).MakeGenericType(keyType, valueType); + var indexer = interfaceType.GetProperty("Item"); + if (indexer is null) return false; + + indexer.SetValue(dictionary, convertedValue, new[] { convertedKey }); + return true; + } + + private static bool TryRemove(object dictionary, Type keyType, Type valueType, object? key) + { + var convertedKey = ConvertValue(key, keyType); + if (convertedKey is null) + return false; + + if (dictionary is IDictionary nonGeneric) + { + if (!nonGeneric.Contains(convertedKey)) return false; + nonGeneric.Remove(convertedKey); + return true; + } + + var interfaceType = typeof(IDictionary<,>).MakeGenericType(keyType, valueType); + var method = interfaceType.GetMethod("Remove", new[] { keyType }); + return method?.Invoke(dictionary, new[] { convertedKey }) is true; + } + + private static object? ConvertValue(object? value, Type targetType) + { + if (value is null) + return null; + + targetType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (targetType.IsInstanceOfType(value)) + return value; + + if (targetType.IsEnum) + { + if (value is string enumText) + return Enum.Parse(targetType, enumText); + return Enum.ToObject(targetType, value); + } + + return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); + } + + private static object? CreateUniqueKey(object dictionary, Type keyType, Type valueType) + { + if (keyType == typeof(string)) + { + const string baseKey = "key"; + if (!ContainsKey(dictionary, keyType, valueType, baseKey)) + return baseKey; + + for (var i = 1; i < 1000; i++) + { + var candidate = $"{baseKey}{i}"; + if (!ContainsKey(dictionary, keyType, valueType, candidate)) + return candidate; + } + } + + if (keyType == typeof(int)) + { + for (var i = 0; i < 1000; i++) + { + if (!ContainsKey(dictionary, keyType, valueType, i)) + return i; + } + } + + var defaultKey = DefaultValueFactory.CreateDefault(keyType); + return ContainsKey(dictionary, keyType, valueType, defaultKey) ? null : defaultKey; + } + + private static string BuildSummary(IReadOnlyList entries) + { + if (entries.Count == 0) return "empty"; + + var sb = new StringBuilder(); + foreach (var entry in entries) + { + if (sb.Length > 0) sb.Append(", "); + sb.Append(ArrayFieldHandler.FormatElement(entry.Key)); + sb.Append('='); + sb.Append(ArrayFieldHandler.FormatElement(entry.Value)); + if (sb.Length > 40) + { + sb.Length = 37; + sb.Append("..."); + break; + } + } + return sb.ToString(); + } + + private static string BuildDictionaryPath(FieldCreationContext context, object? key, string? suffix) + { + var basePath = context.FieldPath ?? context.SourceMember?.Name; + var keyText = ArrayFieldHandler.FormatElement(key); + var path = string.IsNullOrEmpty(basePath) ? $"[{keyText}]" : $"{basePath}[{keyText}]"; + return string.IsNullOrEmpty(suffix) ? path : $"{path}.{suffix}"; + } + + private static void RenderMissing(RenderTreeBuilder builder, int seq, string typeName) + { + builder.OpenElement(seq, "span"); + builder.AddAttribute(seq + 1, "class", "datra-list__missing"); + builder.AddContent(seq + 2, $"no handler for {typeName}"); + builder.CloseElement(); + } + + private readonly record struct DictionaryEntrySnapshot(object? Key, object? Value); +} diff --git a/Datra.WebEditor/Handlers/ListFieldHandler.cs b/Datra.WebEditor/Handlers/ListFieldHandler.cs index 9a6eb0c..25fdabb 100644 --- a/Datra.WebEditor/Handlers/ListFieldHandler.cs +++ b/Datra.WebEditor/Handlers/ListFieldHandler.cs @@ -107,7 +107,7 @@ private void RenderItems(RenderTreeBuilder builder, int startSeq, FieldCreationC builder.AddContent(seq++, index.ToString()); builder.CloseElement(); - var handler = _registry.FindBlazorHandler(elementType); + var handler = _registry.FindBlazorHandler(elementType, context.SourceMember); builder.OpenElement(seq++, "div"); builder.AddAttribute(seq++, "class", "datra-list__field"); if (handler != null) @@ -124,7 +124,13 @@ private void RenderItems(RenderTreeBuilder builder, int startSeq, FieldCreationC context.OnValueChanged?.Invoke(list); }, context.LocaleService, - context.IsReadOnly); + context.IsReadOnly, + context.SourceMember, + BuildElementPath(context, index)) + { + CollectionElement = item, + RootDataObject = context.RootDataObject + }; builder.AddContent(seq++, handler.CreateField(elementContext)); } else @@ -180,4 +186,10 @@ private static string BuildSummary(IList? list, int count) } return sb.ToString(); } + + private static string BuildElementPath(FieldCreationContext context, int index) + { + var basePath = context.FieldPath ?? context.SourceMember?.Name; + return string.IsNullOrEmpty(basePath) ? $"[{index}]" : $"{basePath}[{index}]"; + } } diff --git a/Datra.WebEditor/Handlers/NestedTypeFieldHandler.cs b/Datra.WebEditor/Handlers/NestedTypeFieldHandler.cs index a4193f4..7fcc26b 100644 --- a/Datra.WebEditor/Handlers/NestedTypeFieldHandler.cs +++ b/Datra.WebEditor/Handlers/NestedTypeFieldHandler.cs @@ -71,7 +71,11 @@ public RenderFragment CreateField(FieldCreationContext context) => builder => context.OnValueChanged?.Invoke(obj); }, context.LocaleService, - context.IsReadOnly || !property.CanWrite); + context.IsReadOnly || !property.CanWrite) + { + FieldPath = BuildPropertyPath(context, property), + RootDataObject = context.RootDataObject + }; builder.AddContent(seq++, handler.CreateField(propContext)); } else @@ -88,4 +92,10 @@ public RenderFragment CreateField(FieldCreationContext context) => builder => builder.CloseElement(); // nested }; + + private static string BuildPropertyPath(FieldCreationContext context, PropertyInfo property) + { + var basePath = context.FieldPath ?? context.SourceMember?.Name; + return string.IsNullOrEmpty(basePath) ? property.Name : $"{basePath}.{property.Name}"; + } } diff --git a/Datra.WebEditor/wwwroot/datra-webeditor.css b/Datra.WebEditor/wwwroot/datra-webeditor.css index e46e4bf..c8d6459 100644 --- a/Datra.WebEditor/wwwroot/datra-webeditor.css +++ b/Datra.WebEditor/wwwroot/datra-webeditor.css @@ -373,6 +373,24 @@ font-style: italic; } +.datra-dictionary__row { + display: grid; + grid-template-columns: minmax(80px, 30%) auto minmax(120px, 1fr) auto; + align-items: center; +} +.datra-dictionary__key, +.datra-dictionary__value { + min-width: 0; +} +.datra-dictionary__key .datra-input { + font-family: var(--datra-font-mono); +} +.datra-dictionary__arrow { + color: var(--datra-text-faint); + font-family: var(--datra-font-mono); + font-size: var(--datra-font-size-xs); +} + /* ============================================================ * * Nested type handler * ============================================================ */