havit / Havit.Blazor

Free Bootstrap 5 components for ASP.NET Blazor + optional enterprise-level stack for Blazor development (gRPC code-first, layered architecture, localization, auth, ...)
https://havit.blazor.eu
MIT License
479 stars 67 forks source link

[doc] How do I correctly create custom components that leverage all the good stuff in Hx components #602

Open pollardpj opened 1 year ago

pollardpj commented 1 year ago

I have a requirement to create a custom component, in this case, a date/time picker, which I want to do according to best practice. I am struggling to get validation messages showing within EditForm, I must be doing something fundamentally wrong. I can put this code into a branch for someone to tell me what I should do, but essentially, I want to have some custom code in a component, simple example:

<HxInputText Value="@Value" Label="@Label" ValueChanged="OnValueChanged" ValueExpression="() => Value">
    <InputGroupEndTemplate>
        <HxButton Color="ThemeColor.Primary" Text="Test" />
    </InputGroupEndTemplate>
</HxInputText>

@code {
    [Parameter]
    public string Label { get; set; }

    [Parameter]
    public string Value { get; set; }

    [Parameter]
    public EventCallback<string> ValueChanged { get; set; }

    private async Task OnValueChanged(string value)
    {
        Value = value;
        await ValueChanged.InvokeAsync(value);
    }
}

And use it in EditForm:

@page "/CustomInput_Issue602_Test"

@using System.ComponentModel.DataAnnotations
@using BlazorAppTest.Pages.IssueXYZComponents

<h1>Custom Input</h1>

<EditForm Model="Model">
    <DataAnnotationsValidator />
    <HxInputText Label="HxInputText" @bind-Value="Model.ImportantValue" />
    <CustomInput1 Label="CustomInput1" @bind-Value="Model.ImportantValue" />
    <HxSubmit Text="Submit" Color="ThemeColor.Primary" />
</EditForm>

<p>The Value: @Model.ImportantValue</p>

@code {
    private TheModel Model { get; set; }
    private EditContext EditContext { get; set; }

    protected override void OnInitialized()
    {
        Model = new();
        EditContext = new EditContext(Model);
    }

    private class TheModel
    {
        [Required]
        public string ImportantValue { get; set; } = "delete me";
    }
}

But validation messages only show for the HxInputText component:

image

What am I failing to do here?

pollardpj commented 1 year ago

I think I figured out what I was doing wrong. I needed:

[Parameter]
public Expression<Func<string>> ValueExpression { get; set; }

In this updated custom component, validation messages appear as expected:

@using System.Linq.Expressions

<HxInputText Value="@Value" Label="@Label" ValueChanged="OnValueChanged" ValueExpression="ValueExpression">
    <InputGroupEndTemplate>
        <HxButton Color="ThemeColor.Primary" Text="Test" OnClick="@(() => OnValueChanged("Test"))" />
    </InputGroupEndTemplate>
</HxInputText>

@code {
    [Parameter]
    public string Label { get; set; }

    [Parameter]
    public string Value { get; set; }

    [Parameter]
    public EventCallback<string> ValueChanged { get; set; }

    [Parameter]
    public Expression<Func<string>> ValueExpression { get; set; }

    private async Task OnValueChanged(string value)
    {
        Value = value;
        await ValueChanged.InvokeAsync(value);
    }
}

Is this the way I should implement a custom string based component?

hakenr commented 1 year ago

If you want to get "all the good stuff" you have to derive from HxInputBase. Usually you follow this pattern:

  1. Separate a raw visual implementation of the input-part to an internal component.
  2. Create the full-featured input component by deriving from HxInputBase and delegating the input-part to the internal component created above.

E.g.

using System.Diagnostics.CodeAnalysis;
using Havit.Blazor.Components.Web.Bootstrap.Internal;
using Havit.Bt.Blazor.Components.Forms.Internal;
using Havit.Bt.Blazor.Internal;
using Microsoft.AspNetCore.Components.Rendering;

