Fun.Blazor is in 2.0.0 recently, but actually is is under developing for couple of months, some of the features are came from this project.
A glance on the application features
First, let's have a quick view for the application then we will check some code implementations.
Manage widgets with dashboards, so customer can manage the left side menus accordingly and enable some widgets based on their license. They can drag, resize for multiple widgets in the same page or focus on some big widget in page mode to have a cleaner view.
Rich features, like rich filter powered by OData, images, reports handling, process control etc. Of course, some data analysis visualization.
Threejs interop with blazor
Mix server and wasm mode. For some mobile devices, server mode is kind of light mode and is very fast internally in an enterprise environment.
The gif is reduced the frames for small size, but the total time is not changed.
So it is very quick at least in local environment.
Some code implementations
Mix mode
Why it is important, because in the customers workshop there may be some low power devices, so running WASM mode of blazor is not that fast. But luckily, server side mode can improve that. For some other customers, they have factories located in different places on the earth, so the internet will not be that stable and fast, then WASM mode can help on that. For WASM mode, it is just like any other SPA application written by react, vue etc. which did not use websocket for almost all the communication between browser and server.
How to implement that?
It is similar with the template in Fun.Blazor dotnet new fb-mix -o CoolMix
dotnet new --install Fun.Blazor.Templates::2.0.0
Set a standalone wasm project which can publish for WASM mode
Set a asp.net core project which reference the WASM project.
Some glue code
... app.UseBlazorFrameworkFiles($"/{ClientRouteSegment}") ... app.MapBlazorHub($"/{ServerRouteSegment}/_blazor") app.MapFunBlazor($"/{DocsRouteSegment}/{{*path:nonfile}}", ClientPage.page (WASM true)) app.MapFunBlazor($"/{ClientRouteSegment}/{{*path:nonfile}}", ClientPage.page (WASM false)) app.MapFunBlazor($"/{ServerRouteSegment}/{{*path:nonfile}}", ClientPage.page SERVER)
The ClientPage will be the index file for server mode, wasm mode, or docs.
type ClientPage() = inherit FunComponent() override _.Render() = #if DEBUG html.hotReloadComp (xxxx.App.app, "xxxx.App.app") #else xxxx.App.app #endif static member page pageType ctx = let baseUrl = match pageType with | SERVER -> baseUrl $"/{ServerRouteSegment}/" | WASM _ -> baseUrl $"/{ClientRouteSegment}/" let root = match pageType with | SERVER -> rootComp<ClientPage> ctx RenderMode.ServerPrerendered | WASM isDocs -> div { id "app" style { flexCenter } childContent [ background pureLoader p { if isDocs then "Loading Docs" else "Loading WebApp" } spaceV2 brand ] } let blazorJs = fragment { match pageType with | SERVER -> script { src "/_framework/blazor.server.js" } | WASM _ -> script { src "_framework/blazor.webassembly.js" } sharedJsInterop #if DEBUG html.hotReloadJSInterop #endif } html' { style { height "100%" width "100%" overflowHidden } head { baseUrl title { "WinTool WebApp" } meta { charset "utf-8" } meta { name "viewport" content "width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" } stylesheet $"/_content/WinTool.Client.Share/index.css?{RMP.cacheByAppVersion}" stylesheet $"/_content/WinTool.Client.Share/fonts/fonts.css?{RMP.cacheByAppVersion}" stylesheet $"/_content/MudBlazor/MudBlazor.min.css?{RMP.cacheByAppVersion}" } body { root script { src $"/_content/WinTool.Client.Share/chart.js?{RMP.cacheByAppVersion}" } script { src $"/_content/MudBlazor/MudBlazor.min.js?{RMP.cacheByAppVersion}" } blazorJs } }
Another important thing is handling the http requests. To keep things simple and less bug, even for server side mode, the application will use HttpClient to send request instead of inject server side services. And a lot of the apis are powered by OData, so with HttpClient there will have no code changes for both mode. One important thing need to do is passing the cookie for the server side mode, because all the requests is on the server which is like a BBF layer, so the cookie will not automatically append like in the browser.
type IServiceCollection with member services.SetupXXX() = ... services.AddScoped<IHttpClientHelper>(fun sp -> let httpContext = sp.GetService<IHttpContextAccessor>() let notify = sp.GetService<Notification>() let nav = sp.GetService<NavigationManager>() let cookie = httpContext.HttpContext.Request.Headers.Cookie.ToString() let baseUrl = httpContext.HttpContext.Request.Scheme + "://" + httpContext.HttpContext.Request.Host.ToString() { new IHttpClientHelper with member _.GetCookie() = cookie member _.GetBaseUrl() = baseUrl member _.InspectResponseError(error) = match error with | HttpError.ApiShouldRedirectBecauseNoAuth _ -> nav.NavigateTo($"/{ServerRouteSegment}", forceLoad = true) | _ -> error.Handle notify } )
After finished the main part, other stuff is pretty straightforward. Just inject some services and setup asp.net core and use it in normal way, some boring MVC stuff 😂.
Dependency injection for UI component
You will notice that in the application, there are multiple domains which are developed and isolated separately, but from the UI point of view, they need to integrate with each other to make customers happier. Actually there is nothing special here in Fun.Blazor, because you just implement it like below which is normal interfaces/services from asp.net core DI container's point of view.
Define the interface of the domain specific UI component, like:
type IXXXCoreComponent = abstract member ReportPrinter: category: Reports.ReportsCategory * ids: aval<int64 list> -> NodeRenderFragment
Basically, they just need to return NodeRenderFragment, so it can be used every easily. But it is not necessary, for example, if you just want to show a dialog, you can just return unit.
Implement this component in its own domain and register at the program starting setup file.
services.AddScoped<IXXXCoreComponent, XXXCoreComponent>()
Lastly, consume it
let toolbar = html.inject (fun (hook: IComponentHook, store: IShareStore, coreComp: IXXXCoreComponent) -> ... adaptiview () { let! i18n = store.I18n ... div { style { displayFlex; flexWrapWrap; alignItemsCenter } childContent [ ... MudSpacer'.create () MudButtonGroup'() { Variant Variant.Filled Color Color.Primary childContent [ ... coreComp.ReportPrinter( Reports.ReportsCategory.Part, hook.SelectedPartIds |> AVal.map (List.map int64) ) ... ] } ] } } )
State management
State management is not easy, but with FSharp adaptive model, building dependency graph of state will be easier. First, let's see some basic share states:
type IShareStore with
member store.Lang = store.CreateCVal(nameof store.Lang, "en-US")
member store.IsWindowsAuth = store.CreateCVal(nameof store.IsWindowsAuth, false)
member store.CurrentUser = store.CreateCVal(nameof store.CurrentUser, LoadingState<AccountResp>.Loading)
member store.CurrentUserAfterLogin = store.CurrentUser |> AVal.map (fun x -> x.Value.Value) |> AVal.force
member store.Roles = store.CreateCVal(nameof store.Roles, List.empty<Role>)
member store.Permissions = store.CreateCVal(nameof store.Permissions, List.empty<Permission>)
member store.AmIAdmin() = ...
// build a dependency with other states
member store.MyPermissionFor(target, right) =
adaptive {
let! permissions = store.Permissions
let me = store.CurrentUserAfterLogin
let isInRole id = me.Roles |> Seq.exists (fun x -> x.UserRoleId = id)
let isGranted = permissions |> List.exists (fun p -> p.Target = target && isInRole p.RoleId && p.HasRight right)
return isGranted || store.AmIAdmin()
}
member store.MyPermissionForPermission(right) = store.MyPermissionFor(ModifyPermission, right)
member store.MyPermissionForDashboard(id, right) = store.MyPermissionFor(Dashboard id, right)
There are also some domain states which are shared like below. With this it can sync state across different domain components very easily and also consuming them is very simple with adaptiview or just consuming it in another adaptive expression to build up the dependency graph.
type IComponentHook with
/// Helper function for domain states which can be cached and shared across the whole application
member hook.GetOrLoadSharedStore<'T, 'Services>(key: string, reload, getter: 'Services -> Task<LoadingState<'T>>) =
let store = hook.ShareStore
let data = store.CreateCVal(key, LoadingState<'T>.NotStartYet)
let isFirstLoad = store.CreateCVal(key + "-first-load", true)
if reload || isFirstLoad.Value then
transact (fun () ->
isFirstLoad.Value <- false
data.Value <- LoadingState.start data.Value
)
getter (hook.ServiceProvider.GetMultipleServices<'Services>()) |> Task.map data.Publish |> ignore
data :> aval<_>
// Use above helper function to build some state
member hook.GetOrLoadAppSettings(?reload) =
hook.GetOrLoadSharedStore(
"AppSettings",
(defaultArg reload false),
(fun (api: XXXCoreApi) -> api.GetSettings<AppSetting>(fetchAll = true) |> getODataValue)
)
adaptiview () {
let! reasons, isLoading = hook.GetOrLoadReasons() |> AVal.map (LoadingState.unzip [])
if isLoading then linearLoader
MudTextField'() {
ValueChanged(
function
| SafeString x when reasons |> List.contains x |> not -> hook.SaveReasons(reasons @ [ x ]) |> ignore
| _ -> ()
)
Variant Variant.Outlined
Placeholder "Enter to add"
Adornment Adornment.End
AdornmentIcon Icons.Filled.Add
Margin Margin.Dense
}
}
Another cool example is using adaptive model to build step dependency like:
steps
|> AVal.map (fun x -> x.FromCostCenter)
|> AVal.addLazyCallback (fun step ->
let changeSteps () = ...
match step with
| Step.Ready (_, costCenter, _) ->
if costCenter.Caption = CommandStr.IFH then
steps.Publish(fun steps ->
{ steps with
ToCostCenter = Step.MakeReady(IFHCostCenter.Caption, IFHCostCenter)
Reason = Step.MakeReady("", CommandStr.IFH)
Command = Step.MakeEditing("")
}
)
else
changeSteps ()
| _ ->
changeSteps ()
)
steps
|> AVal.map (fun x -> x.ToCostCenter)
|> AVal.addLazyCallback(
...
)
The step model contains multiple step fields, they can just be adaptively changed by listen to other steps changes. The step definition is like below:
type Step<'Value1, 'Value2> =
| Disabled of 'Value1
| Editing of 'Value1 * RefreshKey
/// This is supposed to be used for init setup, which can be promoted by upper step
| Staged of 'Value1 * RefreshKey
/// This is for automatically complete the current step
| PreReady of 'Value1 * RefreshKey
| Ready of 'Value1 * 'Value2 * RefreshKey
Js interop with blazor
Js ecosystem is very rich, the ability to interop with it is very important. Like in the threejs interop use cases.
Use package Microsoft.TypeScript.MSBuild to automatically compile .ts files under the shared project's wwwroot folder to js
namespace utils { export const scrollToElement = (id) => { let ele = document.getElementById(id) if (ele) { ele.scrollIntoView() } } }
In the shared .fs file:
// This the used at the **ClientPage** which is mentioned before. let sharedJsInterop = fragment { script { src "/_content/xxx.Share/index.js" } script { async' true type' "module" src "/_content/xxx.Share/three.utils.js" } } // Inject this interface whenever they want to call scrollToElement type IJSRuntime with member js.scrollToElement(id: string) = js.InvokeVoidAsync("utils.scrollToElement", id)
Summary
The UI code is clean but the intellisense is not that good at first writing it. But when you get familiar, it gets better. Also for third party library like MudBlazor you will always need to check their documents instead of rely on the intellisense at the beginning.
The hot-reload help a lot when you need to tweak the style, but it is slow if you enable too much files for it. It is better to only enable the files which related to the current feature you are building.
Also the hot-reload will not work if you have features located in different projects. The most paint point is the hot-reload.
Overall it is a good experience, even the hot-reload is not fast, but to manage the UI and state is clean and easy to make it right. So most of the time is just writing code to follow with your mind. Then start the application to try. Yes, tweak css style will slow you down, it is always like that no matter which framework you are using, because it is css 😂. But luckily, MudBlazor can help a lot, because most of your time, you do not need to write any css or inline style.
Fun.Blazor 🚀