Skip to content

Commit

Permalink
Prepare release
Browse files Browse the repository at this point in the history
  • Loading branch information
Apollo3zehn committed Aug 6, 2024
1 parent ba4d2fc commit 3f4bd8a
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 49 deletions.
196 changes: 177 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,190 @@
# BlazorJsonForm

Build Blazor forms from JSON Schema using MudBlazor.
[![GitHub Actions page](https://img.shields.io/badge/GitHub_Pages-Live_example-3f9100)](https://apollo3zehn.github.io/BlazorJsonForm)
[![NuGet](https://img.shields.io/nuget/v/BlazorJsonForm.svg?label=Nuget)](https://www.nuget.org/packages/BlazorJsonForm)

The main use case for this library is a Single-Page Blazor application which needs to provide a proper UI for configuration data. The corresponding C# types may be defined in the backend (or in plugins loaded by the backend). With the help of the library `NJsonSchema` it is then easy to generate a JSON schema from these configuration types, send the resulting JSON to the frontend and then use this library to render a nice UI. The backing storage is a `JsonNode` and when the configuration made by the user should be saved, it can be transferred back to the backend as a JSON string. In the backend you can deserialize and validate the data as shown below.
## Introduction

It is also possible to validate everything in the frontend and best practice would be to do both. You can do so by using `MudForm` (MudBlazor) or `EditContext` (Microsoft) which is shown below as well.
Build Blazor forms from JSON Schema using MudBlazor. Inspiration comes from the [JSON Forms](https://jsonforms.io/examples) project.

[Here](https://apollo3zehn.github.io/BlazorJsonForm/) is a live example with a predefined configuration type. This type has many different property to test all different types of data. The button `Nullable` mode makes the live example to use the same configuration type but now with all types being nullable. The effect is that now all properties are allowed to be null and so the validation is expected to succeed.
The main use case for this library is a Single-Page Blazor application (Wasm) that needs to provide a proper UI for configuration data. The corresponding C# types can be defined in the backend (or in plugins loaded by the backend). Using the external library [NJsonSchema](https://github.com/RicoSuter/NJsonSchema) it is then easy to generate a JSON schema from these configuration types, send the resulting JSON to the frontend and finally use this library to render a nice UI. The backing store is a `JsonNode` that can be passed back to the backend as a JSON string when the user's configuration is about to be saved. The backend can easily deserialize the data into a strongly typed instance and validate it afterwards.

The button `Validate form` validates the current state of the form in the frontend. The button `Validate object` causes the JSON form data to be deseralized and validated using data annotations [validator](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.validator) class.
Additionally to the validation in the backend, the frontend can validate the input data as well. This can be achieved by using `MudForm` (MudBlazor) or `EditContext` (Microsoft).

The following data annotation attributes are supported and tested:
[Here is a live example](https://apollo3zehn.github.io/BlazorJsonForm/) with a predefined configuration type. It has many properties to test all kinds of data. The `Nullable mode` button switches between a type without nullable properties and one with only nullable properties (to be able to test both variants).

The `Validate form` button validates the current state of the form in the frontend. And the `Validate object` button causes the JSON form data to be deseralized and validated using data annotations [validator](https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.validator) class. This would normally be done in the backend.

[![GitHub Actions page](image.avif)](https://apollo3zehn.github.io/BlazorJsonForm)

## Getting started

### Requirements

- .NET 8+
- MudBlazor ([installation guide](https://mudblazor.com/getting-started/installation#online-playground))

Ensure these four components are present at the top level (e.g. in `MainLayout.razor`):

```razor
<MudThemeProvider />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
```

- this library: `dotnet add package BlazorJsonForm --prerelease`

### Type definition

The following data types are supported:

- Integer: `byte`, `int`, `ulong`, `long`
- Floating point: `float`, `double`
- Enum, underlying type: `byte`, `sbyte`, `ushort`, `short`, `uint`, `int`, `ulong`, `long`
- `bool`
- `string`
- Object: `class` or `struct` (including `record`)
- Array: `T[]`, `List<T>`, `IList<T>`
- Dictionary: `Dictionary<string, T>`, `IDictionary<string, T>`

All listed types can also be nullable (e.g. `int?` or `string?`).

The simplest way to define you configuration type is to use C# records. Make sure to add proper XML documentation to each property.

```cs
/// <param name="EngineCount">Number of engines</param>
/// <param name="Fuel">Amount of fuel in L</param>
/// <param name="Message">Message from mankind</param>
/// <param name="LaunchCoordinates">Launch coordinates</param>
record RocketData(
int EngineCount,
double Fuel,
string? Message,
int[] LaunchCoordinates
);
```

> [!NOTE]
> See also [Types.cs](https://github.com/Apollo3zehn/BlazorJsonForm/blob/dev/src/BlazorJsonFormTester.Core/Types.cs) for a complete example.
### JSON Schema

The JSON schema can be easily created in the backend via:

```cs
var schema = JsonSchema.FromType<RocketData>();
```

### Blazor

```html
@if (_schema is not null)
{
<JsonForm Schema="_schema" @bind-Data="_data" />
}

@code
{
private JsonSchema _schema;
private JsonNode? _data;

protected override async Task OnInitializedAsync()
{
_schema = await GetJsonSchemaFromBackendAsync(...);
}
}
```

### Frontend Validation

Wrap `JsonForm` in a `MudForm` as shown below and validate the form via `_form.Validate()`:

```html
<MudButton
OnClick="ValidateForm">
Validate Form
</MudButton>

<MudForm @ref="_form">
<JsonForm
Schema="_schema"
@bind-Data="_data" />
</MudForm>

@code
{
// ...
private MudForm _form = default!;

private async Task ValidateForm()
{
await _form.Validate();

if (_form.IsValid)
...

else
...
}
}
```

### Desialization & Backend Validation

AS shown above, the actual configuration data is stored in the instance variable `_data` which is of type `JsonNode?`.

When the frontend validation succeeds, you can serialize the data via `var jsonString = JsonSerializer.Serialize(_data)` and send it to the backend.

The backend can then deserialize the JSON string into a strongly-typed object and validate it:

```cs
var config = JsonSerializer.Deserialize<RocketData>();
```

> [!NOTE]
> If you already use .NET 9 you should enable the `RespectNullableAnnotations` property of the `JsonSerializerOptions` which ensures that for instance a non-nullable string (`string`) is not being populated with a `null` value. Otherwise an exception is being thrown.
The deserialized object can be further validated by using the .NET built-in `Validator` class:

```cs
var validationResults = new List<ValidationResult>();

var isValid = Validator.TryValidateObject(
config,
new ValidationContext(config),
validationResults,
validateAllProperties: true
);
```

The validator validates all properties against certain conditions. These are being expressed using [data annotation attributes](https://learn.microsoft.com/de-de/dotnet/api/system.componentmodel.dataannotations?view=net-8.0). Currently, the following three data annotation attributes are supported and tested:

```cs
[Range(...)]
int Foo { get; set; }

[StringLength(...)]
string Bar { get; set; }

[RegularExpression(...)]
string FooBar { get; set; }
```

You should consider addings the `Required` attribute next to `RegularExpression` attribute because otherwise [empty strings are always valid](https://stackoverflow.com/a/32945086).
> [!NOTE]
> You should consider adding the `Required` attribute next to `RegularExpression` attribute because otherwise [empty strings are always valid](https://stackoverflow.com/a/32945086).
## Extras

![Example of BlazorJsonForm](image.avif)
You can define custom attrbutes which will change the generated JSON schema as described below.

You can define custom attrbutes which will change the generated JSON schema. Here are two example about how to add a [helper text](https://mudblazor.com/components/textfield#form-props-helper-text) to the inputs and about how to specify custom enum member names to be displayed in the UI:
### Helper text

# How to: Helper text
Add a [helper text](https://mudblazor.com/components/textfield#form-props-helper-text) to inputs:

```cs
using NJsonSchema.Annotations;

namespace BlazorJsonFormTester;

[AttributeUsage(AttributeTargets.Property)]
class HelperTextAttribute : Attribute, IJsonSchemaExtensionDataAttribute
{
Expand All @@ -51,17 +205,17 @@ internal record MyConfigurationType(
);
```

# How to: Enum display names
### Enum display names

Specify custom enum member names to be displayed in the UI:

```cs
using NJsonSchema.Annotations;

namespace BlazorJsonFormTester;

[AttributeUsage(AttributeTargets.Enum)]
internal class EnumDisplayNameAttribute : Attribute, IJsonSchemaExtensionDataAttribute
internal class EnumDisplayNamesAttribute : Attribute, IJsonSchemaExtensionDataAttribute
{
public EnumDisplayNameAttribute(params string[] displayNames)
public EnumDisplayNamesAttribute(params string[] displayNames)
{
ExtensionData = new Dictionary<string, object>()
{
Expand All @@ -72,7 +226,7 @@ internal class EnumDisplayNameAttribute : Attribute, IJsonSchemaExtensionDataAtt
public IReadOnlyDictionary<string, object> ExtensionData { get; }
}

[EnumDisplayName(
[EnumDisplayNames(
"The Mercury",
"The Venus",
"The Mars",
Expand All @@ -91,4 +245,8 @@ internal enum MissionTarget
Uranus,
Neptune
}
```
```

# Known issues

- When using `[RegularExpression]` attribute on a string property, `null` values are not supported anymore. This is because the library `NJsonSchema` which is used to generate the schema is treating a `[RegularExpression]` annotated property as non-nullable and so the schema does not carry nullability information anymore.
Binary file modified image.avif
Binary file not shown.
20 changes: 15 additions & 5 deletions src/BlazorJsonForm/JsonForm.razor
Original file line number Diff line number Diff line change
Expand Up @@ -697,12 +697,12 @@
Required="!isNullable"
MaxLength="schema.MaxLength is null ? int.MaxValue : (int)schema.MaxLength"
Counter="schema.MaxLength is null ? null : schema.MaxLength"
Validation="schema.Pattern is null ? null : (Func<string, IEnumerable<string>>)(value => RegexValidation(value, schema.Pattern))"
Validation="schema.Pattern is null ? null : (Func<string, IEnumerable<string>>)(value => RegexValidation(value, schema.Pattern, isNullable))"
Clearable="true"
OnClearButtonClick="() => setValue?.Invoke(default)"
HelperText="@(GetHelperTextOrDefault(schema))"
@bind-Value:get="data is null ? null : data.GetValue<string>()"
@bind-Value:set="value => setValue?.Invoke(JsonValue.Create(value))" />
@bind-Value:set="value => setValue?.Invoke(JsonValue.Create(value))" />
}

private void RenderEnum<T>(
Expand Down Expand Up @@ -763,7 +763,7 @@
T="T?"
Label="@label"
Required="!isNullable"
ToStringFunc="item => valueNamePairs.First(x => x.First.Equals(item)).Second"
ToStringFunc="item => item is null ? string.Empty : valueNamePairs.First(x => x.First.Equals(item)).Second"
Clearable="true"
OnClearButtonClick="() => setValue?.Invoke(default)"
HelperText="@(GetHelperTextOrDefault(schema))"
Expand Down Expand Up @@ -870,16 +870,26 @@
return ["Required"];
}

private IEnumerable<string> RegexValidation(string? input, string pattern)
private IEnumerable<string> RegexValidation(string? input, string pattern, bool isNullable)
{
if (input is null)
return ["Required"];
{
if (isNullable)
return Enumerable.Empty<string>();

else
return ["Required"];
}

else if (Regex.IsMatch(input, pattern))
{
return Enumerable.Empty<string>();
}

else
{
return ["The input does not match the expected pattern"];
}
}

private string? GetHelperTextOrDefault(JsonSchema schema)
Expand Down
46 changes: 28 additions & 18 deletions src/BlazorJsonForm/MudSelectEnhanced.razor
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,37 @@

@code
{
/* Problem:
/* Problem:
*
* When we have a MudSelect in combination with MultiSelection="true", the
* validation function is called before the property SelectedValues is set,
* which renders the validation function useles. The task is not to workaround
* this issue.
*/
* When we use a MudSelect in combination with MultiSelection="true" and
* Required="true" to represent an enum with a [Flags] attribute, it is
* difficult to track if the value is null or empty (0). Normally this
* combination would now allow having no selected values (i.e. no flags
* set) but in reality this is a valid value. Only null should be invalid
* in case Required="true". MudSelect also does not support resetting the
* value to null, so this is also being worked around.
*/

private bool _isCleared;
private bool _hasValue;

/* https://github.com/MudBlazor/MudBlazor/issues/4328#issuecomment-1086940659 */
protected override bool HasValue(T value)
protected override void OnParametersSet()
{
if (MultiSelection)
var originalSelectedValuesChanged = SelectedValuesChanged;

SelectedValuesChanged = new EventCallback<IEnumerable<T>>(this, async () =>
{
var hasValue = !_isCleared;
if (originalSelectedValuesChanged.HasDelegate)
await originalSelectedValuesChanged.InvokeAsync(SelectedValues);

if (_isCleared)
_isCleared = false;
_hasValue = true;
await Validate();
});
}

return hasValue;
}
protected override bool HasValue(T value)
{
if (MultiSelection)
return _hasValue;

else
return base.HasValue(value);
Expand All @@ -41,11 +50,12 @@
{
set
{
Action realCallback = () =>
Func<Task> realCallback = () =>
{
_isCleared = true;
Validate();
value();
_hasValue = false;

return Validate();
};

OnClearButtonClick = new EventCallback<MouseEventArgs>(this, realCallback);
Expand Down
4 changes: 2 additions & 2 deletions src/BlazorJsonFormTester.Core/EnumDisplayNameAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
namespace BlazorJsonFormTester;

[AttributeUsage(AttributeTargets.Enum)]
internal class EnumDisplayNameAttribute : Attribute, IJsonSchemaExtensionDataAttribute
internal class EnumDisplayNamesAttribute : Attribute, IJsonSchemaExtensionDataAttribute
{
public EnumDisplayNameAttribute(params string[] displayNames)
public EnumDisplayNamesAttribute(params string[] displayNames)
{
ExtensionData = new Dictionary<string, object>()
{
Expand Down
5 changes: 2 additions & 3 deletions src/BlazorJsonFormTester.Core/TestingComponent.razor
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
@using System.Text.Json.Schema
@using System.Text.Json.Nodes
@using System.Text.Json.Nodes
@using System.Text.Json
@using NJsonSchema
@using System.ComponentModel.DataAnnotations
Expand Down Expand Up @@ -139,7 +138,7 @@
Severity.Error
);
}
}
}

private void ValidateObject<T>()
{
Expand Down
Loading

0 comments on commit 3f4bd8a

Please sign in to comment.