sporto / elm-example-app

An example Elm single page application
436 stars 85 forks source link

Facing some issues changing "hash" routing to "path" routing #30

Open jiggneshhgohel opened 7 years ago

jiggneshhgohel commented 7 years ago

@sporto First of all Thanks a lot for this awesome tutorial. From start-to-end I didn't faced any discrepancy or errors executing the code given in tutorial on my dev machine.

Now coming to the issue I am facing:

In section Navigation approaches here you have mentioned the cons of using hash routing and as an alternative suggesting to use path routing which does look intuitive compared to the hash routing. I followed the example you referred to in https://github.com/sporto/elm-navigation-pushstate and updated my code such that it uses path routing.

The changes I made can be found below:

Msgs.elm

module Msgs exposing (..)

import Models exposing (Player)
import Navigation exposing (Location)
import RemoteData exposing (WebData)

type Msg
    = OnFetchPlayers (WebData (List Player))
    | OnLocationChange Location
    | ChangeLocation String  -- NOTE: Added this

Routing.elm

parseLocation : Location -> Route
parseLocation location =
    case (parsePath matchers location) of   -- NOTE: Updated `UrlParser.parseHash` with `UrlParser.parsePath` here
        Just route ->
            route

        Nothing ->
            NotFoundRoute

-- WHEN USING PATH-based Navigation
--    * the paths must start with a backward-slash (/)
--    * to match the path against a matcher use `UrlParser.parsePath` function
--      (refer parseLocation function above for e.g)

