From 2bb79ef69fb172992640ded638dc067f4922eb78 Mon Sep 17 00:00:00 2001 From: rabbitism Date: Wed, 13 Nov 2024 02:24:21 +0800 Subject: [PATCH 1/2] feat: support dynamic form group generation, add demo. --- .../DataTemplates/FormDataTemplateSelector.cs | 24 +++++ demo/Ursa.Demo/Pages/FormDemo.axaml | 26 ++++++ .../Ursa.Demo/ViewModels/FormDemoViewModel.cs | 89 ++++++++++++++++--- src/Ursa.Themes.Semi/Controls/Form.axaml | 3 + src/Ursa/Controls/Form/Form.cs | 20 ++++- src/Ursa/Controls/Form/FormGroup.cs | 10 ++- 6 files changed, 159 insertions(+), 13 deletions(-) create mode 100644 demo/Ursa.Demo/DataTemplates/FormDataTemplateSelector.cs diff --git a/demo/Ursa.Demo/DataTemplates/FormDataTemplateSelector.cs b/demo/Ursa.Demo/DataTemplates/FormDataTemplateSelector.cs new file mode 100644 index 00000000..6351ec38 --- /dev/null +++ b/demo/Ursa.Demo/DataTemplates/FormDataTemplateSelector.cs @@ -0,0 +1,24 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Ursa.Demo.ViewModels; + +namespace Ursa.Demo.Converters; + +public class FormDataTemplateSelector: ResourceDictionary, IDataTemplate +{ + public Control? Build(object? param) + { + if (param is null) return null; + var type = param.GetType(); + if (this.TryGetResource(type, null, out var template) && template is IDataTemplate dataTemplate) + { + return dataTemplate.Build(param); + } + return null; + } + + public bool Match(object? data) + { + return data is IFromItemViewModel; + } +} \ No newline at end of file diff --git a/demo/Ursa.Demo/Pages/FormDemo.axaml b/demo/Ursa.Demo/Pages/FormDemo.axaml index a4a4c8d8..fcf23bf9 100644 --- a/demo/Ursa.Demo/Pages/FormDemo.axaml +++ b/demo/Ursa.Demo/Pages/FormDemo.axaml @@ -7,6 +7,7 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:u="https://irihi.tech/ursa" xmlns:vm="clr-namespace:Ursa.Demo.ViewModels;assembly=Ursa.Demo" + xmlns:converters="clr-namespace:Ursa.Demo.Converters" d:DesignHeight="450" d:DesignWidth="800" x:CompileBindings="True" @@ -50,6 +51,31 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/demo/Ursa.Demo/ViewModels/FormDemoViewModel.cs b/demo/Ursa.Demo/ViewModels/FormDemoViewModel.cs index 97e1873e..82970828 100644 --- a/demo/Ursa.Demo/ViewModels/FormDemoViewModel.cs +++ b/demo/Ursa.Demo/ViewModels/FormDemoViewModel.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.ObjectModel; using System.ComponentModel.DataAnnotations; using CommunityToolkit.Mvvm.ComponentModel; +using Irihi.Avalonia.Shared.Contracts; namespace Ursa.Demo.ViewModels; @@ -11,13 +13,48 @@ public partial class FormDemoViewModel : ObservableObject public FormDemoViewModel() { Model = new DataModel(); + FormGroups = new ObservableCollection + { + new FormGroupViewModel + { + Title = "Basic Information", + Items = new ObservableCollection + { + new FormTextViewModel { Label = "Name" }, + new FormAgeViewModel { Label = "Age" }, + new FormTextViewModel { Label = "Email" } + } + }, + new FormGroupViewModel + { + Title = "Education Information", + Items = new ObservableCollection + { + new FormTextViewModel { Label = "College" }, + new FormDateRangeViewModel { Label = "Study Time" } + } + }, + new FormTextViewModel(){ Label = "Other" } + }; } + + public ObservableCollection FormGroups { get; set; } } -public partial class DataModel : ObservableObject +public class DataModel : ObservableObject { + private DateTime _date; + + private string _email = string.Empty; private string _name = string.Empty; + private double _number; + + public DataModel() + { + Date = DateTime.Today; + } + [MinLength(10)] public string Name { @@ -25,8 +62,6 @@ public string Name set => SetProperty(ref _name, value); } - private double _number; - [Range(0.0, 10.0)] public double Number { @@ -34,8 +69,6 @@ public double Number set => SetProperty(ref _number, value); } - private string _email = string.Empty; - [EmailAddress] public string Email { @@ -43,16 +76,50 @@ public string Email set => SetProperty(ref _email, value); } - private DateTime _date; - public DateTime Date { get => _date; set => SetProperty(ref _date, value); } +} - public DataModel() - { - Date = DateTime.Today; - } +public interface IFormElement +{ + +} + +public interface IFormGroupViewModel : IFormGroup, IFormElement +{ + public string? Title { get; set; } + public ObservableCollection Items { get; set; } +} + +public interface IFromItemViewModel: IFormElement +{ + public string? Label { get; set; } +} + +public partial class FormGroupViewModel : ObservableObject, IFormGroupViewModel +{ + [ObservableProperty] private string? _title; + public ObservableCollection Items { get; set; } = []; +} + +public partial class FormTextViewModel : ObservableObject, IFromItemViewModel +{ + [ObservableProperty] private string? _label; + [ObservableProperty] private string? _value; +} + +public partial class FormAgeViewModel : ObservableObject, IFromItemViewModel +{ + [ObservableProperty] private uint? _age; + [ObservableProperty] private string? _label; +} + +public partial class FormDateRangeViewModel : ObservableObject, IFromItemViewModel +{ + [ObservableProperty] private DateTime? _end; + [ObservableProperty] private string? _label; + [ObservableProperty] private DateTime? _start; } \ No newline at end of file diff --git a/src/Ursa.Themes.Semi/Controls/Form.axaml b/src/Ursa.Themes.Semi/Controls/Form.axaml index 4a987bd8..24494597 100644 --- a/src/Ursa.Themes.Semi/Controls/Form.axaml +++ b/src/Ursa.Themes.Semi/Controls/Form.axaml @@ -70,6 +70,7 @@ @@ -111,6 +112,7 @@ Grid.Column="1" VerticalAlignment="Stretch" VerticalContentAlignment="Center" + ContentTemplate="{TemplateBinding ContentTemplate}" Content="{TemplateBinding Content}" /> @@ -120,6 +122,7 @@ diff --git a/src/Ursa/Controls/Form/Form.cs b/src/Ursa/Controls/Form/Form.cs index 8110b995..9812e811 100644 --- a/src/Ursa/Controls/Form/Form.cs +++ b/src/Ursa/Controls/Form/Form.cs @@ -2,6 +2,7 @@ using Avalonia.Controls; using Avalonia.Controls.Metadata; using Avalonia.Layout; +using Irihi.Avalonia.Shared.Contracts; using Ursa.Common; namespace Ursa.Controls; @@ -64,7 +65,11 @@ protected override bool NeedsContainerOverride(object? item, int index, out obje protected override Control CreateContainerForItemOverride(object? item, int index, object? recycleKey) { - if (item is not Control control) return new FormItem(); + if (item is not Control control) + { + if (item is IFormGroup) return new FormGroup(); + return new FormItem(); + } return new FormItem() { Content = control, @@ -73,4 +78,17 @@ protected override Control CreateContainerForItemOverride(object? item, int inde [!FormItem.NoLabelProperty] = control[!FormItem.NoLabelProperty], }; } + + protected override void PrepareContainerForItemOverride(Control container, object? item, int index) + { + base.PrepareContainerForItemOverride(container, item, index); + if(container is FormItem formItem && !formItem.IsSet(ContentControl.ContentTemplateProperty)) + { + formItem.ContentTemplate = ItemTemplate; + } + if (container is FormGroup group && !group.IsSet(FormGroup.ItemTemplateProperty)) + { + group.ItemTemplate = ItemTemplate; + } + } } \ No newline at end of file diff --git a/src/Ursa/Controls/Form/FormGroup.cs b/src/Ursa/Controls/Form/FormGroup.cs index 42803f9c..54a67c88 100644 --- a/src/Ursa/Controls/Form/FormGroup.cs +++ b/src/Ursa/Controls/Form/FormGroup.cs @@ -21,5 +21,13 @@ protected override Control CreateContainerForItemOverride(object? item, int inde [!FormItem.IsRequiredProperty] = control[!FormItem.IsRequiredProperty], }; } - + + protected override void PrepareContainerForItemOverride(Control container, object? item, int index) + { + base.PrepareContainerForItemOverride(container, item, index); + if (container is FormItem formItem && !formItem.IsSet(ContentControl.ContentTemplateProperty)) + { + formItem.ContentTemplate = ItemTemplate; + } + } } \ No newline at end of file From 14221eaa668cd23fcdf2b3278d80cb55cc846d8c Mon Sep 17 00:00:00 2001 From: rabbitism Date: Wed, 13 Nov 2024 03:58:39 +0800 Subject: [PATCH 2/2] test: add headless test. --- .../DataTemplateSelector.cs | 22 ++++ .../FormTests/Dynamic_Item_Generation/Test.cs | 124 ++++++++++++++++++ .../Dynamic_Item_Generation/TestViewModel.cs | 51 +++++++ .../Dynamic_Item_Generation/TestWindow.axaml | 51 +++++++ .../TestWindow.axaml.cs | 13 ++ 5 files changed, 261 insertions(+) create mode 100644 tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/DataTemplateSelector.cs create mode 100644 tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/Test.cs create mode 100644 tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestViewModel.cs create mode 100644 tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml create mode 100644 tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml.cs diff --git a/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/DataTemplateSelector.cs b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/DataTemplateSelector.cs new file mode 100644 index 00000000..e3176643 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/DataTemplateSelector.cs @@ -0,0 +1,22 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Metadata; + +namespace HeadlessTest.Ursa.Controls.FormTests.Dynamic_Item_Generation; + +internal class DataTemplateSelector : IDataTemplate +{ + [Content] public Dictionary Templates { get; } = new(); + + public Control? Build(object? param) + { + if (param is null) return null; + var type = param.GetType(); + return Templates.TryGetValue(type, out var template) ? template?.Build(param) : null; + } + + public bool Match(object? data) + { + return data is IFromItemViewModel; + } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/Test.cs b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/Test.cs new file mode 100644 index 00000000..ed0266ef --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/Test.cs @@ -0,0 +1,124 @@ +using System.Collections.ObjectModel; +using Avalonia.Controls; +using Avalonia.Headless.XUnit; +using Avalonia.LogicalTree; +using Ursa.Controls; + +namespace HeadlessTest.Ursa.Controls.FormTests.Dynamic_Item_Generation; + +public class Test +{ + [AvaloniaFact] + public void FormItem_Generation() + { + // Arrange + var viewModel = new TestViewModel(); + viewModel.Items = new ObservableCollection + { + new FormTextViewModel { Label = "First Name" }, + new FormTextViewModel { Label = "Last Name" }, + new FormAgeViewModel { Label = "Age" }, + new FormDateRangeViewModel { Label = "Date of Birth" } + }; + var window = new TestWindow { DataContext = viewModel }; + + // Act + window.Show(); + + var form = window.FindControl
("Form"); + var logicalChildren = form.GetLogicalChildren().ToList(); + Assert.True(logicalChildren.All(a=>a is FormItem)); + Assert.IsType(logicalChildren[0].LogicalChildren[0]); + Assert.IsType(logicalChildren[1].LogicalChildren[0]); + Assert.IsType(logicalChildren[2].LogicalChildren[0]); + Assert.IsType(logicalChildren[3].LogicalChildren[0]); + Assert.Equal("First Name", FormItem.GetLabel((Control)logicalChildren[0])); + Assert.Equal("Last Name", FormItem.GetLabel((Control)logicalChildren[1])); + Assert.Equal("Age", FormItem.GetLabel((Control)logicalChildren[2])); + Assert.Equal("Date of Birth", FormItem.GetLabel((Control)logicalChildren[3])); + + window.Close(); + } + + [AvaloniaFact] + public void FormGroup_Generation() + { + // Arrange + var viewModel = new TestViewModel(); + viewModel.Items = new ObservableCollection + { + new FormGroupViewModel + { + Title = "Basic Information", + Items = new ObservableCollection + { + new FormTextViewModel { Label = "First Name" }, + new FormTextViewModel { Label = "Last Name" }, + new FormAgeViewModel { Label = "Age" }, + new FormDateRangeViewModel { Label = "Date of Birth" } + } + } + }; + var window = new TestWindow { DataContext = viewModel }; + + // Act + window.Show(); + + var form = window.FindControl("Form"); + var logicalChildren = form.GetLogicalChildren().ToList(); + Assert.True(logicalChildren.All(a=>a is FormGroup)); + var formGroup = (FormGroup)logicalChildren[0]; + Assert.Equal("Basic Information", formGroup.Header); + var formItems = formGroup.GetLogicalChildren().ToList(); + Assert.True(formItems.All(a=>a is FormItem)); + Assert.IsType(formItems[0].LogicalChildren[0]); + Assert.IsType(formItems[1].LogicalChildren[0]); + Assert.IsType(formItems[2].LogicalChildren[0]); + Assert.IsType(formItems[3].LogicalChildren[0]); + Assert.Equal("First Name", FormItem.GetLabel((Control)formItems[0])); + Assert.Equal("Last Name", FormItem.GetLabel((Control)formItems[1])); + Assert.Equal("Age", FormItem.GetLabel((Control)formItems[2])); + Assert.Equal("Date of Birth", FormItem.GetLabel((Control)formItems[3])); + + window.Close(); + } + + [AvaloniaFact] + public void Mixture_Generation() + { + // Arrange + var viewModel = new TestViewModel(); + viewModel.Items = new ObservableCollection + { + new FormTextViewModel { Label = "First Name" }, + new FormGroupViewModel + { + Title = "Basic Information", + Items = new ObservableCollection + { + new FormTextViewModel { Label = "Last Name" }, + new FormAgeViewModel { Label = "Age" }, + new FormDateRangeViewModel { Label = "Date of Birth" } + } + } + }; + var window = new TestWindow { DataContext = viewModel }; + + // Act + window.Show(); + + var form = window.FindControl("Form"); + var logicalChildren = form.GetLogicalChildren().ToList(); + Assert.True(logicalChildren.All(a=>a is FormItem || a is FormGroup)); + Assert.IsType(logicalChildren[0].LogicalChildren[0]); + var formGroup = (FormGroup)logicalChildren[1]; + Assert.Equal("Basic Information", formGroup.Header); + var formItems = formGroup.GetLogicalChildren().ToList(); + Assert.True(formItems.All(a=>a is FormItem)); + Assert.IsType(formItems[0].LogicalChildren[0]); + Assert.IsType(formItems[1].LogicalChildren[0]); + Assert.IsType(formItems[2].LogicalChildren[0]); + + window.Close(); + } +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestViewModel.cs b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestViewModel.cs new file mode 100644 index 00000000..5e153490 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestViewModel.cs @@ -0,0 +1,51 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using Irihi.Avalonia.Shared.Contracts; + +namespace HeadlessTest.Ursa.Controls.FormTests.Dynamic_Item_Generation; + +public class TestViewModel +{ + public ObservableCollection Items { get; set; } = []; +} + +public interface IFormElement +{ + +} + +public interface IFormGroupViewModel : IFormGroup, IFormElement +{ + public string? Title { get; set; } + public ObservableCollection Items { get; set; } +} + +public interface IFromItemViewModel: IFormElement +{ + public string? Label { get; set; } +} + +public partial class FormGroupViewModel : ObservableObject, IFormGroupViewModel +{ + [ObservableProperty] private string? _title; + public ObservableCollection Items { get; set; } = []; +} + +public partial class FormTextViewModel : ObservableObject, IFromItemViewModel +{ + [ObservableProperty] private string? _label; + [ObservableProperty] private string? _value; +} + +public partial class FormAgeViewModel : ObservableObject, IFromItemViewModel +{ + [ObservableProperty] private uint? _age; + [ObservableProperty] private string? _label; +} + +public partial class FormDateRangeViewModel : ObservableObject, IFromItemViewModel +{ + [ObservableProperty] private DateTime? _end; + [ObservableProperty] private string? _label; + [ObservableProperty] private DateTime? _start; +} \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml new file mode 100644 index 00000000..c65b85e0 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml.cs b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml.cs new file mode 100644 index 00000000..f85d2fa6 --- /dev/null +++ b/tests/HeadlessTest.Ursa/Controls/FormTests/Dynamic_Item_Generation/TestWindow.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace HeadlessTest.Ursa.Controls.FormTests.Dynamic_Item_Generation; + +public partial class TestWindow : Window +{ + public TestWindow() + { + InitializeComponent(); + } +} \ No newline at end of file