Update advanced/part7

This commit is contained in:
Richard Feldman
2018-08-13 22:20:44 -04:00
parent 426ee74b86
commit 8574576934
4 changed files with 349 additions and 257 deletions

View File

@@ -2,11 +2,12 @@ module Article.Feed
exposing
( Model
, Msg
, decoder
, init
, selectTag
, update
, viewArticles
, viewFeedSources
, viewPagination
, viewTabs
)
import Api
@@ -16,12 +17,11 @@ import Article.Slug as ArticleSlug exposing (Slug)
import Article.Tag as Tag exposing (Tag)
import Author
import Avatar exposing (Avatar)
import Browser.Dom as Dom
import Html exposing (..)
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src)
import Html.Events exposing (onClick)
import Http
import HttpBuilder exposing (RequestBuilder, withExpect, withQueryParams)
import HttpBuilder exposing (RequestBuilder)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (required)
import Page
@@ -32,6 +32,7 @@ import Session exposing (Session)
import Task exposing (Task)
import Time
import Timestamp
import Url exposing (Url)
import Username exposing (Username)
import Viewer exposing (Viewer)
import Viewer.Cred as Cred exposing (Cred)
@@ -59,37 +60,29 @@ overkill, so we use simpler APIs instead.
type Model
= Model InternalModel
= Model Internals
{-| This should not be exposed! We want to benefit from the guarantee that only
this module can create or alter this model. This way if it ever ends up in
a surprising state, we know exactly where to look: this module.
-}
type alias InternalModel =
type alias Internals =
{ session : Session
, errors : List String
, articles : PaginatedList (Article Preview)
, sources : FeedSources
, isLoading : Bool
}
init : Session -> FeedSources -> Task Http.Error Model
init session sources =
let
fromArticles articles =
Model
{ session = session
, errors = []
, articles = articles
, sources = sources
, isLoading = False
}
in
FeedSources.selected sources
|> fetch (Session.cred session) 1
|> Task.map fromArticles
init : Session -> PaginatedList (Article Preview) -> Model
init session articles =
Model
{ session = session
, errors = []
, articles = articles
, isLoading = False
}
@@ -97,7 +90,7 @@ init session sources =
viewArticles : Time.Zone -> Model -> List (Html Msg)
viewArticles timeZone (Model { articles, sources, session }) =
viewArticles timeZone (Model { articles, session, errors }) =
let
maybeCred =
Session.cred session
@@ -105,14 +98,8 @@ viewArticles timeZone (Model { articles, sources, session }) =
articlesHtml =
PaginatedList.values articles
|> List.map (viewPreview maybeCred timeZone)
feedSource =
FeedSources.selected sources
pagination =
PaginatedList.view ClickedFeedPage articles (limit feedSource)
in
List.append articlesHtml [ pagination ]
Page.viewErrors ClickedDismissErrors errors :: articlesHtml
viewPreview : Maybe Cred -> Time.Zone -> Article Preview -> Html Msg
@@ -167,85 +154,43 @@ viewPreview maybeCred timeZone article =
[ h1 [] [ text title ]
, p [] [ text description ]
, span [] [ text "Read more..." ]
, ul [ class "tag-list" ]
(List.map viewTag (Article.metadata article).tags)
]
]
viewFeedSources : Model -> Html Msg
viewFeedSources (Model { sources, isLoading, errors }) =
let
errorsHtml =
Page.viewErrors ClickedDismissErrors errors
in
viewTabs :
List ( String, msg )
-> ( String, msg )
-> List ( String, msg )
-> Html msg
viewTabs before selected after =
ul [ class "nav nav-pills outline-active" ] <|
List.concat
[ List.map (viewFeedSource False) (FeedSources.before sources)
, [ viewFeedSource True (FeedSources.selected sources) ]
, List.map (viewFeedSource False) (FeedSources.after sources)
, [ errorsHtml ]
[ List.map (viewTab []) before
, [ viewTab [ class "active" ] selected ]
, List.map (viewTab []) after
]
viewFeedSource : Bool -> Source -> Html Msg
viewFeedSource isSelected source =
viewTab : List (Attribute msg) -> ( String, msg ) -> Html msg
viewTab attrs ( name, msg ) =
li [ class "nav-item" ]
[ a
[ classList [ ( "nav-link", True ), ( "active", isSelected ) ]
, onClick (ClickedFeedSource source)
-- The RealWorld CSS requires an href to work properly.
, href ""
]
[ text (sourceName source) ]
[ -- Note: The RealWorld CSS requires an href to work properly.
a (class "nav-link" :: onClick msg :: href "" :: attrs)
[ text name ]
]
selectTag : Maybe Cred -> Tag -> Cmd Msg
selectTag maybeCred tag =
let
source =
TagFeed tag
in
fetch maybeCred 1 source
|> Task.attempt (CompletedFeedLoad source)
viewPagination : (Int -> msg) -> Model -> Html msg
viewPagination toMsg (Model feed) =
PaginatedList.view toMsg feed.articles
sourceName : Source -> String
sourceName source =
case source of
YourFeed _ ->
"Your Feed"
GlobalFeed ->
"Global Feed"
TagFeed tagName ->
"#" ++ Tag.toString tagName
FavoritedFeed username ->
"Favorited Articles"
AuthorFeed username ->
"My Articles"
limit : Source -> Int
limit feedSource =
case feedSource of
YourFeed _ ->
10
GlobalFeed ->
10
TagFeed tagName ->
10
FavoritedFeed username ->
5
AuthorFeed username ->
5
viewTag : String -> Html msg
viewTag tagName =
li [ class "tag-default tag-pill tag-outline" ] [ text tagName ]
@@ -256,10 +201,7 @@ type Msg
= ClickedDismissErrors
| ClickedFavorite Cred Slug
| ClickedUnfavorite Cred Slug
| ClickedFeedPage Int
| ClickedFeedSource Source
| CompletedFavorite (Result Http.Error (Article Preview))
| CompletedFeedLoad Source (Result Http.Error (PaginatedList (Article Preview)))
update : Maybe Cred -> Msg -> Model -> ( Model, Cmd Msg )
@@ -268,32 +210,6 @@ update maybeCred msg (Model model) =
ClickedDismissErrors ->
( Model { model | errors = [] }, Cmd.none )
ClickedFeedSource source ->
( Model { model | isLoading = True }
, source
|> fetch maybeCred 1
|> Task.attempt (CompletedFeedLoad source)
)
CompletedFeedLoad source (Ok articles) ->
( Model
{ model
| articles = articles
, sources = FeedSources.select source model.sources
, isLoading = False
}
, Cmd.none
)
CompletedFeedLoad _ (Err error) ->
( Model
{ model
| errors = Api.addServerError model.errors
, isLoading = False
}
, Cmd.none
)
ClickedFavorite cred slug ->
fave Article.favorite cred slug model
@@ -310,72 +226,6 @@ update maybeCred msg (Model model) =
, Cmd.none
)
ClickedFeedPage page ->
let
source =
FeedSources.selected model.sources
in
( Model model
, fetch maybeCred page source
|> Task.andThen (\articles -> Task.map (\_ -> articles) scrollToTop)
|> Task.attempt (CompletedFeedLoad source)
)
scrollToTop : Task x ()
scrollToTop =
Dom.setViewport 0 0
-- It's not worth showing the user anything special if scrolling fails.
-- If anything, we'd log this to an error recording service.
|> Task.onError (\_ -> Task.succeed ())
fetch : Maybe Cred -> Int -> Source -> Task Http.Error (PaginatedList (Article Preview))
fetch maybeCred page feedSource =
let
articlesPerPage =
limit feedSource
offset =
(page - 1) * articlesPerPage
params =
[ ( "limit", String.fromInt articlesPerPage )
, ( "offset", String.fromInt offset )
]
in
Task.map (PaginatedList.mapPage (\_ -> page)) <|
case feedSource of
YourFeed cred ->
params
|> buildFromQueryParams (Just cred) (Api.url [ "articles", "feed" ])
|> Cred.addHeader cred
|> HttpBuilder.toRequest
|> Http.toTask
GlobalFeed ->
list maybeCred params
TagFeed tagName ->
list maybeCred (( "tag", Tag.toString tagName ) :: params)
FavoritedFeed username ->
list maybeCred (( "favorited", Username.toString username ) :: params)
AuthorFeed username ->
list maybeCred (( "author", Username.toString username ) :: params)
list :
Maybe Cred
-> List ( String, String )
-> Task Http.Error (PaginatedList (Article Preview))
list maybeCred params =
buildFromQueryParams maybeCred (Api.url [ "articles" ]) params
|> Cred.addHeaderIfAvailable maybeCred
|> HttpBuilder.toRequest
|> Http.toTask
replaceArticle : Article a -> Article a -> Article a
replaceArticle newArticle oldArticle =
@@ -390,29 +240,24 @@ replaceArticle newArticle oldArticle =
-- SERIALIZATION
decoder : Maybe Cred -> Decoder (PaginatedList (Article Preview))
decoder maybeCred =
decoder : Maybe Cred -> Int -> Decoder (PaginatedList (Article Preview))
decoder maybeCred resultsPerPage =
Decode.succeed PaginatedList.fromList
|> required "articlesCount" Decode.int
|> required "articlesCount" (pageCountDecoder resultsPerPage)
|> required "articles" (Decode.list (Article.previewDecoder maybeCred))
-- REQUEST
buildFromQueryParams : Maybe Cred -> String -> List ( String, String ) -> RequestBuilder (PaginatedList (Article Preview))
buildFromQueryParams maybeCred url queryParams =
HttpBuilder.get url
|> withExpect (Http.expectJson (decoder maybeCred))
|> withQueryParams queryParams
pageCountDecoder : Int -> Decoder Int
pageCountDecoder resultsPerPage =
Decode.int
|> Decode.map (\total -> ceiling (toFloat total / toFloat resultsPerPage))
-- INTERNAL
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> InternalModel -> ( Model, Cmd Msg )
fave : (Slug -> Cred -> Http.Request (Article Preview)) -> Cred -> Slug -> Internals -> ( Model, Cmd Msg )
fave toRequest cred slug model =
( Model model
, toRequest slug cred

View File

@@ -3,20 +3,25 @@ module Page.Home exposing (Model, Msg, init, subscriptions, toSession, update, v
{-| The homepage. You can get here via either the / or /#/ routes.
-}
import Article
import Api
import Article exposing (Article, Preview)
import Article.Feed as Feed
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
import Article.Tag as Tag exposing (Tag)
import Browser.Dom as Dom
import Html exposing (..)
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder)
import Html.Events exposing (onClick)
import Http
import HttpBuilder
import Loading
import Log
import Page
import PaginatedList exposing (PaginatedList)
import Session exposing (Session)
import Task exposing (Task)
import Time
import Username exposing (Username)
import Viewer.Cred as Cred exposing (Cred)
@@ -27,6 +32,8 @@ import Viewer.Cred as Cred exposing (Cred)
type alias Model =
{ session : Session
, timeZone : Time.Zone
, feedTab : FeedTab
, feedPage : Int
-- Loaded independently from server
, tags : Status (List Tag)
@@ -41,28 +48,35 @@ type Status a
| Failed
type FeedTab
= YourFeed Cred
| GlobalFeed
| TagFeed Tag
init : Session -> ( Model, Cmd Msg )
init session =
let
feedSources =
feedTab =
case Session.cred session of
Just cred ->
FeedSources.fromLists (YourFeed cred) [ GlobalFeed ]
YourFeed cred
Nothing ->
FeedSources.fromLists GlobalFeed []
GlobalFeed
loadTags =
Tag.list
|> Http.toTask
Http.toTask Tag.list
in
( { session = session
, timeZone = Time.utc
, feedTab = feedTab
, feedPage = 1
, tags = Loading
, feed = Loading
}
, Cmd.batch
[ Feed.init session feedSources
[ fetchFeed session feedTab 1
|> Task.attempt CompletedFeedLoad
, Tag.list
|> Http.send CompletedTagsLoad
@@ -87,7 +101,17 @@ view model =
[ div [ class "col-md-9" ] <|
case model.feed of
Loaded feed ->
viewFeed model.timeZone feed
[ div [ class "feed-toggle" ] <|
List.concat
[ [ viewTabs
(Session.cred model.session)
model.feedTab
]
, Feed.viewArticles model.timeZone feed
|> List.map (Html.map GotFeedMsg)
, [ Feed.viewPagination ClickedFeedPage feed ]
]
]
Loading ->
[]
@@ -130,11 +154,58 @@ viewBanner =
]
viewFeed : Time.Zone -> Feed.Model -> List (Html Msg)
viewFeed timeZone feed =
div [ class "feed-toggle" ]
[ Feed.viewFeedSources feed |> Html.map GotFeedMsg ]
:: (Feed.viewArticles timeZone feed |> List.map (Html.map GotFeedMsg))
-- TABS
viewTabs : Maybe Cred -> FeedTab -> Html Msg
viewTabs maybeCred tab =
case tab of
YourFeed cred ->
Feed.viewTabs [] (yourFeed cred) [ globalFeed ]
GlobalFeed ->
let
otherTabs =
case maybeCred of
Just cred ->
[ yourFeed cred ]
Nothing ->
[]
in
Feed.viewTabs otherTabs globalFeed []
TagFeed tag ->
let
otherTabs =
case maybeCred of
Just cred ->
[ yourFeed cred, globalFeed ]
Nothing ->
[ globalFeed ]
in
Feed.viewTabs otherTabs (tagFeed tag) []
yourFeed : Cred -> ( String, Msg )
yourFeed cred =
( "Your Feed", ClickedTab (YourFeed cred) )
globalFeed : ( String, Msg )
globalFeed =
( "Global Feed", ClickedTab GlobalFeed )
tagFeed : Tag -> ( String, Msg )
tagFeed tag =
( "#" ++ Tag.toString tag, ClickedTab (TagFeed tag) )
-- TAGS
viewTags : List Tag -> Html Msg
@@ -160,6 +231,8 @@ viewTag tagName =
type Msg
= ClickedTag Tag
| ClickedTab FeedTab
| ClickedFeedPage Int
| CompletedFeedLoad (Result Http.Error Feed.Model)
| CompletedTagsLoad (Result Http.Error (List Tag))
| GotTimeZone Time.Zone
@@ -171,12 +244,28 @@ type Msg
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
ClickedTag tagName ->
ClickedTag tag ->
let
subCmd =
Feed.selectTag (Session.cred model.session) tagName
feedTab =
TagFeed tag
in
( model, Cmd.map GotFeedMsg subCmd )
( { model | feedTab = feedTab }
, fetchFeed model.session feedTab 1
|> Task.attempt CompletedFeedLoad
)
ClickedTab tab ->
( { model | feedTab = tab }
, fetchFeed model.session tab 1
|> Task.attempt CompletedFeedLoad
)
ClickedFeedPage page ->
( { model | feedPage = page }
, fetchFeed model.session model.feedTab page
|> Task.andThen (\feed -> Task.map (\_ -> feed) scrollToTop)
|> Task.attempt CompletedFeedLoad
)
CompletedFeedLoad (Ok feed) ->
( { model | feed = Loaded feed }, Cmd.none )
@@ -242,6 +331,53 @@ update msg model =
-- HTTP
fetchFeed : Session -> FeedTab -> Int -> Task Http.Error Feed.Model
fetchFeed session feedTabs page =
let
maybeCred =
Session.cred session
builder =
case feedTabs of
YourFeed cred ->
Api.url [ "articles", "feed" ]
|> HttpBuilder.get
|> Cred.addHeader cred
GlobalFeed ->
Api.url [ "articles" ]
|> HttpBuilder.get
|> Cred.addHeaderIfAvailable maybeCred
TagFeed tag ->
Api.url [ "articles" ]
|> HttpBuilder.get
|> Cred.addHeaderIfAvailable maybeCred
|> HttpBuilder.withQueryParam "tag" (Tag.toString tag)
in
builder
|> HttpBuilder.withExpect (Http.expectJson (Feed.decoder maybeCred articlesPerPage))
|> PaginatedList.fromRequestBuilder articlesPerPage page
|> Task.map (Feed.init session)
articlesPerPage : Int
articlesPerPage =
10
scrollToTop : Task x ()
scrollToTop =
Dom.setViewport 0 0
-- It's not worth showing the user anything special if scrolling fails.
-- If anything, we'd log this to an error recording service.
|> Task.onError (\_ -> Task.succeed ())
-- SUBSCRIPTIONS

View File

@@ -3,6 +3,8 @@ module Page.Profile exposing (Model, Msg, init, subscriptions, toSession, update
{-| An Author's profile.
-}
import Api
import Article exposing (Article, Preview)
import Article.Feed as Feed
import Article.FeedSources as FeedSources exposing (FeedSources, Source(..))
import Author exposing (Author(..), FollowedAuthor, UnfollowedAuthor)
@@ -10,9 +12,11 @@ import Avatar exposing (Avatar)
import Html exposing (..)
import Html.Attributes exposing (..)
import Http
import HttpBuilder exposing (RequestBuilder)
import Loading
import Log
import Page
import PaginatedList exposing (PaginatedList)
import Profile exposing (Profile)
import Route
import Session exposing (Session)
@@ -31,6 +35,8 @@ type alias Model =
{ session : Session
, timeZone : Time.Zone
, errors : List String
, feedTab : FeedTab
, feedPage : Int
-- Loaded independently from server
, author : Status Author
@@ -38,6 +44,11 @@ type alias Model =
}
type FeedTab
= MyArticles
| FavoritedArticles
type Status a
= Loading Username
| LoadingSlowly Username
@@ -54,6 +65,8 @@ init session username =
( { session = session
, timeZone = Time.utc
, errors = []
, feedTab = defaultFeedTab
, feedPage = 1
, author = Loading username
, feed = Loading username
}
@@ -62,16 +75,68 @@ init session username =
|> Http.toTask
|> Task.mapError (Tuple.pair username)
|> Task.attempt CompletedAuthorLoad
, defaultFeedSources username
|> Feed.init session
|> Task.mapError (Tuple.pair username)
|> Task.attempt CompletedFeedLoad
, fetchFeed session defaultFeedTab username 1
, Task.perform GotTimeZone Time.here
, Task.perform (\_ -> PassedSlowLoadThreshold) Loading.slowThreshold
]
)
currentUsername : Model -> Username
currentUsername model =
case model.author of
Loading username ->
username
LoadingSlowly username ->
username
Loaded author ->
Author.username author
Failed username ->
username
defaultFeedTab : FeedTab
defaultFeedTab =
MyArticles
-- HTTP
fetchFeed : Session -> FeedTab -> Username -> Int -> Cmd Msg
fetchFeed session feedTabs username page =
let
maybeCred =
Session.cred session
( extraParamName, extraParamVal ) =
case feedTabs of
MyArticles ->
( "author", Username.toString username )
FavoritedArticles ->
( "favorited", Username.toString username )
in
Api.url [ "articles" ]
|> HttpBuilder.get
|> HttpBuilder.withExpect (Http.expectJson (Feed.decoder maybeCred articlesPerPage))
|> HttpBuilder.withQueryParam extraParamName extraParamVal
|> Cred.addHeaderIfAvailable maybeCred
|> PaginatedList.fromRequestBuilder articlesPerPage page
|> Task.map (Feed.init session)
|> Task.mapError (Tuple.pair username)
|> Task.attempt CompletedFeedLoad
articlesPerPage : Int
articlesPerPage =
5
-- VIEW
@@ -145,7 +210,18 @@ view model =
, case model.feed of
Loaded feed ->
div [ class "container" ]
[ div [ class "row" ] [ viewFeed model.timeZone feed ] ]
[ div [ class "row" ]
[ div [ class "col-xs-12 col-md-10 offset-md-1" ]
[ div [ class "articles-toggle" ] <|
List.concat
[ [ viewTabs model.feedTab ]
, Feed.viewArticles model.timeZone feed
|> List.map (Html.map GotFeedMsg)
, [ Feed.viewPagination ClickedFeedPage feed ]
]
]
]
]
Loading _ ->
text ""
@@ -202,15 +278,27 @@ defaultTitle =
-- FEED
-- TABS
viewFeed : Time.Zone -> Feed.Model -> Html Msg
viewFeed timeZone feed =
div [ class "col-xs-12 col-md-10 offset-md-1" ] <|
div [ class "articles-toggle" ]
[ Feed.viewFeedSources feed |> Html.map GotFeedMsg ]
:: (Feed.viewArticles timeZone feed |> List.map (Html.map GotFeedMsg))
viewTabs : FeedTab -> Html Msg
viewTabs tab =
case tab of
MyArticles ->
Feed.viewTabs [] myArticles [ favoritedArticles ]
FavoritedArticles ->
Feed.viewTabs [ myArticles ] favoritedArticles []
myArticles : ( String, Msg )
myArticles =
( "My Articles", ClickedTab MyArticles )
favoritedArticles : ( String, Msg )
favoritedArticles =
( "Favorited Articles", ClickedTab FavoritedArticles )
@@ -221,6 +309,8 @@ type Msg
= ClickedDismissErrors
| ClickedFollow Cred UnfollowedAuthor
| ClickedUnfollow Cred FollowedAuthor
| ClickedTab FeedTab
| ClickedFeedPage Int
| CompletedFollowChange (Result Http.Error Author)
| CompletedAuthorLoad (Result ( Username, Http.Error ) Author)
| CompletedFeedLoad (Result ( Username, Http.Error ) Feed.Model)
@@ -248,6 +338,16 @@ update msg model =
|> Http.send CompletedFollowChange
)
ClickedTab tab ->
( { model | feedTab = tab }
, fetchFeed model.session tab (currentUsername model) 1
)
ClickedFeedPage page ->
( { model | feedPage = page }
, fetchFeed model.session model.feedTab (currentUsername model) page
)
CompletedFollowChange (Ok newAuthor) ->
( { model | author = Loaded newAuthor }
, Cmd.none
@@ -335,12 +435,3 @@ subscriptions model =
toSession : Model -> Session
toSession model =
model.session
-- INTERNAL
defaultFeedSources : Username -> FeedSources
defaultFeedSources username =
FeedSources.fromLists (AuthorFeed username) [ FavoritedFeed username ]

View File

@@ -1,8 +1,11 @@
module PaginatedList exposing (PaginatedList, fromList, map, mapPage, page, total, values, view)
module PaginatedList exposing (PaginatedList, fromList, fromRequestBuilder, map, page, total, values, view)
import Html exposing (Html, a, li, text, ul)
import Html.Attributes exposing (class, classList, href)
import Html.Events exposing (onClick)
import Http
import HttpBuilder exposing (RequestBuilder)
import Task exposing (Task)
@@ -54,29 +57,18 @@ map transform (PaginatedList info) =
PaginatedList { info | values = List.map transform info.values }
mapPage : (Int -> Int) -> PaginatedList a -> PaginatedList a
mapPage transform (PaginatedList info) =
PaginatedList { info | page = transform info.page }
-- VIEW
view : (Int -> msg) -> PaginatedList a -> Int -> Html msg
view toMsg list resultsPerPage =
view : (Int -> msg) -> PaginatedList a -> Html msg
view toMsg (PaginatedList info) =
let
totalPages =
ceiling (toFloat (total list) / toFloat resultsPerPage)
activePage =
page list
viewPageLink currentPage =
pageLink toMsg currentPage (currentPage == activePage)
pageLink toMsg currentPage (currentPage == info.page)
in
if totalPages > 1 then
List.range 1 totalPages
if info.total > 1 then
List.range 1 info.total
|> List.map viewPageLink
|> ul [ class "pagination" ]
@@ -96,3 +88,31 @@ pageLink toMsg targetPage isActive =
]
[ text (String.fromInt targetPage) ]
]
-- HTTP
{-| I considered accepting a record here so I don't mess up the argument order.
-}
fromRequestBuilder :
Int
-> Int
-> RequestBuilder (PaginatedList a)
-> Task Http.Error (PaginatedList a)
fromRequestBuilder resultsPerPage pageNumber builder =
let
offset =
(pageNumber - 1) * resultsPerPage
params =
[ ( "limit", String.fromInt resultsPerPage )
, ( "offset", String.fromInt offset )
]
in
builder
|> HttpBuilder.withQueryParams params
|> HttpBuilder.toRequest
|> Http.toTask
|> Task.map (\(PaginatedList info) -> PaginatedList { info | page = pageNumber })