Closed Tarmil closed 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.
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 _ -> ()
@Tarmil I will give it a try. Thanks for reply.
Released in v0.9.
@Tarmil is it possible to put this in doc ? or some sample we have . it might be very useful for future users.
I documented it here: https://fsbolero.io/docs/Routing#page-models
Don't hesitate to tell me if something is not clear!
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.
@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
That seems to be exactly what I was looking for. Thanks for the help!
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 typeCounter.Model
,/data/{dataId}
has a model of typeData.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:
The inconvenient would be that when using
router.Link
, you need to pass something as the model (presumablyUnchecked.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: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 fromdefaultPageModel
?), it can be done in the update function's handling ofSetPage
.