namespace Havit.Bt.Blazor;

public class BtSingleSelect<TValue, TItem> : HxInputBase<TValue>, IInputWithTipLabel, IInputWithSize, IInputWithPlaceholder
{
    [Parameter] public BtSingleSelectSettings Settings { get; set; }
    protected override BtSingleSelectSettings GetSettings() => this.Settings;

    /// <inheritdoc />>
    [Parameter] public bool TipVisible { get; set; } = false;

    /// <inheritdoc />>
    [Parameter] public string TipText { get; set; } = IInputWithTipLabel.DefaultTipText;

    /// <inheritdoc />>
    [Parameter] public Func<TItem, string> TextSelector { get; set; } = x => x.ToString();

    /// <inheritdoc />>
    [Parameter] public Func<TItem, TValue> ValueSelector { get; set; }

    /// <inheritdoc />>
    [Parameter] public IList<TItem> Items { get; set; } = new List<TItem>();

    /// <inheritdoc />>
    [Parameter] public PopperOffset Offset { get; set; } = new PopperOffset(0, 0);

    /// <inheritdoc />>
    [Parameter] public Func<TItem, IComparable> SortKeySelector { get; set; }

    /// <inheritdoc />>
    [Parameter] public bool AutoSort { get; set; } = true;

    /// <inheritdoc />>
    [Parameter] public string Placeholder { get; set; }

    /// <inheritdoc />>
    [Parameter] public EventCallback<string> TextChanged { get; set; }

    /// <inheritdoc />>
    [Parameter] public string EmptyDataText { get; set; }

    /// <inheritdoc />>
    [Parameter] public int? MenuMaxHeight { get; set; }
    protected int MenuMaxHeightEffective => this.MenuMaxHeight ?? this.GetSettings()?.MenuMaxHeight ?? this.GetDefaults().MenuMaxHeight ?? throw new InvalidOperationException(nameof(MenuMaxHeight) + " default for " + nameof(BtSingleSelect) + " has to be set.");

    /// <summary>
    /// Custom CSS Class to render with dropdown items.
    /// </summary>
    [Parameter] public string ItemCssClass { get; set; }

    /// <summary>
    /// Size of the input.
    /// </summary>
    [Parameter] public InputSize? InputSize { get; set; }
    protected InputSize InputSizeEffective => this.InputSize ?? GetSettings()?.InputSize ?? GetDefaults()?.InputSize ?? throw new InvalidOperationException(nameof(InputSize) + " default for " + nameof(HxInputNumber) + " has to be set.");
    InputSize IInputWithSize.InputSizeEffective => this.InputSizeEffective;

    [Parameter] public string SoftValidationWarning { get; set; }

    [Parameter] public bool? Nullable { get; set; }
    protected bool NullableEffective
    {
        get
        {
            if (Nullable is not null)
            {
                return Nullable.Value;
            }

            if (System.Nullable.GetUnderlyingType(typeof(TValue)) is not null)
            {
                return true;
            }

            if (typeof(TValue).IsClass)
            {
                return true;
            }

            return false;
        }
    }

    [Parameter] public string NullText { get; set; }

    protected override BtSingleSelectSettings GetDefaults() => BtSingleSelect.Defaults;

    protected override void BuildRenderValidationMessage(RenderTreeBuilder builder)
    {
        if (!string.IsNullOrEmpty(SoftValidationWarning))
        {
            builder.OpenRegion(1);

            builder.OpenElement(1, "div");
            builder.AddAttribute(2, "class", "is-warning");
            builder.CloseElement();

            builder.OpenElement(10, "div");
            builder.AddAttribute(11, "class", "warning-feedback feedback-floating position-absolute top-100 start-0 text-truncate");
            builder.AddContent(12, this.SoftValidationWarning);
            builder.CloseElement();

            builder.CloseRegion();
        }

        base.BuildRenderValidationMessage(builder);
    }

