fsbolero / Bolero

Bolero brings Blazor to F# developers with an easy to use Model-View-Update architecture, HTML combinators, hot reloaded templates, type-safe endpoints, advanced routing and remoting capabilities, and more.
https://fsbolero.io
Apache License 2.0
1.06k stars 53 forks source link

Handle page models in Router.infer #79

Closed Tarmil closed 5 years ago

Tarmil commented 5 years ago

The problem: we have an application with several pages that are routed via Router.infer. We would like to keep a piece of model that is specific to the current page. For example, say /counter has a model of type Counter.Model, /data/{dataId} has a model of type Data.Model, and / and /hello/{name} have no specific model. We don't want to keep all these models side by side in the global app model and have to juggle their behavior when switching pages; instead, we want to only have the model relevant to the current page.

Currently, the only solution to this problem is what @kunjee17 shows in #78: defining a separate union for the model, with a case for each page that needs one. This means a bunch of boilerplate with partial matching at every use site, which is far from ideal.

Instead, we should be able to include the page route and model together in the same union. For example with something like this:

type Page =
    // A page can have no model...
    | [<EndPoint "/">]
      Home
    // ... or just a page model...
    | [<EndPoint "/counter"; PageModel "counter">]
      Counter of counter: Counter.Model
    // ... or just path arguments...
    | [<EndPoint "/hello/{name}">]
      Hello of name: string
    // ... or both.
    | [<EndPoint "/data/{dataId}"; PageModel "data">]
      Data of dataId: int * data: Data.Model

The inconvenient would be that when using router.Link, you need to pass something as the model (presumably Unchecked.defaultof<_>, which we might alias for convenience).

A question that needs to be resolved is how to handle the case when the page is set from the URL. This happens on page startup, and when the user clicks a link. We generate a Page value from the URL, but that means that we need to have a "default" model value to fill that field in. A possibility would be to provide it as an extra argument to Router.infer like this:

let defaultPageModel = function
    | Counter _ -> box Counter.defaultModel
    | Data _ -> box Data.defaultModel
    | Home | Hello _ -> null

let router = Router.inferWithModel SetPage (fun model -> model.page) defaultPageModel

Bolero would call this function once per case with a dummy Page just to get the corresponding default model. Unfortunately this function has to return obj, since each case can have a different type of model (or none at all). I would love suggestions on how to make this better typed! (without switching to a dependently-typed language 😄)

If the app needs more specific handling (for example, if the user is on /data/1 and clicks a link to /data/2, should we keep the existing page model or reset it to the value from defaultPageModel?), it can be done in the update function's handling of SetPage.

BentTranberg commented 5 years ago

I have the same problem/question in my applications that use Elmish.WPF. It would be nice if a solution could be found using Elmish, at least kind of partially, but I understand that could be a tall order.

Tarmil commented 5 years ago

An alternate design using a type instead of an attribute:

// In Bolero:
type PageModel<'T> = { Value: 'T }
let NoModel<'T> = { Value = Unchecked.defaultof<'T> } // Helper for router.Link

// User code:
type Page =
    // A page can have no model...
    | [<EndPoint "/">]
      Home
    // ... or just a page model...
    | [<EndPoint "/counter">]
      Counter of model: PageModel<Counter.Model>
    // ... or just path arguments...
    | [<EndPoint "/hello/{name}">]
      Hello of name: string
    // ... or both.
    | [<EndPoint "/data/{dataId}">]
      Data of dataId: int * model: PageModel<Data.Model>

let myUrl = router.Link (Data (123, NoModel))

The defaultPageModel function can then be better typed using a function defineDefaultModel: PageModel<'T> -> 'T -> unit:

let defaultPageModel = function
    | Counter (model = m) -> Router.defineDefaultModel m Counter.defaultModel
    | Data (model = m) -> Router.defineDefaultModel m Data.defaultModel
    | Home | Hello _ -> ()
kunjee17 commented 5 years ago

