Skip to content

Collaboration on Project #6

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
mishael-o opened this issue Dec 14, 2023 · 43 comments
Closed

Collaboration on Project #6

mishael-o opened this issue Dec 14, 2023 · 43 comments

Comments

@mishael-o
Copy link
Collaborator

Hey, great project you have here. I was looking at doing something similar with source generators and the community toolkit but I see you have already started the work here.

If you are open to collaboration, it would be great to add to and grow this project.

@gragra33
Copy link
Owner

Hey, great project you have here.

Many thanks.

If you are open to collaboration, it would be great to add to and grow this project.

Sure thing. Happy to see this grow. I will work on adding contribution guidelines.

@mishael-o
Copy link
Collaborator Author

Super. I will pull the repo and play around. Let me know if there are any ideas you are looking at implementing.

@gragra33 gragra33 closed this as completed Jan 7, 2024
@gragra33
Copy link
Owner

Hi @mishael-o, you expressed interest in collaborating. How is it going and what do you have planned? I ask as I am looking at adding more myself.

@gragra33 gragra33 reopened this Jan 21, 2024
@mishael-o
Copy link
Collaborator Author

mishael-o commented Jan 23, 2024

Hey @gragra33, it has been a busy start to the year, I haven't had the time to dig deep but these are some improvements I have thought about

Features

  • Auto registration of view models (with the ability to override with manual registration). If we go this route, then we can replace AddMvvmNavigation with AddMvvm and add the ability to opt out from the MvvmNavigation in case the consumer wants to navigate a different way
  • Support for parameters via source generator. It is a rough idea, but when a field has an attribute Parameter or CascadingParameter or SupplyParameterFromQuery or SupplyParameterFromForm we add the corresponding property to the razor page. This will need more thought though.

CI-CD improvements

  • Create builds and releases on GitHub
  • Publish to Nuget from GitHub
  • Add test coverage report. We can use Codecov (I am familiar with this) or Sonarqube.
  • Versioning using Nerdbank.GitVersioning

Code Quality & Docs

  • Improvements to documentation in code
  • Improvements to project structure
  • Better testing using bUnit
  • Add .editorconfig for code quality standards
  • Implement project docs using DocFx
  • Code scanning using CodeQL

@gragra33
Copy link
Owner

Hey @gragra33, it has been a busy start to the year, I haven't had the time to dig deep but these are some improvements I have thought about

Features

[..trimmed..]

CI-CD improvements

[..trimmed..]

Code Quality & Docs

[..trimmed..]

Yep, there are some there that I had on my private to-do list. I have several experiments on how to improve and expand. I will try and put some more time into trying to finalise them.

Parameter handling is something that I have wanted to add but am not happy with concepts that I have been trying in projects. I have not spent time creating a Source Generator however I have come to the same conclusion to do the mapping. from ComponentBase to the ViewModel or directly to the 'Model.Propertyin theViewModel`.

Also, received a request for supporting OwningComponentBase with a new MvvmOwningComponentBase and IAsyncDisposable.

Any help would be greatly appreciated.

@mishael-o
Copy link
Collaborator Author

Cool. I can look into the MvvmOwningComponentBase and IAsyncDisposable if you haven't started on that, else, I can start fleshing out the idea for Parameter handling.

@gragra33
Copy link
Owner

Cool. I can look into the MvvmOwningComponentBase and IAsyncDisposable if you haven't started on that, else, I can start fleshing out the idea for Parameter handling.

I offered the person who made the suggestion to create a PR however there is no reply. If I do not hear from him by the weekend, I am going to add it.

@mishael-o
Copy link
Collaborator Author

Cool. I will look into Parameter handling and auto registration of view models.

@gragra33
Copy link
Owner

@mishael-o I have pushed changes to the updates branch. How far away are you? If not too far, I will wait.

@gragra33
Copy link
Owner

gragra33 commented Jan 29, 2024

Support for parameters via source generator. It is a rough idea, but when a field has an attribute Parameter or CascadingParameter or SupplyParameterFromQuery or SupplyParameterFromForm we add the corresponding property to the razor page. This will need more thought though.

I'm going to have a crack at this. I have thoughts on how.

The key issue will be with how the MvvmBaseComponent, MvvmOwningBaseComponent, & the MvvmBaseLayoutComponent resolve the ViewModel - concrete implementation or contract/interface. The first is easy, the second is complicated. We cannot assume the type of container system used. Will it be the default (Microsoft), Autofac, or some other?

It may be that we have an attribute on the ViewModel with a type reference to the Component or a set of Components. The MvvmSample project will be a good test case for building the Source Generator. In particular the SamplePageViewModel.

Something like (freehand coding):