    protected override string GetInputCssClassToRender()
    {
        var cssClass = base.GetInputCssClassToRender();

        if (!string.IsNullOrEmpty(SoftValidationWarning))
        {
            cssClass = CssClassHelper.Combine(cssClass, "is-warning");
        }

        return cssClass;
    }

    protected override void BuildRenderInput(RenderTreeBuilder builder)
    {
        builder.OpenRegion(3000);

        builder.OpenComponent<BtSingleSelectInternal<TValue, TItem>>(3001);
        builder.AddAttribute(3003, nameof(BtSingleSelectInternal<TValue, TItem>.TextSelector), TextSelector);
        builder.AddAttribute(3004, nameof(BtSingleSelectInternal<TValue, TItem>.Items), Items);
        builder.AddAttribute(3005, nameof(BtSingleSelectInternal<TValue, TItem>.Offset), Offset);
        builder.AddAttribute(3006, nameof(BtSingleSelectInternal<TValue, TItem>.SortKeySelector), SortKeySelector);
        builder.AddAttribute(3007, nameof(BtSingleSelectInternal<TValue, TItem>.AutoSort), AutoSort);
        builder.AddAttribute(3008, nameof(BtSingleSelectInternal<TValue, TItem>.Placeholder), Placeholder);
        builder.AddAttribute(3009, nameof(BtSingleSelectInternal<TValue, TItem>.TextChanged), TextChanged);
        builder.AddAttribute(3010, nameof(BtSingleSelectInternal<TValue, TItem>.CssClass), CssClass);
        builder.AddAttribute(3011, nameof(BtSingleSelectInternal<TValue, TItem>.EnabledEffective), EnabledEffective);
        builder.AddAttribute(3012, nameof(BtSingleSelectInternal<TValue, TItem>.Value), Value);
        builder.AddAttribute(3013, nameof(BtSingleSelectInternal<TValue, TItem>.ValueChanged), EventCallback.Factory.Create<TValue>(this, HandleValueChanged));
        builder.AddAttribute(3014, nameof(BtSingleSelectInternal<TValue, TItem>.ValueSelector), ValueSelector);
        builder.AddAttribute(3015, nameof(BtSingleSelectInternal<TValue, TItem>.EmptyDataText), EmptyDataText);
        builder.AddAttribute(3016, nameof(BtSingleSelectInternal<TValue, TItem>.MenuMaxHeightEffective), MenuMaxHeightEffective);
        builder.AddAttribute(3017, nameof(BtSingleSelectInternal<TValue, TItem>.InputCssClass), GetInputCssClassToRender());
        builder.AddAttribute(3018, nameof(BtSingleSelectInternal<TValue, TItem>.ItemCssClass), ItemCssClass);
        builder.AddAttribute(3019, nameof(BtSingleSelectInternal<TValue, TItem>.InputName), Label);
        builder.AddAttribute(3020, nameof(BtSingleSelectInternal<TValue, TItem>.NullableEffective), NullableEffective);
        builder.AddAttribute(3021, nameof(BtSingleSelectInternal<TValue, TItem>.NullText), NullText);
        builder.CloseComponent();

        builder.CloseRegion();
    }

    private void HandleValueChanged(TValue newValue)
    {
        CurrentValue = newValue; // setter includes ValueChanged + NotifyFieldChanged
    }

    protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out TValue result, [NotNullWhen(false)] out string validationErrorMessage)
    {
        throw new NotImplementedException();
    }

}

The HxInputBase is and abstract class which forces you to implement:

...all the other virtual methods allow you to customize some feature of HxInputBase.

I'll take this as a suggestion to add some guidelines to the documentation, will track this in our backlog.

pollardpj commented 1 year ago

Thanks @hakenr , that's clearer to me now, I will give it a go when I feel brave enough :)