-- WHEN USING HASH-based Navigation
--    * the paths must start with a Hash-sign (#)
--    * to match the path against a matcher use `UrlParser.parseHash` function
--      (refer parseLocation function above for e.g)

-- Noticed the importance of / and # in README at
--  https://github.com/sporto/elm-navigation-pushstate#links
--      > Links should trigger a message to change the location when clicked, e.g. ChangeLocation "/users"

--  http://package.elm-lang.org/packages/elm-lang/navigation/2.1.0/Navigation#Location
--    > Note 2: These fields correspond exactly with the fields of document.location as described [here](https://developer.mozilla.org/en-US/docs/Web/API/Location)
--        where the targetLink mentions
--          ```
--            Location.pathname
--               Is a DOMString containing an initial '/' followed by the path of the URL..
--
--            Location.hash
--                Is a DOMString containing a '#' followed by the fragment identifier of the URL.
--          ```

playersPath : String
playersPath =
    "/players"

playerPath : PlayerId -> String
playerPath id =
    "/players/" ++ id

CustomHtmlEvents.elm (newly introduced)

module CustomHtmlEvents exposing (..)

import Html exposing (Attribute)
import Html.Events exposing (onWithOptions)
import Json.Decode as Decode

{-|
When clicking a link we want to prevent the default browser behaviour which is to load a new page.
So we use `onWithOptions` instead of `onClick`.
-}
onLinkClick : msg -> Attribute msg
onLinkClick message =
    let
        options =
            { stopPropagation = False
            , preventDefault = True
            }

    in
        onWithOptions "click" options (Decode.succeed message)

Players/List.elm

...
...
import CustomHtmlEvents exposing (onLinkClick)

...
...
..

editBtn : Player -> Html Msg
editBtn player =
    let
        path =
            playerPath player.id
    in
        a
            [ class "btn regular"
            , href path
            , onLinkClick (Msgs.ChangeLocation path)  -- NOTE: Added this attribute
            ]
            [ i [ class "fa fa-pencil mr1" ] [], text "Edit" ]

Players/Edit.elm

...
...
import CustomHtmlEvents exposing (onLinkClick)

...
...
..

listBtn : Html Msg
listBtn =
    let
        path =
            playersPath
    in
        a
            [ class "btn regular"
            , href path
            , onLinkClick (Msgs.ChangeLocation path) -- NOTE: Added this attribute
            ]
            [ i [ class "fa fa-chevron-left mr1"] [], text "List" ]

And that does work! Please refer the screenshots attached while navigating:

1.png (top route)

1

2.png (Player route)

2

3.png (Players route accessed via clicking the List link on top-left in 2.png)

3

Now what I am unable to understand is that when I try to access routes clicking navigation links they work. However when I manually change the route in browser's address bar like

http://localhost:3000/players I get following

5

http://localhost:3000/players/2 I get following

6

http://localhost:3000/ - This one does work by showing the Listing.

http://localhost:3000/#players - This one too works by showing the Listing but I am wondering why? Why this "hash" routing works?

However http://localhost:3000/#players/2 doesn't work.

So I am seeking your help in understanding the above behavior regarding manually changing the URL in address bar and they returning 404 response and how to fix them up to behave as expected i.e. whether I type in the URL or use navigation button or links they should show the desired data.

I have just started learning Elm and also novice to Functional Programming so please bear me on any questions I asked which sound silly to you.

Again Thank you so much for this wonderful tutorial. It demonstrates sheer clarity you have with this tutorial and is definitely one of the foremost resources one should refer to get started with Web App Development with Elm and at the same time getting acquainted the language's core concepts.

sporto commented 7 years ago

Hi When you input http://localhost:3000/players directly on your url you are doing a request to your server. Your server needs to know what page to serve in this case. What you are seeing is the server not knowing what to give you.

For the server http://localhost:3000/, http://localhost:3000/players and http://localhost:3000/players/1 are all different pages.

However http://localhost:3000/, http://localhost:3000/#players and http://localhost:3000/#players/ is all the same page as the hash is ignored by the server.

So what you need is a server that gives you the same page regardless of the path. So if you hit http://localhost:3000/ it should give you the Elm app. If you hit http://localhost:3000/players it should as well.

The key of all this is the server implementation. In the example you can see this here: elm-live --pushstate

The elm-tutorial-app uses webpack dev server. Have a look at https://webpack.js.org/configuration/dev-server/#devserver-historyapifallback , however you will need to learn a bit of webpack to do this.

jiggneshhgohel commented 7 years ago

Thanks @sporto for the elaborate explanation.

As a matter of fact after few hours of posting this issue I searched elm lang navigation 404 on Google and I found this Stackoverflow Post which you have answered and it mentions the same solution i.e using webpack's historyApiFallback option. I will go through it and the link you have given above and try to get this working.

While I do that I am adding below my understanding, after your explanation above, regarding how the pieces fit together and I would request you to provide your confirmation on the same:

When we first time input http://localhost:3000 in address bar this triggers the init flow

Main.elm

init : Location -> ( Model, Cmd Msg )
init location =
    let
        currentRoute =
            Routing.parseLocation location

    in
        ( initialModel currentRoute, fetchPlayers )

matches the route map PlayersRoute top and sets it as the initial route in the model

Models.elm

initialModel : Route -> Model
initialModel route =
    { players = RemoteData.Loading
    , route = route
    }

After setting the initial route, it triggers command fetchPlayers which as can be seen requests data from our API server listening at port 4000 http://localhost:4000

Commands.elm

fetchPlayers : Cmd Msg
fetchPlayers =
    Http.get fetchPlayersUrl playersDecoder
        |> RemoteData.sendRequest
        |> Cmd.map Msgs.OnFetchPlayers

fetchPlayersUrl : String
fetchPlayersUrl =
    "http://localhost:4000/players"

Once the data is fetched it triggers a msg Msgs.OnFetchPlayers which as be seen below sets the fetched Players list from API server and sets it on the Model

Update.elm

...

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Msgs.OnFetchPlayers response ->
            ( { model | players = response }, Cmd.none )
...

Then the view is rendered which matches the case model.route of as Models.PlayersRoute and hence displays the Players Listing. This players listing is rendered on Client server listening at port 3000 http://localhost:3000

View.elm

...
..

view : Model -> Html Msg
view model =
    div []
        [ page model
        ]

page : Model -> Html Msg
page model =
    case model.route of
        Models.PlayersRoute ->
            Players.List.view model.players

        Models.PlayerRoute id ->
            playerEditPage model id

        Models.NotFoundRoute ->
            notFoundView

....

Now that the top route is handled everything now stays in Elm-court. The navigation button or links when clicked requests data from remote server (the API server serving at port 4000). So http://localhost:3000/players when manually accessed returns an error but when accessed via Elm renders data for that route fetching it from http://localhost:4000/players

Similarly when http://localhost:3000/players/2 is manually accessed returns an error but when accessed via Elm renders data for that route fetching it from http://localhost:4000/players/2.

And that perfectly makes sense because Client server (serving on port 3000) doesn't have any routes configured to served from itself. Each route configured is aimed to be served from API server (serving on port 4000)

Now a question arises that if Client server is not configured to serve any route then how come it is able to serve the top route? The reasoning behind the working of it as per my understanding is:

We are using webpack-dev-server and as per its documentation

webpack github io_2017-04-27_13-28-41

it serves the files in the current directory, unless you configure a specific content base.

To load your bundled files, you will need to create an index.html file in the build folder from which static files are served (--content-base option).

And we do have an index.html in our src folder configured to be rendered like this:

in webpack.config.js

...
module.exports = {
  entry: {
    app: [
      "./src/index.js"
    ]
  },

...

in index.js

...

require('./index.html');

...

@sporto can you please confirm this understanding of mine of the flow? If I am wrong at any place requesting you to rectify me.

Thanks.

jiggneshhgohel commented 7 years ago

@sporto One more confusion I have is regarding loading data for Edit route /players/:id :

When we click on Edit btn and the flow reaches to View.elm, in page function case model.route of matches Models.PlayerRoute id and it executes the function playerEditPage model id

View.elm

...

view : Model -> Html Msg
view model =
    div []
        [ page model
        ]

page : Model -> Html Msg
page model =
    case model.route of
        Models.PlayersRoute ->
            Players.List.view model.players

        Models.PlayerRoute id ->
            playerEditPage model id

        Models.NotFoundRoute ->
            notFoundView

playerEditPage : Model -> PlayerId -> Html Msg
playerEditPage model playerId =
    case model.players of
        RemoteData.NotAsked ->
            text ""

        RemoteData.Loading ->
            text "Loading ..."

        RemoteData.Success players ->
            let
                maybePlayer =
                    players
                        |> List.filter (\player -> player.id == playerId)
                        |> List.head

            in
                case maybePlayer of
                    Just player ->
                        Players.Edit.view player

                    Nothing ->
                        notFoundView

        RemoteData.Failure err ->
            text (toString err)

...

So does this function playerEditPage uses the players data set on Model, before, when handled msg Msgs.OnFetchPlayers and then filters the data from that list for matching player id?

Update.elm

...

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Msgs.OnFetchPlayers response ->
            ( { model | players = response }, Cmd.none )

...

I strongly feel that that should be the case. But can you please confirm this understanding of mine?

If I am correct then consider the scenario where in players list doesn't contain some specific information related to a Player which is not available in the list, say Player's Profile information. This profile information should usually be available in the player's details end-point exposed by API GET /players/:id which returns response like

{
  "id": "2",
  "name": "Lance",
  "level": 1,
  "profile": {
      picture: "https://mypictures.com/players/2/picture.jpg",
      address: "my address",
      rating: 3
   }
}

Obviously then the following logic cannot help in rendering the profile information because it as of now just filters data from pre-obtained players list and to achieve the desired purpose we should explicitly request data from end-point http://localhost:4000/players/2 correct?

...

playerEditPage : Model -> PlayerId -> Html Msg
playerEditPage model playerId =
    case model.players of
        RemoteData.NotAsked ->
            text ""

        RemoteData.Loading ->
            text "Loading ..."

        RemoteData.Success players ->
            let
                maybePlayer =
                    players
                        |> List.filter (\player -> player.id == playerId)
                        |> List.head

            in
                case maybePlayer of
                    Just player ->
                        Players.Edit.view player

                    Nothing ->
                        notFoundView

        RemoteData.Failure err ->
            text (toString err)

...

Thanks.

Epikem commented 6 years ago

2018-04-29 2

I configured historyApiFallback of webpack devserver to use index.html and it does work. However, font-awesome icons does not shown when refreshed on pages. It restores when I refresh on route root /.