Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle page models in Router.infer #79

Closed
Tarmil opened this issue Sep 9, 2019 · 9 comments
Closed

Handle page models in Router.infer #79

Tarmil opened this issue Sep 9, 2019 · 9 comments
Labels
enhancement New feature or request released: v0.9

Comments

@Tarmil
Copy link
Member

Tarmil commented Sep 9, 2019

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.

@Tarmil Tarmil added the enhancement New feature or request label Sep 9, 2019
@BentTranberg
Copy link

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
Copy link
Member Author

Tarmil commented Sep 17, 2019

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
Copy link

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

Tarmil added a commit that referenced this issue Sep 20, 2019
Tarmil added a commit that referenced this issue Sep 20, 2019
Tarmil added a commit that referenced this issue Sep 22, 2019
Tarmil added a commit that referenced this issue Sep 22, 2019
@Tarmil
Copy link
Member Author

Tarmil commented Sep 24, 2019

Released in v0.9.

@Tarmil Tarmil closed this as completed Sep 24, 2019
@kunjee17
Copy link

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

@Tarmil
Copy link
Member Author

Tarmil commented Sep 25, 2019

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

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

@vip-ehowlett
Copy link

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
Copy link
Member Author

Tarmil commented Jul 14, 2022

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

  • You have a login page with a page model, containing page-specific stuff like the username and password being typed.

  • This page also needs access to the currently logged in User, which is part of the global state.

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

  • as an extra argument to LoginPage.update and/or LoginPage.view (whichever of the two needs it)

  • using the ExternalMsg pattern if the login page needs to set the current user back into the global model.

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
Copy link

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request released: v0.9
Projects
None yet
Development

No branches or pull requests

4 participants