@Tarmil I will give it a try. Thanks for reply.

Tarmil commented 5 years ago

Released in v0.9.

kunjee17 commented 5 years ago

@Tarmil is it possible to put this in doc ? or some sample we have . it might be very useful for future users.

Tarmil commented 5 years ago

I documented it here: https://fsbolero.io/docs/Routing#page-models

Don't hesitate to tell me if something is not clear!

vip-ehowlett commented 2 years ago

How would I go about instantiated a page model with multiple different init functions. Say, I want all my login/logout logic in one sub-component and just swap between actions based on states. I need to populated the sub-component with the global state of either having a user or not having a user. I'm not sure how to get that to work with the Router.definePageModel function.

Tarmil commented 2 years ago

@vip-ehowlett So if I understand your need correctly:

I think the solution is to actually not make the User part of the login page model, but instead pass it around separately:

Here's a quick example:

type User = ...

module LoginPage =

    type Model =
        { usernameBeingTyped: string
          passwordBeingTyped: string }

    type Msg =
        | TypeUsername of string
        | TypePassword of string
        | SubmitLogin
        | SubmitLogout
        | LoginSuccessful of User
        | LogoutSuccessful

    type ExternalMsg =
        | NoOp
        | LoggedIn of User
        | LoggedOut

    let initModel =
        { usernameBeingTyped = ""
          passwordBeingTyped = "" }

    // My update function actually doesn't need the current user as input,
    // but if yours does, then here is how to pass it
    let update (msg: Msg) (model: Model) (currentUser: User option) : Model * Cmd<Msg> * ExternalMsg =
        match msg with
        | TypeUsername u -> { model with usernameBeingTyped = u }, Cmd.none, NoOp
        | TypePassword p -> { model with passwordBeingTyped = p }, Cmd.none, NoOp
        | SubmitLogin ->
            let cmd = makeLoginCommand model // using a remote function or something...
            model, cmd, NoOp
        | SubmitLogout ->
            let cmd = makeLogoutCommand() // using a remote function or something...
            model, cmd, NoOp
        | LoginSuccessful user ->
            initModel, Cmd.none, LoggedIn user
        | LogoutSuccessful ->
            initModel, Cmd.none, LogoutSuccessful

    let view (model: Model) (currentUser: User option) (dispatch: Msg -> unit) =
        cond currentUser <| function
            | None -> 
                // ...snip: a form with two text boxes and a button that dispatches SubmitLogin
            | Some user -> 
                // ...snip: display the current username and a button that dispatches SubmitLogout

module Main =

    type Page =
        | Login of PageModel<LoginPage.Model>
        | ...

    type Model =
        { page: Page
          currentUser: User option }

    type Msg =
        | SetPage of page
        | LoginMsg of LoginPage.Msg
        | ...

    let update (msg: Msg) (model: Model) =
        match msg, page with
        | SetPage page, _ ->
            { model with page = page }, Cmd.none
        | LoginMsg loginMsg, Login loginPage ->
            let (loginModel, loginCmd, loginExtMsg) = LoginPage.update loginMsg loginPage.Model model.User

            let newModel =
                match loginExtMsg with
                | LoginPage.NoOp -> model
                | LoginPage.LoggedIn user -> { model with currentUser = Some user }
                | LoginPage.LoggedOut -> { model with currentUser = None }

            let newPage = Page.Login { Model = loginModel }
            { newModel with page = newPage }, Cmd.map LoginMsg loginCmd

    let view (model: Model) (dispatch: Msg -> unit) =
        cond model.page <| function
            | Login loginPage -> LoginPage.view loginPage.model model.currentUser (dispatch << LoginMsg)
            | ...

    let defaultModel = function
        | Login model -> Router.defineModel model LoginPage.initModel
        | ...

    let router = Router.inferWithModel SetPage (fun m -> m.page) defaultModel
vip-ehowlett commented 2 years ago

That seems to be exactly what I was looking for. Thanks for the help!