Options expression for MudSelectAttribute

Options expression for MudSelectAttribute

I recently made a small change to my CG.Blazor.Forms._MudBlazor NUGET package. Specifically, I added a property called OptionsFunc to the MudSelectAttribute and MudRadioGroupAttribute attributes. For this article, I’ll only discuss the RenderMudSelectAttribute class.

The property makes it possible to tie in a live data source for the rendered component. For instance, assume I have this view-model:

public class ExampleVM
{
    [RenderMudSelect(OptionsFunc = "GetOptions")]
    public string Foo { get; set; }

    protected Task<IEnumerable<string>> GetOptions()
    {
        return Task.FromResult(new string[] { "Item A", "Item B" }.AsEnumerable());
    }
}

This example view-model has a single property, Foo, that is decorated with the RenderMudSelectAttribute attribute. That tells the form generator to render a form with a single MudSelect control, bound to the Foo property, on an instance of ExampleVM.

Here is some quick markup to produce a dynamic UI from that view-model:

<DynamicForm Model="Model" />

@code {
   protected ExampleVM Model { get; set; } = new ExampleVM();
}

The results, at runtime, look something like this:

So far, so good. Now, let’s go look at that OptionsFunc property again – the one on the RenderMudSelectAttribute attribute. In our example, that property is set to the name of a method, on our view-model, called GetOptions. If we look at the signature for the GetOptions method, we see that it accepts no parameters and returns an enumerable sequence of strings. It’s obvious, from the screen shot, that the MudSelect control is using the GetOptions method to populate the list of options, for the dropdown, at runtime, but, how is it doing that?

Let’s go look at that.

The source for the RenderMudSelectAttribute attribute looks like this:

[AttributeUsage(AttributeTargets.Property)]
public class RenderMudSelectAttribute : MudBlazorAttribute
{
    public Adornment Adornment { get; set; }
    public Color AdornmentColor { get; set; }
    public string AdornmentIcon { get; set; }
    public string AdornmentText { get; set; }
    public bool AutoFocus { get; set; }
    public bool Clearable { get; set; }
    public string CloseIcon { get; set; }
    public bool Dense { get; set; }
    public Direction Direction { get; set; }
    public bool Disabled { get; set; }
    public bool DisableUnderLine { get; set; }
    public string Format { get; set; }
    public bool FullWidth { get; set; }
    public Size IconSize { get; set; }
    public bool Immediate { get; set; }
    public InputMode InputMode { get; set; }
    public string Label { get; set; }
    public int Lines { get; set; }
    public Margin Margin { get; set; }
    public int MaxHeight { get; set; }
    public bool MultiSelection { get; set; }
    public bool OffsetX { get; set; }
    public bool OffsetY { get; set; }
    public string OpenIcon { get; set; }
    public string Options { get; set; }
    public string OptionsFunc { get; set; }
    public string Pattern { get; set; }
    public bool ReadOnly { get; set; }
    public bool Strict { get; set; }
    public Variant Variant { get; set; }

    public RenderMudSelectAttribute()
    {
        // Set default values.
        Adornment = Adornment.End;
        AdornmentColor = Color.Default;
        AdornmentIcon = string.Empty;
        AdornmentText = string.Empty;
        AutoFocus = false;
        Clearable = false;
        CloseIcon = string.Empty;
        Dense = false;
        Direction = Direction.Bottom;
        Disabled = false;
        DisableUnderLine = false;
        Format = string.Empty;
        FullWidth = false;
        IconSize = Size.Medium;
        Immediate = false;
        InputMode = InputMode.text;
        Label = string.Empty;
        Lines = 1;
        Margin = Margin.None;
        MaxHeight = 300;
        MultiSelection = false;
        OffsetX = false;
        OffsetY = false;
        OpenIcon = string.Empty;
        Options = string.Empty;
        OptionsFunc = string.Empty;
        Pattern = string.Empty;
        ReadOnly = false;
        Strict = false;
        Variant = Variant.Text;
    }