[MvvmParameters (Component: typeof([HexEntry1), Pameters: ["Param1", "Param2"]]
public class HexEntryViewModel : RecipientViewModelBase<ConvertAsciiToHexMessage>
{
    [Parameter]
    public string Param1 { get; set; }

    [CascadingParameter]
    public string Param2 { get; set; }

    // etc..
}

These will then be added to the Component via the source generator in the file HexEntry1.razor.g.cs:

public partial class HexEntry1
{
    [Parameter]
    public string Param1
    {
        get => ViewModel!.Param1;
        set => ViewModel!.Param1= value;
    }

    [CascadingParameter]
    public string Param2
    {
        get => ViewModel!.Param2;
        set => ViewModel!.Param2= value;
    }


    // etc..
}

Will have to also recognise the Toolkit's observable properties...

@mishael-o
Copy link
Collaborator Author

mishael-o commented Jan 29, 2024

@mishael-o I have pushed changes to the updates branch.

Hey, I have done some work on the auto registration of viewmodels, Just trying to see how handle cases where the type passed into the MvvmComponentBase<>, etc., is not concrete. Will look at your branch and probably port the changes over there.

It may be that we have an attribute on the ViewModel with a type reference to the Component or a set of Components. The MvvmSample project will be a good test case for building the Source Generator. In particular the SamplePageViewModel.


We cannot assume the type of container system used. Will it be the default (Microsoft), Autofac, or some other?

How this can be an issue?


[MvvmParameters (Component: typeof([HexEntry1), Pameters: ["Param1", "Param2"]]

I see, but we should be able to get the Components and view model from MvvmComponentBase<HexEntryViewModel> via source generator, and implement the properties in HexEntry1.razor.g.cs


How far away are you? If not too far, I will wait.

I haven't gone too far on this, I will dedicate some time over the weekend, I was playing around with some ideas for Parameter resolution on the view model.

public class BaseComponent<T> : ComponentBase, IDisposable
    where T : notnull, IViewModelBase
{
    [Inject]
    protected IParameterResolver ParameterResolver { get; set; } = default!;

    [Inject]
    public T ViewModel { get; set; } = default!;

    public override async Task SetParametersAsync(ParameterView parameters)
    {
        ParameterResolver.ResolveParameters(parameters, this, ViewModel);

        await base.SetParametersAsync(ParameterView.Empty);
        await ViewModel.SetParametersAsync();
    }

   // shortened for brevity
}

Parameter Resolver

public class ParameterResolver : IParameterResolver
{
    private readonly ConcurrentDictionary<Type, IReadOnlyDictionary<string, ParameterInfo>> cachedProperties = new();
    private readonly Type typeOfParamaterAttribute = typeof(ParameterAttribute);
    private readonly Type typeOfCascadingParameter = typeof(CascadingParameterAttribute);

    public void ResolveParameter(KeyValuePair<string, object> parameter, object target)
    {
        ArgumentNullException.ThrowIfNull(target);

        var type = target.GetType();
        var objectProperties = GetObjectProperties(type);

        ResolveKeyValuePair(parameter, objectProperties, target);
    }

    public void ResolveParameters(IReadOnlyDictionary<string, object> parameters, object target)
    {
        ArgumentNullException.ThrowIfNull(target);
        ArgumentNullException.ThrowIfNull(parameters);

        var type = target.GetType();
        var objectProperties = GetObjectProperties(type);

        foreach (var kvPair in parameters)
        {
            ResolveKeyValuePair(kvPair, objectProperties, target);
        }
    }

    public void ResolveParameters(ParameterView parameters, IComponent component, IViewModelBase viewModel)
    {
        ArgumentNullException.ThrowIfNull(component);
        ArgumentNullException.ThrowIfNull(viewModel);

        var componentType = component.GetType();
        var vmType = viewModel.GetType();

        var vmProperties = GetObjectProperties(vmType);
        var componentProperties = GetObjectProperties(componentType);

        foreach (var item in parameters)
        {
            if (vmProperties.TryGetValue(item.Name, out var paramterInfo))
            {
                paramterInfo.PropertyInfo.SetValue(viewModel, item.Value);
                continue;
            }

            if (componentProperties.TryGetValue(item.Name, out paramterInfo))
            {
                paramterInfo.PropertyInfo.SetValue(component, item.Value);
                continue;
            }

            if (!item.Cascading)
            {
                throw new InvalidOperationException($"No match for parameter {item.Name} found in component {componentType.FullName} and viewmodel {vmType.FullName}.");
            }
        }
    }

    private static void ResolveKeyValuePair(KeyValuePair<string, object> parameter, IReadOnlyDictionary<string, ParameterInfo> objectProperties, object target)
    {
        if (!objectProperties.TryGetValue(parameter.Key, out var paramterInfo))
        {
            throw new InvalidOperationException($"No match for parameter {parameter.Key} found in {target.GetType().FullName}.");
        }

        paramterInfo.PropertyInfo.SetValue(target, parameter.Value);
    }

    private IReadOnlyDictionary<string, ParameterInfo> GetObjectProperties(Type objectType)
    {
        if (cachedProperties.TryGetValue(objectType, out var objectProperties))
        {
            return objectProperties;
        }

        objectProperties = objectType.GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
            .Where(p => p.IsDefined(typeOfParamaterAttribute, true) || p.IsDefined(typeOfCascadingParameter, true))
            .ToDictionary(p => p.Name, p => new ParameterInfo(p, IsCascadingParameter(p)), StringComparer.OrdinalIgnoreCase);

        if (objectProperties.Count == 0)
        {
            return ImmutableDictionary<string, ParameterInfo>.Empty;
        }

        cachedProperties.TryAdd(objectType, objectProperties);

        return objectProperties;
    }

    private bool IsCascadingParameter(PropertyInfo propertyInfo)
    {
        return propertyInfo.IsDefined(typeOfCascadingParameter, true);
    }

    private class ParameterInfo
    {
        public ParameterInfo(PropertyInfo propertyInfo)
        {
            PropertyInfo = propertyInfo;
        }

        public ParameterInfo(PropertyInfo propertyInfo, bool isCascadingParameter)
        {
            PropertyInfo = propertyInfo;
            IsCascadingParameter = isCascadingParameter;
        }

        public PropertyInfo PropertyInfo { get; }

        public bool IsCascadingParameter { get; }
    }
}

SampleViewModel

public class SampleViewModel: ViewModelBase
{
    [CascadingParameter]
    public string Title { get; private set; } = default!;

    [Parameter]
    public int? Id { get; set; }

    [Parameter]
    [EditorRequired]
    public EventCallback<bool> OnEditCompleted { get; set; }
}

Sample View

@code {
    [CascadingParameter]
    public string Title { get; private set; } = default!;
    
    [Parameter]
    public int? Id { get; set; }

    [Parameter]
    [EditorRequired]
    public EventCallback<bool> OnEditCompleted { get; set; }
}

We will need to also cater for attributes like [EditorRequired]

@gragra33
Copy link
Owner

We cannot assume the type of container system used. Will it be the default (Microsoft), Autofac, or some other?

How this can be an issue?

Depends on the type of Blazor application and how and where all parts exist. AutoFac is also used for plug-in Frameworks. Then there is Lazy loading.

How far away are you? If not too far, I will wait.

I haven't gone too far on this, I will dedicate some time over the weekend, I was playing around with some ideas for Parameter resolution on the view model.

Interesting approach. My concern is that it is a little reflection-heavy. I have the same current concern with MvvmNavigationManager but to a lesser extent based on how the DI is set up. SourceGenerator would be the best solution, removing all reflection. Not quite sure how your approach would be affected without testing those and other scenarios.

We will need to also cater for attributes like [EditorRequired]

Yes, I had thought of that with the approach that I was taking with ViewModel as the source, not the Component. I should have a working sample when I focus more on this over the weekend.

@mishael-o
Copy link
Collaborator Author

Hey @gragra33, I created a draft PR #7 for the auto registration of view models. I am not done with it, I need to do some other enhancements and testing. However, in the meantime, you can check it out.

As for the source generator work, still playing around with some ideas.

@gragra33
Copy link
Owner

gragra33 commented Feb 5, 2024

Hey @gragra33, I created a draft PR #7 for the auto registration of view models. I am not done with it, I need to do some other enhancements and testing. However, in the meantime, you can check it out.

As for the source generator work, still playing around with some ideas.

@mishael-o The auto registration is nice and clean. Maybe add another option for full auto-discovery on top of selecting specific assemblies.

Family commitments disrupted my weekend. I'll let you know when I have something to look at. Then we can compare and make a decision.

@mishael-o
Copy link
Collaborator Author

mishael-o commented Feb 12, 2024

@mishael-o The auto registration is nice and clean. Maybe add another option for full auto-discovery on top of selecting specific assemblies.

I have added this feature as requested.

Family commitments disrupted my weekend. I'll let you know when I have something to look at. Then we can compare and make a decision.

No stress. I understand. Still working on the TODOs on the PR.

@gragra33
Copy link
Owner

gragra33 commented Feb 15, 2024

@mishael-o The auto registration is nice and clean. Maybe add another option for full auto-discovery on top of selecting specific assemblies.

I have added this feature as requested.

I have spotted an issue with .Net 7.0 support. Hosting services that do not support .Net 8.0, like Cloudflare "Pages", will fail to publish with v8.0.* of Microsoft.AspNetCore.Components.Web. We need to support v7.0.15+.

The issue is with your AddViewModels method. This line specifically:

var vmDefinitionAttributeGeneric = vmImplementationType.GetCustomAttribute(customAttributeGenericType);

It is throwing the following exception:

System.AggregateException: One or more errors occurred. (Specified method is not supported.)
 ---> System.NotSupportedException: Specified method is not supported.
   at System.Array.InternalCreate(RuntimeType elementType, Int32 rank, Int32* lengths, Int32* lowerBounds)
   at System.Array.CreateInstance(Type elementType, Int32 length)
   at System.Reflection.CustomAttribute.GetCustomAttributes(ICustomAttributeProvider obj, Type attributeType, Boolean inherit)
   at System.Attribute.GetAttr(ICustomAttributeProvider element, Type attributeType, Boolean inherit)
   at System.Attribute.GetCustomAttribute(MemberInfo element, Type attributeType)
   at System.Reflection.CustomAttributeExtensions.GetCustomAttribute(MemberInfo element, Type attributeType)
   at Blazing.Mvvm.ServicesExtension.AddViewModels(IServiceCollection services, IEnumerable`1 assemblies) in C:\GitHub\Published\XXX\mish\Blazing.Mvvm\src\Blazing.Mvvm\Extensions\ServicesExtension.cs:line 71
   at Blazing.Mvvm.ServicesExtension.RegisterDependencies(IServiceCollection services, LibraryConfiguration configuration) in C:\GitHub\Published\XXX\mish\Blazing.Mvvm\src\Blazing.Mvvm\Extensions\ServicesExtension.cs:line 43
   at Blazing.Mvvm.ServicesExtension.AddMvvm(IServiceCollection services, LibraryConfiguration configuration) in C:\GitHub\Published\XXX\mish\Blazing.Mvvm\src\Blazing.Mvvm\Extensions\ServicesExtension.cs:line 21
   at Program.<Main>$(String[] args) in C:\GitHub\Published\XXX\mish\Blazing.Mvvm\src\Blazing.Mvvm.Sample.Wasm\Program.cs:line 13
   --- End of inner exception stack trace ---

To replicate the issue, roll back to the following versions for the Blazing.Mvvm.Sample.Wasm project:

<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.*" />
  <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.*" PrivateAssets="all" />
  <PackageReference Include="Microsoft.AspNetCore.WebUtilities" Version="2.1.1" />
</ItemGroup>

I have not looked at finding a solution.

@mishael-o
Copy link
Collaborator Author

mishael-o commented Feb 15, 2024

Interesting.....good catch. I will investigate.

@mishael-o
Copy link
Collaborator Author

mishael-o commented Feb 22, 2024

@gragra33 , I have implemented a fix/workaround to the branch in the PR. The exception only occurs .Net 7 Blazor wasm, however on Blazor server that code runs fine, which is very odd. I tried digging deep but couldn't find much info on this except these below. They may be related to the underlying issue.

Anywho, this got me thinking, I know we are looking at using source generators, but instead of doing this via reflection, maybe generating the code that adds each VM registration to the services might be a better approach.

Idea:

Manual registration

public partial class MainLayoutViewModel : ViewModelBase
{
}

// Register type
services.AddTransient<MainLayoutViewModel>();

Auto registration

[ViewModel]
public partial class MainLayoutViewModel : ViewModelBase
{
}

// Generated code for ServicesExtension.cs in ServicesExtension.g.cs 
private static partial AddViewModels(IServiceCollection services)
{
    services.TryAddTransient<MainLayoutViewModel>();
}

Auto registration and ViewModel extension

Example 1:

[ViewModel]
public partial class MainLayoutViewModel 
{
}

// Generated class MainLayoutViewModel.g.cs
public partial class MainLayoutViewModel : ViewModelBase
{
}

// Generated code for ServicesExtension.cs in ServicesExtension.g.cs 
private static partial AddViewModels(IServiceCollection services)
{
   services.TryAddTransient<MainLayoutViewModel>();
}

Example 2:

[ViewModel<ITestNavigationViewModel>(Lifetime = ServiceLifetime.Scoped)]
public partial class TestNavigationViewModel : ITestNavigationViewModel
{
}

// Generated class TestNavigationViewModel .g.cs
public partial class TestNavigationViewModel : ViewModelBase
{
}

// Generated code for ServicesExtension.cs in ServicesExtension.g.cs 
private static partial AddViewModels(IServiceCollection services)
{
   services.TryAddScoped<ITestNavigationViewModel, TestNavigationViewModel>();
}

Let me know what you think.

@gragra33
Copy link
Owner

@gragra33 , I have implemented a fix/workaround to the branch in the PR. The exception only occurs .Net 7 Blazor wasm, however on Blazor server that code runs fine, which is very odd. I tried digging deep but couldn't find much info on this except these below. They may be related to the underlying issue.

dotnet/runtime#86765
dotnet/runtime#87871

Hmmm... Have not looked closely at this. AutoFac can do this easily, however, I am trying to stay with a clear MS DI solution.

Let me know what you think.

A source generator? If you are game. They're not simple to build. However, The CommunityToolkit does have source generators and this looks like it would not be too difficult to modify for this requirement: ObservableObjectGenerator.cs

@mishael-o
Copy link
Collaborator Author

mishael-o commented Feb 22, 2024

A source generator? If you are game. They're not simple to build. However, The CommunityToolkit does have source generators and this looks like it would not be too difficult to modify for this requirement: ObservableObjectGenerator.cs

Cool, I will have a go at it.

If we need a release soon, you can let me know, so I clean up the existing branch in the PR. If not, then I can focus my time on implementing the source generator code.

@gragra33
Copy link
Owner

If we need a release soon, you can let me know, so I clean up the existing branch in the PR. If not, then I can focus my time on implementing the source generator code.

Keep going, I will wait. I need to finish working on the other source generator.

@gragra33
Copy link
Owner

@mishael-o It has been a few weeks since your last update. How is it going?

@mishael-o
Copy link
Collaborator Author

mishael-o commented Mar 19, 2024

@gragra33 Hey, I have made some progress, just have not had the time to flesh out the solution.

Firstly, auto registration of view models via source generators may not be a good solution as it creates generated code in each library where a view model exists and we will need to find and consolidate each of those generated code files and make that callable under the hood when a consumer does services.AddMvvm();. For now, reflections during startup are the right solution.

Also, I found some interesting issues with source generators and Blazor. You can have a read below, but long story short from .NET 6, Blazor uses source generators to create the backing CS files and code for the Blazor markup, therefore we can't find the components via our source generator as source generators (or the code they generate) cannot interact with each other 😄.

There are two workaround for this:

  • Consumers create a backing CS file for the Blazor component and inherit MvvmComponentBase. (I am leaning more toward this method)
public partial class Form : MvvmComponentBase<EditContactViewModel>
{
}
<PropertyGroup>
  <UseRazorSourceGenerator>false</UseRazorSourceGenerator>
</PropertyGroup>

I prefer the first workaround but we can let consumers choose whichever they like, source generator will work on both.

Finally, see below a rough draft of the View generator that I am working on. I will have some capacity next week so I will continue then.

using System.Diagnostics;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Blazing.Mvvm.SourceGenerators;

[Generator(LanguageNames.CSharp)]

public class ViewGenerator : IIncrementalGenerator
{
    public ViewGenerator()
    {
        //System.Diagnostics.Debugger.Launch();
    }

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Get views that implement IView<TViewModel> generic interface
        // Get the generic type argument of the IView<TViewModel> interface
        // Get properties of the generic type argument that have the ParameterAttribute
        // Create a partial class of the view with the ViewModel properties

        var views = context.SyntaxProvider.CreateSyntaxProvider(
            IsSyntaxTargetForGeneration,
            GetViewInfo)
            .Where(static m => m is not null)!;

        context.RegisterSourceOutput(views, GenerateViewCode!);
    }


    private static bool IsSyntaxTargetForGeneration(SyntaxNode node, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (node is not ClassDeclarationSyntax classDeclaration)
        {
            return false;
        }

        if (classDeclaration.Modifiers.Any(SyntaxKind.AbstractKeyword))
        {
            return false;
        }

        if (!classDeclaration.Modifiers.Any(SyntaxKind.PartialKeyword))
        {
            return false;
        }

        var inheritsMvvmBaseType = classDeclaration.BaseList?.Types
            .Any(t => t.Type is GenericNameSyntax && t.ToString().StartsWith("MvvmComponentBase<"));

        return inheritsMvvmBaseType.GetValueOrDefault();
    }

    private static ViewInfo? GetViewInfo(GeneratorSyntaxContext context, CancellationToken cancellationToken)
    {
        cancellationToken.ThrowIfCancellationRequested();

        if (context.SemanticModel.GetDeclaredSymbol(context.Node) is not INamedTypeSymbol namedTypeSymbol)
        {
            return null;
        }

        var typeArguments = namedTypeSymbol.AllInterfaces
            .FirstOrDefault(i => i.Name == "IView");

        if (typeArguments is null)
        {
            return null;
        }

        var viewModelType = typeArguments.TypeArguments[0];

        var prop = viewModelType.GetMembers().OfType<IPropertySymbol>();
        var attributes = prop.SelectMany(p => p.GetAttributes());

        var properties = viewModelType.GetMembers().OfType<IPropertySymbol>()
            .Where(p => p.GetAttributes()
            .Any(a => a.AttributeClass?.ToString() == "Microsoft.AspNetCore.Components.ParameterAttribute"))
            .Select(p => new PropertyInfo(p.Name, p.Type.ToDisplayString(), p.Type.NullableAnnotation == NullableAnnotation.Annotated))
            .ToArray();

        if (properties.Length == 0)
        {
            return null;
        }

        return new(
            namedTypeSymbol.Name,
            namedTypeSymbol.ToString(),
            namedTypeSymbol.ContainingNamespace?.ToString() ?? string.Empty,
            properties);
    }

    private static void GenerateViewCode(SourceProductionContext context, ViewInfo view)
    {
        var stringBuilder = new StringBuilder();


        stringBuilder.Append($$"""
            // <auto-generated />
            using Microsoft.AspNetCore.Components;

            namespace {{view.Namespace}};

            public partial class {{view.Name}}
            {
            """);

        foreach (var property in view.Properties)
        {
            stringBuilder
                .AppendLine()
                .Append($$"""
                    [global::Microsoft.AspNetCore.Components.Parameter]
                    [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
                    [global::System.CodeDom.Compiler.GeneratedCode("Blazing.Mvvm.SourceGenerators.ViewGenerator", "1.0.0")]
                    public {{property.Type}} {{property.Name}} { get; set; }
                """);

            if (property.IsNullable)
            {
                stringBuilder.Append(" = default!;");
            }
        }

        stringBuilder
            .AppendLine()
            .AppendLine()
            .Append("""
                [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
                [global::System.CodeDom.Compiler.GeneratedCode("Blazing.Mvvm.SourceGenerators.ViewGenerator", "1.0.0")]                
                public override global::System.Threading.Tasks.Task SetParametersAsync(global::Microsoft.AspNetCore.Components.ParameterView parameters)
                {
                    foreach (global::Microsoft.AspNetCore.Components.ParameterValue parameter in parameters)
                    {
                        var result = SetParameterValue(parameter.Name, parameter.Value);

                        if (!result && !parameter.Cascading)
                        {
                            throw new InvalidOperationException($"No match in ViewModel for parameter {parameter.Name}");
                        }
                    }
                    
                    return base.SetParametersAsync(global::Microsoft.AspNetCore.Components.ParameterView.Empty);
                }

                [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
                [global::System.CodeDom.Compiler.GeneratedCode("Blazing.Mvvm.SourceGenerators.ViewGenerator", "1.0.0")]  
                private bool SetParameterValue(string name, object value)
                {
                    switch (name)
                    {
            """);

        foreach (var property in view.Properties)
        {
            stringBuilder
                .AppendLine()
                .Append($$"""
                            case "{{property.Name}}":
                                ViewModel.{{property.Name}} = ({{property.Type}})value;
                                return true;
                """);
        }

        stringBuilder
            .AppendLine()
            .Append("""
                    }

                    return false;
                }
            """);

        stringBuilder
            .AppendLine()
            .Append("}");

        context.AddSource($"{view.Name}.razor.g.cs", stringBuilder.ToString());
    }
}

internal record ViewInfo(
    string Name,
    string FullName,
    string? Namespace,
    PropertyInfo[] Properties);

internal record PropertyInfo(
    string Name,
    string Type,
    bool IsNullable);

@gragra33
Copy link
Owner

@mishael-o sounds good. I'll take a look soon.

@mishael-o
Copy link
Collaborator Author

mishael-o commented Apr 14, 2024

Hey @gragra33, doing a check-in. Still working on the source generator, and I have made good progress. The core logic is in place, however, I still need to cater for these requirements (that I have picked up).

  • Support these attributes and their configurations
    • ParameterAttribute (CaptureUnmatchedValues)
    • EditorRequiredAttribute
    • CascadingParameterAttribute
    • SupplyParameterFromFormAttribute
    • SupplyParameterFromQueryAttribute
  • Code analysis and warnings
  • Handling when consumers don't use nullable reference types

@gragra33
Copy link
Owner

@mishael-o How are you going with it?

@mishael-o
Copy link
Collaborator Author

mishael-o commented May 14, 2024

@mishael-o How are you going with it?

Hey @gragra33, still here, I have been off sick for a while.

Quick update:

The source generator work is taking quite a bit of time to implement, apart from the requirements I mentioned in an earlier comment, I was also thinking that we are enforcing the parameters defined in the ViewModels in the View. If a ViewModel is used by multiple Views and a View would want to opt out of some parameters, how would we handle that? Do you think this is a use case we should cater for?

I know you said you are not in a rush to get changes out, however, I think we could do the reflection implementation for the parameters in the meantime and then later on we can implement the changes in the same library or have an additional library called Blazing.Mvvm.SourceGenerators which is opt-in and can override the default reflection-based one, let me know what you think.

Other Changes:
I have made changes to the MvvmOwningComponentBase implementation you made. I removed the MvvmOwningComponentBase<T, S> as I didn't see its use. There is already the ScopedServices property that can be used to resolve the service. I also made changes to the MvvmNavigationManager to enable support for pages that implement MvvmOwningComponentBase<T>.

I dropped the IDisposable and IAsyncDisposable from the IViewModelBase. I know this was added to enable disposing of the ViewModels in the View, however, this can cause issues because we don't know how the ViewModel is registered in the DI. We may be disposing a Singleton view model and this will cause issues. I prefer we let the consumers of the library decide if they want to implement IDisposable in their view model and how they want to dispose it. There are some weird issues with IDisposables and Blazors DI.

Finally, I updated the test samples and also did some cleanup.

Check out the latest changes in the PR, and let me know if there are areas you want reverted or further improvements. I would prefer this goes in first so we have a base before we start ramping up on the source generator work.

@gragra33
Copy link
Owner

gragra33 commented Jun 8, 2024

@mishael-o going to have a look this weekend as work here has been crazy busy.

@gragra33
Copy link
Owner

gragra33 commented Jun 9, 2024

@mishael-o let's close out this pull request and open another the the remainder.

@gragra33
Copy link
Owner

@mishael-o just a quick check to see where you are up to...

@mishael-o
Copy link
Collaborator Author

Hey @gragra33, still on the tests. I have written most of the unit tests for the updated code and added code comments for improved documentation (I still need to update the readme). Now writing components tests using bunit to validate the behaviours of the updated ViewModel base classes.

I will push this week or next. I will update once I have pushed.

@mishael-o
Copy link
Collaborator Author

Hey @gragra33, I thought I would be done this weekend and push today but I found some interesting weirdness with form validation behaving differently on the server and wasm (caused by changes I made). I'm just trying to iron it out then I will aim to push this week. I have completed all the code changes, comments and tests, just sorting this out then I will push.

@gragra33
Copy link
Owner

@mishael-o Thank you for your contribution, much appreciated. I've merged your PR, and in the process of adding Keyed MvvmNavLink & MvvmNavigationManager support. I only have tests and documentation left to update. Will add .Net 9.0 support and push to the updates branch this weekend.

@mishael-o
Copy link
Collaborator Author

@gragra33 No stress. I enjoyed working on the repo. Thanks for inviting me to collaborate; I appreciate it. I'm looking forward to all the cool stuff we will build 😄.

@gragra33
Copy link
Owner

gragra33 commented Nov 3, 2024

@mishael-o I've pushed my changes for Keyed support in MvvmNavLink, a new MvvmKeyNavLink component, and MvvmNavigationManager updated. MvvmComponentBase, including variants, required updating the ViewModel property injection handling to allow full Keyed support. Views now support the ViewModelDefinition attribute.

Tests have been added, and the docs have been updated.

Feedback is welcome.

@mishael-o
Copy link
Collaborator Author

@gragra33 cool. I will check it out.

@mishael-o
Copy link
Collaborator Author

mishael-o commented Nov 6, 2024

@gragra33 cool. I will check it out.

Hey @gragra33, I went through the updates. However, I think we can implement the resolution of keyed view models in a better way.

The implementation you added for SetViewModel() relies on code like the one below for Keyed ViewModels.

@attribute [ViewModelDefinition(Key = nameof(TestKeyedNavigationViewModel))]
@inherits MvvmComponentBase<TestKeyedNavigationViewModel>

I understand it would be preferable to resolve view models based on how they were defined but we need to ensure that we don't create unnecessary coupling of ViewModelDefinition on the view.

Suggestions

I prefer telling the consumers to override the ViewModel property in views that inherit the base components (except MvvmOwningComponentBase) which have keyed ViewModels. Then we can rely on the Blazor SDK to resolve this for us.

[Inject(Key = "TestKeyedNavigationViewModel")]
protected override TestKeyedNavigationViewModel ViewModel 
{ 
    get => base.ViewModel;
    set => base.ViewModel = value;
}

I don't think Keyed dependencies are being used a lot in code bases so this shouldn't be a train smash.

This can be a stop-gap so we take our time to see how we implement this without defining attributes in both the view and view models. Maybe source generators can play a role here for us.

MvvmOwningComponentBase

As for the MvvmOwningComponentBase I need some time to think about this, as would we need to infer if the ViewModel is a keyed dependency without defining the ViewModelDefinition attribute in the view.

  • Maybe we create MvvmKeyedOwningComponentBase that is specific for this.
  • We have a property that can be set with the Key name and we check that.
  • We create a keyed attribute for views that tells us that the view model has a keyed dependency i.e. [MvvmKeyedViewModel("Key") then we read that instead of ViewModelDefinition. This could also work for normal components and then we can generate based on that. (Can also be used with source generators).

These are ideas I have. Tell me what you think.

Oh, lastly, FYI some tests are failing also, I will need to check out why they are failing 😅.

@gragra33
Copy link
Owner

gragra33 commented Nov 9, 2024

Hey @gragra33, I went through the updates. However, I think we can implement the resolution of keyed view models in a better way.

The implementation you added for SetViewModel() relies on code like the one below for Keyed ViewModels.

@attribute [ViewModelDefinition(Key = nameof(TestKeyedNavigationViewModel))]
@inherits MvvmComponentBase<TestKeyedNavigationViewModel>

I understand it would be preferable to resolve view models based on how they were defined but we need to ensure that we don't create unnecessary coupling of ViewModelDefinition on the view.

Suggestions

I prefer telling the consumers to override the ViewModel property in views that inherit the base components (except MvvmOwningComponentBase) which have keyed ViewModels. Then we can rely on the Blazor SDK to resolve this for us.

[Inject(Key = "TestKeyedNavigationViewModel")]
protected override TestKeyedNavigationViewModel ViewModel 
{ 
    get => base.ViewModel;
    set => base.ViewModel = value;
}

I don't think Keyed dependencies are being used a lot in code bases so this shouldn't be a train smash.

This can be a stop-gap so we take our time to see how we implement this without defining attributes in both the view and view models. Maybe source generators can play a role here for us.

@mishael-o Yes, I tried this, and there is an issue where the Injection happens twice, and the second injection is missing data. It is not a viable option. The method implemented uses the least amount of plumbing required.

Also, the sample code uses a concrete class however Interfaces works fine.

Here is the update for the page:

@attribute [ViewModelDefinition(Key = "TestKeyedNavigationViewModel")]
@inherits MvvmComponentBase<ITestKeyedNavigationViewModel>

and the ViewModel:

[ViewModelDefinition<ITestKeyedNavigationViewModel>(Key = nameof(TestKeyedNavigationViewModel))]
public sealed class TestKeyedNavigationViewModel(IMvvmNavigationManager mvvmNavigationManager, NavigationManager navigationManager)
    : TestNavigationBaseViewModel(mvvmNavigationManager, navigationManager), ITestKeyedNavigationViewModel

I've also updated the tests for this removing all concrete references:

Services.AddKeyedSingleton<ITestKeyedNavigationViewModel, TestKeyedNavigationViewModel>("TestKeyedNavigationViewModel");

and in the test itself:

var cutViewModel = GetViewModel<ITestKeyedNavigationViewModel>("TestKeyedNavigationViewModel");

Oh, lastly, FYI some tests are failing also, I will need to check out why they are failing 😅.

I hadn't touched the other code, so did [not] run those tests. But yes, they are failing for some odd reason. I have fixed all but ParameterResolverTests. I'll leave that one for you.

@mishael-o
Copy link
Collaborator Author

@mishael-o Yes, I tried this, and there is an issue where the Injection happens twice, and the second injection is missing data. It is not a viable option.

@gragra33 this is intereseting, what scenario/view did this occurs. I am curious about this behaviour because the HexTranslate.razor does this also and I didn't notice any issues.

Lastly....I will push a PR tomorrow or Saturday for the test fixes and improvements.

@gragra33
Copy link
Owner

@gragra33 this is intereseting, what scenario/view did this occurs. I am curious about this behaviour because the HexTranslate.razor does this also and I didn't notice any issues.

@mishael-o When working with Keyed Injection. That is when I noticed the behaviour.

Lastly....I will push a PR tomorrow or Saturday for the test fixes and improvements.

I'll update for .Net 9.0 this weekend and do a full review.

@gragra33
Copy link
Owner

Sorry, my busy work schedule and family commitments have slowed me down and last weekend couldn't allocate time to review. The review is now posted and it still requires more work. Please see my comments on the PR.

@gragra33
Copy link
Owner

Congrats ... changes are now live!

@mishael-o
Copy link
Collaborator Author

Congrats ... changes are now live!

That's great. Finally, V2 is out.

@gragra33 gragra33 closed this as completed Dec 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants