diff --git a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor index 7a4262a..f834154 100644 --- a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor +++ b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor @@ -1,8 +1,15 @@ @inherits ComponentBase -@if (ViewModel.State is not null) -{ +
+ +

@ViewModel.NameOnPage

+

Current count: @ViewModel.State.Count

-} + + @ViewModel.State.Text + + @Description + +
diff --git a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor.cs b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor.cs new file mode 100644 index 0000000..7efecf0 --- /dev/null +++ b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor.cs @@ -0,0 +1,9 @@ +using Microsoft.AspNetCore.Components; + +namespace BitzArt.Blazor.MVVM.SampleApp.Client.Components; + +public partial class Counter : ComponentBase +{ + [Parameter, EditorRequired] + public string? Description { get; set; } +} diff --git a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor.css b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor.css new file mode 100644 index 0000000..a7b4a93 --- /dev/null +++ b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Components/Counter.razor.css @@ -0,0 +1,12 @@ +.card { + box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2); + transition: 0.3s; + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 2rem; + width: 400px; +} + +.card:hover { + box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2); +} diff --git a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Pages/CounterPage.razor b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Pages/CounterPage.razor index 0c74ee3..074f1be 100644 --- a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Pages/CounterPage.razor +++ b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/Pages/CounterPage.razor @@ -4,13 +4,29 @@ Counter -

Counter

-

Current render mode: @RenderingEnvironment

@ViewModel.State?.Text