    public override IDictionary<string, object> ToAttributes()
    {
        // Create a table to hold the attributes.
        var attr = new Dictionary<string, object>();

        // Does this property have a non-default value?
        if (Adornment.End != Adornment)
        {
            // Add the property value.
            attr[nameof(Adornment)] = Adornment;
        }

        // Does this property have a non-default value?
        if (Color.Default != AdornmentColor)
        {
            // Add the property value.
            attr[nameof(AdornmentColor)] = AdornmentColor;
        }

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(AdornmentIcon))
        {
            // Add the property value.
            attr[nameof(AdornmentIcon)] = AdornmentIcon;
        }

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(AdornmentText))
        {
            // Add the property value.
            attr[nameof(AdornmentText)] = AdornmentText;
        }

        // Does this property have a non-default value?
        if (false != AutoFocus)
        {
            // Add the property value.
            attr[nameof(AutoFocus)] = AutoFocus;
        }

        // Does this property have a non-default value?
        if (false != Clearable)
        {
            // Add the property value.
            attr[nameof(Clearable)] = Clearable;
        }

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(CloseIcon))
        {
            // Add the property value.
            attr[nameof(CloseIcon)] = CloseIcon;
        }

        // Does this property have a non-default value?
        if (false != Dense)
        {
            // Add the property value.
            attr[nameof(Dense)] = Dense;
        }

        // Does this property have a non-default value?
        if (Direction.Bottom != Direction)
        {
            // Add the property value.
            attr[nameof(Direction)] = Direction;
        }

        // Does this property have a non-default value?
        if (false != Disabled)
        {
            // Add the property value.
            attr[nameof(Disabled)] = Disabled;
        }

        // Does this property have a non-default value?
        if (false != DisableUnderLine)
        {
            // Add the property value.
            attr[nameof(DisableUnderLine)] = DisableUnderLine;
        }

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(Format))
        {
            // Add the property value.
            attr[nameof(Format)] = Format;
        }

        // Does this property have a non-default value?
        if (false != FullWidth)
        {
            // Add the property value.
            attr[nameof(FullWidth)] = FullWidth;
        }

        // Does this property have a non-default value?
        if (Size.Medium != IconSize)
        {
            // Add the property value.
            attr[nameof(IconSize)] = IconSize;
        }

        // Does this property have a non-default value?
        if (false != Immediate)
        {
            // Add the property value.
            attr[nameof(Immediate)] = Immediate;
        }

        // Does this property have a non-default value?
        if (InputMode.text != InputMode)
        {
            // Add the property value.
            attr[nameof(InputMode)] = InputMode;
        }

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(Label))
        {
            // Add the property value.
            attr[nameof(Label)] = Label;
        }

        // Does this property have a non-default value?
        if (1 != Lines)
        {
            // Add the property value.
            attr[nameof(Lines)] = Lines;
        }

        // Does this property have a non-default value?
        if (Margin.None != Margin)
        {
            // Add the property value.
            attr[nameof(Margin)] = Margin;
        }

        // Does this property have a non-default value?
        if (300 != MaxHeight)
        {
            // Add the property value.
            attr[nameof(MaxHeight)] = MaxHeight;
        }

        // Does this property have a non-default value?
        if (false != MultiSelection)
        {
            // Add the property value.
            attr[nameof(MultiSelection)] = MultiSelection;
        }

        // Does this property have a non-default value?
        if (false != OffsetX)
        {
            // Add the property value.
            attr[nameof(OffsetX)] = OffsetX;
        }

        // Does this property have a non-default value?
        if (false != OffsetY)
        {
            // Add the property value.
            attr[nameof(OffsetY)] = OffsetY;
        }

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(Pattern))
        {
            // Add the property value.
            attr[nameof(Pattern)] = Pattern;
        }

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(OpenIcon))
        {
            // Add the property value.
            attr[nameof(OpenIcon)] = OpenIcon;
        }

        // Note: the options are deliberately not added to the attributes.

        // Does this property have a non-default value?
        if (false == string.IsNullOrEmpty(Pattern))
        {
            // Add the property value.
            attr[nameof(Pattern)] = Pattern;
        }

        // Does this property have a non-default value?
        if (false != ReadOnly)
        {
            // Add the property value.
            attr[nameof(ReadOnly)] = ReadOnly;
        }

        // Does this property have a non-default value?
        if (false != Strict)
        {
            // Add the property value.
            attr[nameof(Strict)] = Strict;
        }

        // Does this property have a non-default value?
        if (Variant.Text != Variant)
        {
            // Add the property value.
            attr[nameof(Variant)] = Variant;
        }

        // Return the attributes.
        return attr;
    }

    public override int Generate(
        RenderTreeBuilder builder,
        int index,
        IHandleEvent eventTarget,
        Stack<object> path,
        PropertyInfo prop,
        ILogger<IFormGenerator> logger
        )
    {
        // Validate the parameters before attempting to use them.
        Guard.Instance().ThrowIfNull(builder, nameof(builder))
            .ThrowIfLessThanZero(index, nameof(index))
            .ThrowIfNull(path, nameof(path))
            .ThrowIfNull(prop, nameof(prop))
            .ThrowIfNull(logger, nameof(logger));

        try
        {
            // If we get here then we are trying to render a MudSelect component
            //   and bind it to the specified string property.

            // Should never happen, but, pffft, check it anyway.
            if (path.Count < 2)
            {
                // Let the world know what we're doing.
                logger.LogDebug(
                    "RenderMudSelectAttribute::Generate called with a shallow path!"
                    );

                // Return the index.
                return index;
            }

            // Create a complete property path, for logging.
            var propPath = $"{string.Join('.', path.Skip(1).Reverse().Select(x => x.GetType().Name))}.{prop.Name}";

            // Get the model reference.
            var model = path.Peek();

            // Should never happen, but, pffft, check it anyway.
            if (null == model)
            {
                // Let the world know what we're doing.
                logger.LogDebug(
                    "RenderMudSelectAttribute::Generate called with a null model!"
                    );

                // Return the index.
                return index;
            }
                
            // Get the property's parent.
            var propParent = path.Skip(1).First();

            // We only render MudSelect controls against strings.
            if (prop.PropertyType == typeof(string))
            {
                // Let the world know what we're doing.
                logger.LogDebug(
                    "Rendering property: '{PropPath}' as a MudSelect. [idx: '{Index}']",
                    propPath,
                    index
                    );

                // Get any non-default attribute values (overrides).
                var attributes = ToAttributes();

                // Ensure the Label property is set.
                if (false == attributes.ContainsKey("Label"))
                {
                    // Ensure we have a label.
                    attributes["Label"] = prop.Name;
                }

                // Ensure the T attribute is set.
                attributes["T"] = typeof(string).Name;

                // Ensure the property value is set.
                attributes["Value"] = (string)prop.GetValue(propParent);

                // Ensure the property is bound, both ways.
                attributes["ValueChanged"] = RuntimeHelpers.TypeCheck<EventCallback<string>>(
                    EventCallback.Factory.Create<string>(
                        eventTarget,
                        EventCallback.Factory.CreateInferred<string>(
                            eventTarget,
                            x => prop.SetValue(propParent, x),
                            (string)prop.GetValue(propParent)
                            )
                        )
                    );

                // Make the compiler happy.
                if (null != propParent)
                {
                    // Ensure the For property value is set.
                    attributes["For"] = Expression.Lambda<Func<string>>(
                        MemberExpression.Property(
                            Expression.Constant(
                                propParent,
                                propParent.GetType()),
                            prop.Name
                            )
                        );
                }

                // Render the property as a MudSelect control.
                index = builder.RenderUIComponent<MudSelect<string>>(
                    index++,
                    attributes: attributes,
                    contentDelegate: childBuilder =>
                    {
                        // How should we build the options?
                        IEnumerable<string> options;

                        // Get the view-model.
                        var viewModel = path.Last();

                        // Try to resolve the options func.
                        if (TryOptionsFunc(
                            viewModel,
                            propParent,
                            out var func
                            ))
						{
                            // If we get here then we resolved the options 
                            //   func, so let's use it now to populate the
                            //   options.

                            // Invoke the function.
                            options = func.Invoke().Result
                                .Select(x => x.Trim())
                                .ToArray();
                        }
						else
						{
                            // If we get here then we failed to resolve the
                            //   options func, so, try to use the options
                            //   property instead.

                            // Split the options.
                            options = Options.Split(',')
                                .Select(x => x.Trim())
                                .ToArray();
                        }

                        // Loop through the options
                        foreach (var option in options)
                        {
                            var index2 = index; // Reset the index.

                            // Create attributes for the item.
                            var selectItemAttributes = new Dictionary<string, object>()
                            {
                                { "Value", option },
                                { "T", attributes["T"] }
                            };

                            // Render the MudSelectItem control.
                            index2 = childBuilder.RenderUIComponent<MudSelectItem<string>>(
                                index2++,
                                attributes: selectItemAttributes
                                );
                        }
                    });
            }
            else
            {
                // Let the world know what we're doing.
                logger.LogDebug(
                    "Not rendering property: '{PropPath}' since we only render " +
                    "MudSelect components on properties of type: string. " +
                    "That property is of type: '{PropType}'!",
                    propPath,
                    prop.PropertyType.Name
                    );
            }

            // Return the index.
            return index;
        }
        catch (Exception ex)
        {
            // Give the error better context.
            throw new FormGenerationException(
                message: "Failed to render a MudSelect component! " +
                    "See inner exception(s) for more detail.",
                innerException: ex
                );
        }
    }

    private bool TryOptionsFunc(
        object viewModel,
        object propParent,
        out Func<Task<IEnumerable<string>>> func
        )
	{
        func = null;

        // Is the property populated?
        if (false == string.IsNullOrEmpty(OptionsFunc))
		{
            // Create possible targets for the search.
            var targets = (viewModel == propParent)
                ? new[] { viewModel }
                : new[] { viewModel, propParent };

            // Loop and look for the function.
            foreach (var target in targets)
			{
                // Get the target type.
                var targetType = target.GetType();

                // Look for the named method.
                var methodInfo = targetType.GetMethod(
                    OptionsFunc,
                    BindingFlags.Public |
                    BindingFlags.NonPublic |
                    BindingFlags.Instance
                    );

                // Did we succeed?
                if (null != methodInfo)
				{
                    // Create a viewModel reference expression.
                    var viewModelExp = Expression.Constant(
                        target
                        );

                    // Create the method call expression.
                    var callExp = Expression.Call(
                        viewModelExp,
                        methodInfo
                        );

                    // Create a lambda expression.
                    var lambdaExp = Expression.Lambda<Func<Task<IEnumerable<string>>>>(
                        callExp,
                        callExp.Arguments.OfType<ParameterExpression>()
                        );

                    // Compile the expression to a func.
                    func = lambdaExp.Compile();

                    // We found the func.
                    return true;
                }
            }
        }

        // We didn't find the func.
        return false;
	}
}

The class derives from MudBlazorAttribute, which derives from FormGeneratorAttribute, which is the base attribute used for all form generator related attributes. MudBlazorAttribute is the base for all MudBlazor related form generation attributes. The hierarchy isn’t super important, except to say that the form generator understands how to go find FormGeneratorAttribute attributes, at render time, and use them to render the individual parts of a form. MudBlazorAttribute contains properties that are common to most MudBlazor components. By deriving from MudBlazorAttribute, we gain the ability for our RenderMudSelectAttribute attribute to be discovered by the form generator, and called at render time, in order to render a MudSelect component, on the UI.

RenderMudSelectAttribute obviously contains a bunch of properties. Most of those properties are strictly for the MudBlazor library, and have no effect at all on the operation of the attribute. Two exceptions to that are the Options property, and the OptionsFunc property. Options can be set to a comma separated list of hard coded options. This is perfect for when a static list of options is required. OptionsFunc is the property to keep our eye on, as we move through the source listing for RenderMudSelectAttribute.

The ToAttributes method simply wraps all the property values up into a single table. Honestly, this method is a hold-over from my original library, where the functionality of these attributes were split up into multiple classes, but, that’s ok, this method still serves a useful purpose, which is to present the property values in a way that the MudBlazor library can make sense of. We’ll see that in action shortly. For now, just understand that the ToAttributes method is called from the Generate method, at render time, to pass any property values to the MudSelect component we’re creating.