- - + + +@if (RenderingEnvironment.IsPrerender) +{ +

+ Awaiting interactivity... +

+} +else +{ + + + +} diff --git a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterPageViewModel.cs b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterPageViewModel.cs index 1083322..9146abe 100644 --- a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterPageViewModel.cs +++ b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterPageViewModel.cs @@ -1,25 +1,32 @@ -using Microsoft.AspNetCore.Components; +namespace BitzArt.Blazor.MVVM.SampleApp; -namespace BitzArt.Blazor.MVVM.SampleApp; - -public class CounterPageViewModel( - RenderingEnvironment renderingEnvironment) +public class CounterPageViewModel(RenderingEnvironment renderingEnvironment) : ViewModel { - [Inject] + [NestViewModel] public CounterViewModel Counter1 { get; set; } = null!; - [Inject] + [NestViewModel] public CounterViewModel Counter2 { get; set; } = null!; + [NestViewModel] + public CounterViewModel Counter3 { get; set; } = null!; + public override void InitializeState() { - State.Text = $"ViewModel State initialized on: {renderingEnvironment}"; - Counter2.State!.Count = 100; + State.Text = $"Page State initialized on: {renderingEnvironment}"; + } + + protected override void OnDependenciesInjected() + { + Counter2.OnStateInitialized += (_) => + { + Counter2.State!.Count += 100; + }; } } -public class CounterPageViewModelState +public class CounterPageViewModelState : ComponentState { - public string Text { get; set; } = "State not initialized"; + public string Text { get; set; } = "Page State not initialized"; } diff --git a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterViewModel.cs b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterViewModel.cs index aca7fd3..fdbc2e9 100644 --- a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterViewModel.cs +++ b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp.Client/ViewModels/CounterViewModel.cs @@ -1,37 +1,50 @@ namespace BitzArt.Blazor.MVVM.SampleApp; -public class CounterViewModel : ViewModel +public class CounterViewModel(RenderingEnvironment renderingEnvironment) + : ViewModel, IDisposable { - private readonly Timer _timer; + [ParentViewModel] + public CounterPageViewModel Parent { get; set; } = null!; - public CounterViewModel() + private Timer? _timer; + + public string NameOnPage { - _timer = new Timer(TimerIncrementCount, null, 1000, 1000); + get + { + if (this == Parent.Counter1) return "Counter 1"; + if (this == Parent.Counter2) return "Counter 2"; + if (this == Parent.Counter3) return "Counter 3"; + return string.Empty; + } } public override void InitializeState() { - State.Count = 0; + State.Text = $"Counter State initialized on: {renderingEnvironment}"; } - private void TimerIncrementCount(object? state) + protected override void OnDependenciesInjected() { - if (State is null) return; + if (this == Parent.Counter3) _timer = new Timer(IncrementCount, null, 1000, 1000); + } + public void IncrementCount(object? state = null) + { State.Count++; ComponentStateHasChanged(); } - public void IncrementCount() + public void Dispose() { - if (State is null) return; - - State.Count++; - ComponentStateHasChanged(); + _timer?.Dispose(); + GC.SuppressFinalize(this); } } -public class CounterState +public class CounterState : ComponentState { - public int? Count { get; set; } + public int Count { get; set; } = 0; + + public string Text { get; set; } = "Counter State not initialized"; } diff --git a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp/Components/Layout/NavMenu.razor b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp/Components/Layout/NavMenu.razor index 71b85e3..038b1a1 100644 --- a/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp/Components/Layout/NavMenu.razor +++ b/sample/BitzArt.Blazor.MVVM.SampleApp/BitzArt.Blazor.MVVM.SampleApp/Components/Layout/NavMenu.razor @@ -1,6 +1,6 @@  diff --git a/src/BitzArt.Blazor.MVVM/Attributes/NestViewModelAttribute.cs b/src/BitzArt.Blazor.MVVM/Attributes/NestViewModelAttribute.cs new file mode 100644 index 0000000..a2e579f --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Attributes/NestViewModelAttribute.cs @@ -0,0 +1,6 @@ +namespace BitzArt.Blazor.MVVM; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class NestViewModelAttribute : Attribute +{ +} diff --git a/src/BitzArt.Blazor.MVVM/Attributes/ParentViewModelAttribute.cs b/src/BitzArt.Blazor.MVVM/Attributes/ParentViewModelAttribute.cs new file mode 100644 index 0000000..61bec30 --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Attributes/ParentViewModelAttribute.cs @@ -0,0 +1,7 @@ +namespace BitzArt.Blazor.MVVM; + +// TODO: Implement ParentAttribute +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)] +public sealed class ParentViewModelAttribute : Attribute +{ +} diff --git a/src/BitzArt.Blazor.MVVM/Builder/BlazorMvvmBuilder.cs b/src/BitzArt.Blazor.MVVM/Builder/BlazorMvvmBuilder.cs index 1c5b144..3f8d439 100644 --- a/src/BitzArt.Blazor.MVVM/Builder/BlazorMvvmBuilder.cs +++ b/src/BitzArt.Blazor.MVVM/Builder/BlazorMvvmBuilder.cs @@ -6,7 +6,7 @@ public interface IBlazorMvvmBuilder { public IServiceCollection ServiceCollection { get; } - public IViewModelFactory Factory { get; } + internal IViewModelFactory Factory { get; } } internal class BlazorMvvmBuilder : IBlazorMvvmBuilder @@ -27,5 +27,6 @@ public BlazorMvvmBuilder( ServiceCollection.AddSingleton(Factory); ServiceCollection.AddSingleton(); + ServiceCollection.AddScoped(); } } diff --git a/src/BitzArt.Blazor.MVVM/Components/ComponentBase{TViewModel}.cs b/src/BitzArt.Blazor.MVVM/Components/ComponentBase{TViewModel}.cs new file mode 100644 index 0000000..d658934 --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Components/ComponentBase{TViewModel}.cs @@ -0,0 +1,95 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BitzArt.Blazor.MVVM; + +public abstract class ComponentBase : ComponentBase, IStateComponent + where TViewModel : ViewModel +{ + [Parameter, EditorRequired] + public TViewModel ViewModel { get; set; } = null!; + + [Inject] + protected IServiceProvider ServiceProvider { get; set; } = null!; + + [Inject] + protected IJSRuntime Js { get; set; } = default!; + + [Inject] + internal PageStateDictionaryContainer PageStateDictionaryContainer { get; set; } = null!; + + [Inject] + private protected BlazorViewModelStateManager StateManager { get; set; } = null!; + + [Inject] + protected RenderingEnvironment RenderingEnvironment { get; set; } = null!; + + /// + /// Navigation manager. + /// + [Inject] + protected NavigationManager NavigationManager { get; set; } = null!; + + /// + /// Method invoked when the component is ready to start, having received its initial + /// parameters from its parent in the render tree. Override this method if you will + /// perform an asynchronous operation and want the component to refresh when that + /// operation is completed. + /// + /// A representing any asynchronous operation. + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + ViewModel.OnComponentStateChanged += async (_) => + { + await InvokeAsync(StateHasChanged); + }; + + await SetupStateAsync(); + } + + private async Task SetupStateAsync() + { + var shouldRestoreState = ShouldRestoreState(); + + if (shouldRestoreState) await RestoreStateAsync(); + else await InitializeStateAsync(); + } + + protected virtual bool ShouldRestoreState() + { + return !RenderingEnvironment.IsPrerender; + } + + protected virtual async Task InitializeStateAsync() + { + if (ViewModel is not IStatefulViewModel statefulViewModel) return; + + await StateManager.InitializeStateAsync(ViewModel); + + statefulViewModel.State.IsInitialized = true; + + statefulViewModel.OnStateInitialized?.Invoke(ViewModel); + statefulViewModel.OnStateInitializedAsync?.Invoke(ViewModel); + + StateHasChanged(); + } + + protected virtual async Task RestoreStateAsync() + { + if (ViewModel is not IStatefulViewModel statefulViewModel) return; + + await PageStateDictionaryContainer!.WaitUntilConfiguredAsync(); + var state = PageStateDictionaryContainer!.PageStateDictionary!.Get(ViewModel.Signature); + if (state is null) + { + await InitializeStateAsync(); + return; + } + if (state!.GetType() != statefulViewModel.StateType) throw new InvalidOperationException("State type mismatch."); + statefulViewModel.State = state; + statefulViewModel.State.IsInitialized = true; + } + + void IStateComponent.StateHasChanged() => InvokeAsync(StateHasChanged); +} diff --git a/src/BitzArt.Blazor.MVVM/State/ComponentStateContainer.razor b/src/BitzArt.Blazor.MVVM/Components/ComponentStateContainer.razor similarity index 100% rename from src/BitzArt.Blazor.MVVM/State/ComponentStateContainer.razor rename to src/BitzArt.Blazor.MVVM/Components/ComponentStateContainer.razor diff --git a/src/BitzArt.Blazor.MVVM/State/ComponentStateContainer.razor.cs b/src/BitzArt.Blazor.MVVM/Components/ComponentStateContainer.razor.cs similarity index 100% rename from src/BitzArt.Blazor.MVVM/State/ComponentStateContainer.razor.cs rename to src/BitzArt.Blazor.MVVM/Components/ComponentStateContainer.razor.cs diff --git a/src/BitzArt.Blazor.MVVM/Models/PageBase.cs b/src/BitzArt.Blazor.MVVM/Components/PageBase{TViewModel}.cs similarity index 63% rename from src/BitzArt.Blazor.MVVM/Models/PageBase.cs rename to src/BitzArt.Blazor.MVVM/Components/PageBase{TViewModel}.cs index 95e1e4d..d1b2824 100644 --- a/src/BitzArt.Blazor.MVVM/Models/PageBase.cs +++ b/src/BitzArt.Blazor.MVVM/Components/PageBase{TViewModel}.cs @@ -9,7 +9,7 @@ namespace BitzArt.Blazor.MVVM; /// Blazor page base class with view model support. /// /// Type of this component's ViewModel -public abstract class PageBase : ComponentBase, IStateComponent +public abstract class PageBase : ComponentBase, IStateComponent, IDisposable where TViewModel : ViewModel { [Inject] @@ -19,33 +19,19 @@ public abstract class PageBase : ComponentBase, IStateCo set => base.ViewModel = value; } - [Inject] private BlazorViewModelStateManager StateManager { get; set; } = null!; - private const string StateKey = "state"; protected override async Task RestoreStateAsync() { - await RestoreComponentStateAsync(ViewModel); - } - - private async Task RestoreComponentStateAsync(ViewModel viewModel) - { - var isPrerender = RenderingEnvironment.IsPrerender; - var state = isPrerender - ? null - : await Js.InvokeAsync("getInnerText", [StateKey]); + var state = await Js.InvokeAsync("getInnerText", [StateKey]); + var buffer = Convert.FromBase64String(state!); + var json = Encoding.UTF8.GetString(buffer); - if (state is not null) - { - var buffer = Convert.FromBase64String(state); - var json = Encoding.UTF8.GetString(buffer); + var pageState = await StateManager.RestoreStateAsync(ViewModel, json); + PageStateDictionaryContainer.PageStateDictionary = pageState; + PageStateDictionaryContainer.MarkConfigured(); - await StateManager.RestoreStateAsync(viewModel, json); - } - else - { - await StateManager.InitializeStateAsync(viewModel); - } + await base.RestoreStateAsync(); } /// @@ -57,6 +43,11 @@ public override Task SetParametersAsync(ParameterView parameters) return base.SetParametersAsync(parameters); } + + public void Dispose() + { + PageStateDictionaryContainer.Dispose(); + } } internal static class StateJsonOptionsProvider diff --git a/src/BitzArt.Blazor.MVVM/Extensions/AddViewModelExtensions.cs b/src/BitzArt.Blazor.MVVM/Extensions/AddViewModelExtensions.cs index 3882b42..aa233b2 100644 --- a/src/BitzArt.Blazor.MVVM/Extensions/AddViewModelExtensions.cs +++ b/src/BitzArt.Blazor.MVVM/Extensions/AddViewModelExtensions.cs @@ -72,7 +72,7 @@ public static IBlazorMvvmBuilder AddViewModel(this IBlazorMvvmBuilder builder, T builder.ServiceCollection.AddTransient(viewModelType, serviceProvider => { var factory = serviceProvider.GetRequiredService(); - return factory.Create(serviceProvider, viewModelType); + return factory.Create(serviceProvider, viewModelType, new RootComponentSignature()); }); return builder; @@ -92,7 +92,7 @@ public static IBlazorMvvmBuilder AddViewModel(this IBlazorMvvmBuilde builder.ServiceCollection.AddTransient(serviceProvider => { var factory = serviceProvider.GetRequiredService(); - return factory.Create(serviceProvider); + return factory.Create(serviceProvider, new RootComponentSignature()); }); return builder; diff --git a/src/BitzArt.Blazor.MVVM/Factory/ViewModelFactory.cs b/src/BitzArt.Blazor.MVVM/Factory/ViewModelFactory.cs index 11d6a4d..6717293 100644 --- a/src/BitzArt.Blazor.MVVM/Factory/ViewModelFactory.cs +++ b/src/BitzArt.Blazor.MVVM/Factory/ViewModelFactory.cs @@ -2,12 +2,12 @@ namespace BitzArt.Blazor.MVVM; -public interface IViewModelFactory +internal interface IViewModelFactory { public void AddViewModel(Type viewModelType, string registrationKey); - public ViewModel Create(IServiceProvider serviceProvider, Type viewModelType); - public TViewModel Create(IServiceProvider serviceProvider) where TViewModel : ViewModel; + public TViewModel Create(IServiceProvider serviceProvider, ComponentSignature? signature, ViewModel? parent = null) where TViewModel : ViewModel; + public ViewModel Create(IServiceProvider serviceProvider, Type viewModelType, ComponentSignature? signature, ViewModel? parent = null, List? affectedViewModels = null); } internal class ViewModelFactory : IViewModelFactory @@ -30,22 +30,49 @@ public void AddViewModel(Type viewModelType, string registrationKey) InjectionMaps.Add(viewModelType, new(viewModelType, registrationKey)); } - public TViewModel Create(IServiceProvider serviceProvider) + public TViewModel Create(IServiceProvider serviceProvider, ComponentSignature? signature, ViewModel? parent = null) where TViewModel : ViewModel - => (TViewModel)Create(serviceProvider, typeof(TViewModel)); + => (TViewModel)Create(serviceProvider, typeof(TViewModel), signature, parent); - public ViewModel Create(IServiceProvider serviceProvider, Type viewModelType) + public ViewModel Create(IServiceProvider serviceProvider, Type viewModelType, ComponentSignature? signature, ViewModel? parent = null, List? affectedViewModels = null) { + signature ??= new RootComponentSignature(); + var isRoot = signature is RootComponentSignature; + affectedViewModels ??= []; + var viewModelMap = InjectionMaps.GetValueOrDefault(viewModelType) ?? throw new InvalidOperationException($"ViewModel {viewModelType.Name} is not registered in the factory."); var viewModel = (ViewModel)serviceProvider.GetRequiredKeyedService(typeof(ViewModel), viewModelMap.RegistrationKey); + viewModel.Signature = signature; + foreach (var injection in viewModelMap.Injections) { - var injectedViewModel = Create(serviceProvider, injection.ViewModelType); - injection.Property.SetValue(viewModel, injectedViewModel); + if (injection.IsServiceInjection) + { + var injectedDependency = serviceProvider.GetRequiredService(injection.DependencyType); + injection.Property.SetValue(viewModel, injectedDependency); + } + + else if (injection.IsNestedViewModelInjection) + { + var nestedSignature = new ComponentSignature(parent: signature); + var injectedViewModel = Create(serviceProvider, injection.DependencyType, nestedSignature, parent: viewModel, affectedViewModels: affectedViewModels); + injection.Property.SetValue(viewModel, injectedViewModel); + } + + else if (injection.IsParentViewModelInjection) + { + if (parent is null) throw new InvalidOperationException( + $"Parent injection '{injection.Property.Name}' in {viewModelType.Name} requires a parent ViewModel, but no parent was found in current ViewModel hierarchy."); + injection.Property.SetValue(viewModel, parent); + } } - viewModel.OnDependenciesInjected(); + + affectedViewModels.Add(viewModel); + + if (isRoot) foreach (var affected in affectedViewModels) affected.OnDependenciesInjected(); + return viewModel; } diff --git a/src/BitzArt.Blazor.MVVM/Factory/ViewModelInjection.cs b/src/BitzArt.Blazor.MVVM/Factory/ViewModelInjection.cs deleted file mode 100644 index 7312788..0000000 --- a/src/BitzArt.Blazor.MVVM/Factory/ViewModelInjection.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Reflection; - -namespace BitzArt.Blazor.MVVM; - -internal class ViewModelInjection -{ - public PropertyInfo Property { get; set; } - - public Type ViewModelType { get; set; } - - public ViewModelInjection(PropertyInfo property, Type viewModelType) - { - Property = property; - ViewModelType = viewModelType; - } -} \ No newline at end of file diff --git a/src/BitzArt.Blazor.MVVM/Factory/ViewModelInjectionMap.cs b/src/BitzArt.Blazor.MVVM/Factory/ViewModelInjectionMap.cs deleted file mode 100644 index 136fd3c..0000000 --- a/src/BitzArt.Blazor.MVVM/Factory/ViewModelInjectionMap.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.AspNetCore.Components; -using System.Reflection; - -namespace BitzArt.Blazor.MVVM; - -internal class ViewModelInjectionMap -{ - public Type ViewModelType { get; set; } - - public string RegistrationKey { get; set; } - - public IEnumerable Injections { get; set; } - - public ViewModelInjectionMap(Type viewModelType, string registrationKey) - { - ViewModelType = viewModelType; - Injections = ParseInjections(viewModelType); - RegistrationKey = registrationKey; - } - - private static IEnumerable ParseInjections(Type viewModelType) - { - var properties = viewModelType.GetProperties() - .Where(x => x.GetCustomAttribute() is not null) - .Where(x => typeof(ViewModel).IsAssignableFrom(x.PropertyType)); - - return properties.Select(x => new ViewModelInjection(x, x.PropertyType)); - } -} diff --git a/src/BitzArt.Blazor.MVVM/Interfaces/IStatefulViewModel.cs b/src/BitzArt.Blazor.MVVM/Interfaces/IStatefulViewModel.cs index f53179e..53a3570 100644 --- a/src/BitzArt.Blazor.MVVM/Interfaces/IStatefulViewModel.cs +++ b/src/BitzArt.Blazor.MVVM/Interfaces/IStatefulViewModel.cs @@ -3,7 +3,7 @@ internal interface IStatefulViewModel { public Type StateType { get; } - public object State { get; set; } + public ComponentState State { get; set; } public void InitializeState(); public Task InitializeStateAsync(); @@ -13,4 +13,7 @@ internal interface IStatefulViewModel public void OnStateChanged(); public Task OnStateChangedAsync(); + + public StateInitializedHandler? OnStateInitialized { get; set; } + public StateInitializedAsyncHandler? OnStateInitializedAsync { get; set; } } diff --git a/src/BitzArt.Blazor.MVVM/Models/ComponentBase{TViewModel}.cs b/src/BitzArt.Blazor.MVVM/Models/ComponentBase{TViewModel}.cs deleted file mode 100644 index 4ee77cd..0000000 --- a/src/BitzArt.Blazor.MVVM/Models/ComponentBase{TViewModel}.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Components; -using Microsoft.JSInterop; -using static BitzArt.Blazor.MVVM.ViewModel; - -namespace BitzArt.Blazor.MVVM; - -public abstract class ComponentBase : ComponentBase, IStateComponent - where TViewModel : ViewModel -{ - [Parameter, EditorRequired] - public TViewModel ViewModel { get; set; } = null!; - - [Inject] - protected IServiceProvider ServiceProvider { get; set; } = null!; - - [Inject] - protected IJSRuntime Js { get; set; } = default!; - - [Inject] - protected RenderingEnvironment RenderingEnvironment { get; set; } = null!; - - /// - /// Navigation manager. - /// - [Inject] - protected NavigationManager NavigationManager { get; set; } = null!; - - /// - /// Method invoked when the component is ready to start, having received its initial - /// parameters from its parent in the render tree. Override this method if you will - /// perform an asynchronous operation and want the component to refresh when that - /// operation is completed. - /// - /// A representing any asynchronous operation. - protected override async Task OnInitializedAsync() - { - await base.OnInitializedAsync(); - ViewModel.OnStateChange += async (_, args) => - { - await InvokeAsync(StateHasChanged); - return args!; - }; - - await RestoreStateAsync(); - } - - protected virtual Task RestoreStateAsync() => Task.CompletedTask; - - void IStateComponent.StateHasChanged() => InvokeAsync(StateHasChanged); -} diff --git a/src/BitzArt.Blazor.MVVM/Models/ComponentSignature.cs b/src/BitzArt.Blazor.MVVM/Models/ComponentSignature.cs new file mode 100644 index 0000000..c8b0752 --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Models/ComponentSignature.cs @@ -0,0 +1,25 @@ +namespace BitzArt.Blazor.MVVM; + +internal class RootComponentSignature : ComponentSignature +{ + public override bool IsRoot() => true; + + public RootComponentSignature() : base(null) { } +} + +internal class ComponentSignature +{ + public virtual bool IsRoot() => false; + + public virtual ComponentSignature? ParentSignature { get; set; } + + public ComponentSignature(ComponentSignature? parent) + { + ParentSignature = parent; + } + + public ComponentSignature NestNew() + { + return new ComponentSignature(parent: this); + } +} diff --git a/src/BitzArt.Blazor.MVVM/Models/ComponentState.cs b/src/BitzArt.Blazor.MVVM/Models/ComponentState.cs new file mode 100644 index 0000000..2584311 --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Models/ComponentState.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace BitzArt.Blazor.MVVM; + +public abstract class ComponentState +{ + [JsonIgnore] + public bool IsInitialized { get; internal set; } +} diff --git a/src/BitzArt.Blazor.MVVM/Models/PageStateDictionary.cs b/src/BitzArt.Blazor.MVVM/Models/PageStateDictionary.cs new file mode 100644 index 0000000..93feee5 --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Models/PageStateDictionary.cs @@ -0,0 +1,61 @@ +namespace BitzArt.Blazor.MVVM; + +internal class PageStateDictionaryContainer : IDisposable +{ + public PageStateDictionary? PageStateDictionary { get; set; } + + private bool Configured { get; set; } = false; + + private readonly List waitingUntilConfigured = []; + + public void Dispose() + { + PageStateDictionary = null; + Configured = false; + } + + public void MarkConfigured() + { + Configured = true; + while (waitingUntilConfigured.Count > 0) + { + var cts = waitingUntilConfigured.First(); + waitingUntilConfigured.Remove(cts); + cts.Cancel(); + } + } + + public async Task WaitUntilConfiguredAsync() + { + if (Configured) return; + + var waitHandle = new AutoResetEvent(false); + var cts = new CancellationTokenSource(); + + waitingUntilConfigured.Add(cts); + cts.Token.Register(() => + { + waitHandle.Set(); + }); + + await Task.Run(cts.Token.WaitHandle.WaitOne); + } +} + +internal class PageStateDictionary +{ + private Dictionary ComponentStates { get; } + + public PageStateDictionary() + { + ComponentStates = []; + } + + public void Add(ComponentSignature signature, ComponentState? state) + { + ComponentStates.Add(signature, state); + } + + public ComponentState? Get(ComponentSignature signature) + => ComponentStates.GetValueOrDefault(signature); +} diff --git a/src/BitzArt.Blazor.MVVM/Models/ViewModel.cs b/src/BitzArt.Blazor.MVVM/Models/ViewModel.cs index 9cc124e..55bf1a3 100644 --- a/src/BitzArt.Blazor.MVVM/Models/ViewModel.cs +++ b/src/BitzArt.Blazor.MVVM/Models/ViewModel.cs @@ -1,26 +1,42 @@ -namespace BitzArt.Blazor.MVVM; +using Microsoft.AspNetCore.Components; + +namespace BitzArt.Blazor.MVVM; /// /// ViewModel base class. /// public abstract class ViewModel { - public class StateChangeEventArgs : EventArgs { } - public delegate Task StateHasChangedHandler(object sender, StateChangeEventArgs? args); - public StateHasChangedHandler? OnStateChange { get; set; } + [Inject] + private protected IViewModelFactory ViewModelFactory { get; set; } = null!; + + [Inject] + private protected IServiceProvider ServiceProvider { get; set; } = null!; + + internal ComponentSignature Signature { get; set; } = null!; + + protected TViewModel CreateNestedViewModel() + where TViewModel : ViewModel + { + var nestedSignature = Signature.NestNew(); + return ViewModelFactory.Create(ServiceProvider, nestedSignature); + } + + public delegate Task ComponentStateHasChangedHandler(object sender); + public ComponentStateHasChangedHandler? OnComponentStateChanged { get; set; } /// /// Notifies the component that the state has changed. /// - protected void ComponentStateHasChanged(StateChangeEventArgs? args = null) + protected void ComponentStateHasChanged() { - OnStateChange?.Invoke(this, args); + OnComponentStateChanged?.Invoke(this); } /// /// Called when this ViewModel's dependencies have been injected. /// - protected internal void OnDependenciesInjected() { } + protected internal virtual void OnDependenciesInjected() { } } /// @@ -28,13 +44,49 @@ protected internal void OnDependenciesInjected() { } /// /// public abstract class ViewModel : ViewModel, IStatefulViewModel - where TState : class, new() + where TState : ComponentState, new() { public ViewModel() { State = new(); } + public bool IsStateInitialized { get; set; } + + private StateInitializedHandler _onStateInitializedHandler = null!; + public StateInitializedHandler? OnStateInitialized + { + get => _onStateInitializedHandler; + set + { + var initialValue = _onStateInitializedHandler; + + _onStateInitializedHandler = value!; + if (IsStateInitialized) + { + var diff = value - initialValue; + diff!.Invoke(this); + } + } + } + + private StateInitializedAsyncHandler _onStateInitializedAsyncHandler = null!; + public StateInitializedAsyncHandler? OnStateInitializedAsync + { + get => _onStateInitializedAsyncHandler; + set + { + var initialValue = _onStateInitializedAsyncHandler; + + _onStateInitializedAsyncHandler = value!; + if (IsStateInitialized) + { + var diff = value - initialValue; + diff!.Invoke(this); + } + } + } + private TState _state = null!; /// @@ -46,7 +98,7 @@ public TState State set => _state = value; } - object IStatefulViewModel.State + ComponentState IStatefulViewModel.State { get => State; set => State = (TState)value; @@ -90,3 +142,6 @@ public virtual Task OnStateChangedAsync() return Task.CompletedTask; } } + +public delegate void StateInitializedHandler(object sender); +public delegate Task StateInitializedAsyncHandler(object sender); diff --git a/src/BitzArt.Blazor.MVVM/Models/ViewModelInjection.cs b/src/BitzArt.Blazor.MVVM/Models/ViewModelInjection.cs new file mode 100644 index 0000000..96ef038 --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Models/ViewModelInjection.cs @@ -0,0 +1,43 @@ +using System.Reflection; + +namespace BitzArt.Blazor.MVVM; + +internal class ViewModelInjection +{ + public PropertyInfo Property { get; set; } + + public Type DependencyType { get; set; } + + public virtual bool IsNestedViewModelInjection { get; set; } + + public virtual bool IsParentViewModelInjection { get; set; } + + public virtual bool IsServiceInjection { get; set; } + + public ViewModelInjection( + PropertyInfo property, + Type dependencyType, + bool nest = false, + bool parent = false, + bool service = false) + { + Property = property; + DependencyType = dependencyType; + + if (!nest && !parent && !service) throw new InvalidOperationException( + "ViewModelInjection must be either a nested view model, parent view model, or service injection."); + + IsNestedViewModelInjection = nest; + IsParentViewModelInjection = parent; + IsServiceInjection = service; + } + + public static ViewModelInjection Nest(PropertyInfo property, Type dependencyType) + => new(property, dependencyType, nest: true); + + public static ViewModelInjection Parent(PropertyInfo property, Type dependencyType) + => new(property, dependencyType, parent: true); + + public static ViewModelInjection Service(PropertyInfo property, Type dependencyType) + => new(property, dependencyType, service: true); +} \ No newline at end of file diff --git a/src/BitzArt.Blazor.MVVM/Models/ViewModelInjectionMap.cs b/src/BitzArt.Blazor.MVVM/Models/ViewModelInjectionMap.cs new file mode 100644 index 0000000..44f4223 --- /dev/null +++ b/src/BitzArt.Blazor.MVVM/Models/ViewModelInjectionMap.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Components; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; + +namespace BitzArt.Blazor.MVVM; + +internal class ViewModelInjectionMap +{ + public Type ViewModelType { get; set; } + + public string RegistrationKey { get; set; } + + public IEnumerable Injections { get; set; } + + [SuppressMessage("Style", "IDE0290:Use primary constructor")] + public ViewModelInjectionMap(Type viewModelType, string registrationKey) + { + ViewModelType = viewModelType; + Injections = ParseInjections(viewModelType); + RegistrationKey = registrationKey; + } + + private static List ParseInjections(Type viewModelType) + { + var result = new List(); + + AddServiceInjections(viewModelType, result); + AddNestInjections(viewModelType, result); + AddParentInjections(viewModelType, result); + + return result; + } + + private static void AddServiceInjections(Type viewModelType, List list) + { + var injectProperties = viewModelType.GetProperties( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic) + .Where(x => x.GetCustomAttribute() is not null); + + foreach (var prop in injectProperties) + { + var dependencyType = prop.PropertyType; + + list.Add(ViewModelInjection.Service(prop, dependencyType)); + } + } + + private static void AddNestInjections(Type viewModelType, List list) + { + var nestProperties = viewModelType.GetProperties( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic) + .Where(x => x.GetCustomAttribute() is not null); + + foreach (var prop in nestProperties) + { + if (!typeof(ViewModel).IsAssignableFrom(prop.PropertyType)) throw new InvalidOperationException( + $"Property {prop.Name} in {viewModelType.Name} is not a ViewModel."); + var dependencyType = prop.PropertyType; + + list.Add(ViewModelInjection.Nest(prop, dependencyType)); + } + } + + private static void AddParentInjections(Type viewModelType, List list) + { + var parentProperties = viewModelType.GetProperties( + BindingFlags.Instance + | BindingFlags.Public + | BindingFlags.NonPublic) + .Where(x => x.GetCustomAttribute() is not null); + + foreach (var prop in parentProperties) + { + if (!typeof(ViewModel).IsAssignableFrom(prop.PropertyType)) throw new InvalidOperationException( + $"Property {prop.Name} in {viewModelType.Name} is not a ViewModel."); + var dependencyType = prop.PropertyType; + + list.Add(ViewModelInjection.Parent(prop, dependencyType)); + } + } +} diff --git a/src/BitzArt.Blazor.MVVM/State/BlazorViewModelStateManager.cs b/src/BitzArt.Blazor.MVVM/Services/BlazorViewModelStateManager.cs similarity index 77% rename from src/BitzArt.Blazor.MVVM/State/BlazorViewModelStateManager.cs rename to src/BitzArt.Blazor.MVVM/Services/BlazorViewModelStateManager.cs index efc6b19..4e2b1f7 100644 --- a/src/BitzArt.Blazor.MVVM/State/BlazorViewModelStateManager.cs +++ b/src/BitzArt.Blazor.MVVM/Services/BlazorViewModelStateManager.cs @@ -25,17 +25,17 @@ internal class BlazorViewModelStateManager(IViewModelFactory viewModelFactory) { var state = new Dictionary(); - if (viewModel is IStatefulViewModel statefulViewModel) + if (viewModel is IStatefulViewModel statefulViewModel && statefulViewModel.State.IsInitialized) { foreach (var property in statefulViewModel.StateType.GetProperties()) state.Add(property.Name, property.GetValue(statefulViewModel.State)); } - foreach (var injection in injectionMap.Injections) + foreach (var injection in injectionMap.Injections.Where(x => x.IsNestedViewModelInjection)) { var property = injection.Property; var nestedViewModel = property.GetValue(viewModel) as ViewModel; - var nestedMap = _viewModelFactory.GetInjectionMap(injection.ViewModelType); + var nestedMap = _viewModelFactory.GetInjectionMap(injection.DependencyType); var nestedState = GetState(nestedViewModel!, nestedMap); if (nestedState is not null) @@ -48,18 +48,21 @@ internal class BlazorViewModelStateManager(IViewModelFactory viewModelFactory) /// /// Restores states in s hierarchy from JSON string. /// - public async Task RestoreStateAsync(ViewModel viewModel, string json) + public async Task RestoreStateAsync(ViewModel rootViewModel, string json) { var node = JsonNode.Parse(json); - if (node is null) return; + if (node is null) return null; - var injectionMap = _viewModelFactory.GetInjectionMap(viewModel.GetType()); - await RestoreStateAsync(viewModel, node, injectionMap); + var injectionMap = _viewModelFactory.GetInjectionMap(rootViewModel.GetType()); + var result = new PageStateDictionary(); + await RestoreStateAsync(rootViewModel, node, injectionMap, result); + + return result; } - private async Task RestoreStateAsync(ViewModel viewModel, JsonNode node, ViewModelInjectionMap injectionMap) + private async Task RestoreStateAsync(ViewModel viewModel, JsonNode node, ViewModelInjectionMap injectionMap, PageStateDictionary pageStateDictionary) { - foreach (var injection in injectionMap.Injections) + foreach (var injection in injectionMap.Injections.Where(x => x.IsNestedViewModelInjection)) { var property = injection.Property; var jsonKey = $"{_nestedStatePrefix}{property.Name}"; @@ -68,9 +71,9 @@ private async Task RestoreStateAsync(ViewModel viewModel, JsonNode node, ViewMod if (nestedNode is null) continue; var nestedViewModel = property.GetValue(viewModel) as ViewModel; - var nestedMap = _viewModelFactory.GetInjectionMap(injection.ViewModelType); + var nestedMap = _viewModelFactory.GetInjectionMap(injection.DependencyType); - await RestoreStateAsync(nestedViewModel!, nestedNode, nestedMap); + await RestoreStateAsync(nestedViewModel!, nestedNode, nestedMap, pageStateDictionary); (node as JsonObject)!.Remove(jsonKey); } @@ -78,7 +81,7 @@ private async Task RestoreStateAsync(ViewModel viewModel, JsonNode node, ViewMod if (viewModel is not IStatefulViewModel statefulViewModel) return; var state = JsonSerializer.Deserialize(node, statefulViewModel.StateType, StateJsonOptionsProvider.Options); - statefulViewModel.State = state!; + pageStateDictionary.Add(viewModel.Signature, state as ComponentState); statefulViewModel.OnStateRestored(); await statefulViewModel.OnStateRestoredAsync(); @@ -98,18 +101,9 @@ public async Task InitializeStateAsync(ViewModel viewModel) private async Task InitializeStateAsync(ViewModel viewModel, ViewModelInjectionMap injectionMap) { - foreach (var injection in injectionMap.Injections) - { - var property = injection.Property; - var nestedViewModel = property.GetValue(viewModel) as ViewModel; - var nestedMap = _viewModelFactory.GetInjectionMap(injection.ViewModelType); - - await InitializeStateAsync(nestedViewModel!, nestedMap); - } - if (viewModel is not IStatefulViewModel statefulViewModel) return; - statefulViewModel.State = Activator.CreateInstance(statefulViewModel.StateType)!; + statefulViewModel.State = (Activator.CreateInstance(statefulViewModel.StateType) as ComponentState)!; statefulViewModel.OnStateChanged(); await statefulViewModel.OnStateChangedAsync(); diff --git a/tests/BitzArt.Blazor.MVVM.Tests/ViewModels/TestNestedStatefulViewModel.cs b/tests/BitzArt.Blazor.MVVM.Tests/ViewModels/TestNestedStatefulViewModel.cs index dc2d4bf..4d5ead5 100644 --- a/tests/BitzArt.Blazor.MVVM.Tests/ViewModels/TestNestedStatefulViewModel.cs +++ b/tests/BitzArt.Blazor.MVVM.Tests/ViewModels/TestNestedStatefulViewModel.cs @@ -8,7 +8,7 @@ public class TestNestedStatefulViewModel : ViewModel