The next method is named Generate. This is also overridden from the FormGeneratorAttribute class and is called by the form generator to perform rendering. In our case, we want to render a MudSelect component, so we’ve overridden it for that purpose.

Generate starts by validating incoming parameter values. After that we do some sanity checking on the contents of the path variable. Assuming that’s all good, we then move on to craft a property path that we’ll need for logging purposes. After that we fetch a reference to the model, which is the parent of the property we’re attempting to render. In our example, model refers to the ExampleVM object.

After that we get a reference to the property parent. That may seem redundant, and in this particular case, it is, but sometimes the model and the property parent aren’t the same object at all, and that’s why we get both references.

prop is an incoming argument we were given from the form generator. That object contains reflection information for the current property. We use that next to determine if the property is of type string, or not. MudSelect undoubtedly supports binding to all sorts of data types, but, we only support rendering against string properties. That may change, at some point, for now, we only render MudSelect components against string properties.

After verifying the data type of the property, we then call the ToAttributes method, to get a list of attribute values that we can forward to the MudBlazor library. MudBlazor controls typically contain a ridiculous number of options and choices. Since we’re dynamically rendering our MudSelect control, we need to specify some of those choices at compile time. That’s what the properties on the RenderMudSelectAttribute class are for – we make choices with those properties that are then forwarded to the MudSelect control, at render time.

Some of the attribute we’ll pass to MudBlazor need to have default values. Label is a good example, since the control needs to display that whether the developer specified a label on the attribute, or not. As a result, we make sure Label and a few other attributes have something reasonable in them.

Using reflection, we pull the current value of the property we’re rendering and plug it into the Value attribute. We also wire up a callback for the ValueChanged event, which is called by the MudSelect component whenever a user selects anything. MudSelect also has a callback, of sorts, for the For attribute. For is used during validation, so, we go ahead and wire that up as well.

Finally, we get to the RenderUIComponent extension method. This extension method is really the heart of the form generator. It’s not a complicated method but it does wrap some of the lower level Blazor rendering logic for us. In our case, we’re going to use RenderUIComponent to render our MudSelect control.

One of the parameters for RenderUIComponent is a delegate that is used to render child content. In our case, for a MudSelect, the child content needs to be a list of MudSelectItemcomponents, with each one bound to a unique option. Here is where we’ll make use of the OptionsFunc property …

Notice the call to the TryOptionsFunc method. We’ll cover that method shortly, but for now, just know that it goes out and locates whatever method is named in the OptionsFunc property. So, for our example, it is the method that went out and found the GetOptions method on the ExampleVM class.

So, assuming OptionsFunc contains the name of a method, and assuming we found that method using a call to TryOptionsFunc, we then call that named method to get a list of options. On the other hand, if OptionsFunc doesn’t have a method name in it, or we couldn’t find the method, then we fall back to using the Options property, which contains a static, comma separated list of options.

Either way, once we have the list of options, we loop through and create each one as a MudSelectItem component, with another call to the RenderUIComponent extension method – this time as child content of the MudSelect control.

After that, we’re done rendering our MudSelect component.

The only thing I haven’t yet covered is the TryOptionsFunc method. Let’s do that now.

The method follows the standard .NET TryXXX pattern, by sporting a single outgoing parameter for the value we’re trying to create, and returning a true/false value to indicate whether the outgoing parameter is valid, or not. In our case, our method has a single outgoing Func that accepts no parameters and returns a Task that contains an enumerable sequence of strings.

We start by checking the value of the OptionsFunc property. If the property is empty we return false to indicate we failed to find an options function.

If the property does contain a method name, we then start searching for that method. We search for using the view-model, which is typically the top-level object that was presented to the form generator. If we don’t find the method there we move on to the model, which is typically the parent of the property we’re rendering. If we find it in either place we set the value of the Func and return true. Otherwise, we return false.

If we find the MethodInfo for the method on either the model, or the view-model, we then use that to create a LINQ expression, and a lambda. We compile the resulting lambda, and set the value on the outgoing parameter, but we don’t actually call it here – we do that in the Generate method, like we talked about earlier.


So that, in a nutshell, is the new OptionsFunc property. I added it to the RenderMudSelectAttribute and RenderRadioGroupAttribute attributes. I have yet to go back and add it to the equivalent HTML attributes, but, I intend to. I’ll also go out, at some point, and add them to the Syncfusion attributes, if they make sense out there.

Thanks for reading!

Photo by Chris Yang on Unsplash