Add part7

This commit is contained in:
Richard Feldman
2018-05-05 04:54:47 -04:00
parent f6bef58e3d
commit 825dea437b
575 changed files with 79140 additions and 0 deletions

View File

@@ -0,0 +1,419 @@
module Views.Article.Feed exposing (FeedSource, Model, Msg, authorFeed, favoritedFeed, globalFeed, init, selectTag, tagFeed, update, viewArticles, viewFeedSources, yourFeed)
{-| NOTE: This module has its own Model, view, and update. This is not normal!
If you find yourself doing this often, please watch <https://www.youtube.com/watch?v=DoA4Txr4GUs>
This is the reusable Article Feed that appears on both the Home page as well as
on the Profile page. There's a lot of logic here, so it's more convenient to use
the heavyweight approach of giving this its own Model, view, and update.
This means callers must use Html.map and Cmd.map to use this thing, but in
this case that's totally worth it because of the amount of logic wrapped up
in this thing.
For every other reusable view in this application, this API would be totally
overkill, so we use simpler APIs instead.
-}
import Data.Article as Article exposing (Article, Tag)
import Data.Article.Feed exposing (Feed)
import Data.AuthToken exposing (AuthToken)
import Data.Session exposing (Session)
import Data.User exposing (Username)
import Dom.Scroll
import Html exposing (..)
import Html.Attributes exposing (attribute, class, classList, href, id, placeholder, src)
import Html.Events exposing (onClick)
import Http
import Request.Article
import SelectList exposing (Position(..), SelectList)
import Task exposing (Task)
import Util exposing (onClickStopPropagation, pair, viewIf)
import Views.Article
import Views.Errors as Errors
import Views.Page exposing (bodyId)
import Views.Spinner exposing (spinner)
-- MODEL --
type Model
= Model InternalModel
{-| 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 file.
-}
type alias InternalModel =
{ errors : List String
, feed : Feed
, feedSources : SelectList FeedSource
, activePage : Int
, isLoading : Bool
}
init : Session -> SelectList FeedSource -> Task Http.Error Model
init session feedSources =
let
source =
SelectList.selected feedSources
toModel ( activePage, feed ) =
Model
{ errors = []
, activePage = activePage
, feed = feed
, feedSources = feedSources
, isLoading = False
}
in
source
|> fetch (Maybe.map .token session.user) 1
|> Task.map toModel
-- VIEW --
viewArticles : Model -> List (Html Msg)
viewArticles (Model { activePage, feed, feedSources }) =
List.map (Views.Article.view ToggleFavorite) feed.articles
++ [ pagination activePage feed (SelectList.selected feedSources) ]
viewFeedSources : Model -> Html Msg
viewFeedSources (Model { feedSources, isLoading, errors }) =
ul [ class "nav nav-pills outline-active" ] <|
SelectList.toList (SelectList.mapBy viewFeedSource feedSources)
++ [ Errors.view DismissErrors errors, viewIf isLoading spinner ]
viewFeedSource : Position -> FeedSource -> Html Msg
viewFeedSource position source =
li [ class "nav-item" ]
[ a
[ classList [ ( "nav-link", True ), ( "active", position == Selected ) ]
, href "javascript:void(0);"
, onClick (SelectFeedSource source)
]
[ text (sourceName source) ]
]
selectTag : Maybe AuthToken -> Tag -> Cmd Msg
selectTag maybeAuthToken tagName =
let
source =
tagFeed tagName
in
source
|> fetch maybeAuthToken 1
|> Task.attempt (FeedLoadCompleted source)
sourceName : FeedSource -> String
sourceName source =
case source of
YourFeed ->
"Your Feed"
GlobalFeed ->
"Global Feed"
TagFeed tagName ->
"#" ++ Article.tagToString tagName
FavoritedFeed username ->
"Favorited Articles"
AuthorFeed username ->
"My Articles"
limit : FeedSource -> Int
limit feedSource =
case feedSource of
YourFeed ->
10
GlobalFeed ->
10
TagFeed tagName ->
10
FavoritedFeed username ->
5
AuthorFeed username ->
5
pagination : Int -> Feed -> FeedSource -> Html Msg
pagination activePage feed feedSource =
let
articlesPerPage =
limit feedSource
totalPages =
ceiling (toFloat feed.articlesCount / toFloat articlesPerPage)
in
if totalPages > 1 then
List.range 1 totalPages
|> List.map (\page -> pageLink page (page == activePage))
|> ul [ class "pagination" ]
else
Html.text ""
pageLink : Int -> Bool -> Html Msg
pageLink page isActive =
li [ classList [ ( "page-item", True ), ( "active", isActive ) ] ]
[ a
[ class "page-link"
, href "javascript:void(0);"
, onClick (SelectPage page)
]
[ text (toString page) ]
]
-- UPDATE --
type Msg
= DismissErrors
| SelectFeedSource FeedSource
| FeedLoadCompleted FeedSource (Result Http.Error ( Int, Feed ))
| ToggleFavorite (Article ())
| FavoriteCompleted (Result Http.Error (Article ()))
| SelectPage Int
update : Session -> Msg -> Model -> ( Model, Cmd Msg )
update session msg (Model internalModel) =
updateInternal session msg internalModel
|> Tuple.mapFirst Model
updateInternal : Session -> Msg -> InternalModel -> ( InternalModel, Cmd Msg )
updateInternal session msg model =
case msg of
DismissErrors ->
( { model | errors = [] }, Cmd.none )
SelectFeedSource source ->
source
|> fetch (Maybe.map .token session.user) 1
|> Task.attempt (FeedLoadCompleted source)
|> pair { model | isLoading = True }
FeedLoadCompleted source (Ok ( activePage, feed )) ->
( { model
| feed = feed
, feedSources = selectFeedSource source model.feedSources
, activePage = activePage
, isLoading = False
}
, Cmd.none
)
FeedLoadCompleted _ (Err error) ->
( { model
| errors = model.errors ++ [ "Server error while trying to load feed" ]
, isLoading = False
}
, Cmd.none
)
ToggleFavorite article ->
case session.user of
Nothing ->
( { model | errors = model.errors ++ [ "You are currently signed out. You must sign in to favorite articles." ] }
, Cmd.none
)
Just user ->
Request.Article.toggleFavorite article user.token
|> Http.send FavoriteCompleted
|> pair model
FavoriteCompleted (Ok article) ->
let
feed =
model.feed
newFeed =
{ feed | articles = List.map (replaceArticle article) feed.articles }
in
( { model | feed = newFeed }, Cmd.none )
FavoriteCompleted (Err error) ->
( { model | errors = model.errors ++ [ "Server error while trying to favorite article." ] }
, Cmd.none
)
SelectPage page ->
let
source =
SelectList.selected model.feedSources
in
source
|> fetch (Maybe.map .token session.user) page
|> Task.andThen (\feed -> Task.map (\_ -> feed) scrollToTop)
|> Task.attempt (FeedLoadCompleted source)
|> pair model
scrollToTop : Task x ()
scrollToTop =
Dom.Scroll.toTop bodyId
-- 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 AuthToken -> Int -> FeedSource -> Task Http.Error ( Int, Feed )
fetch token page feedSource =
let
defaultListConfig =
Request.Article.defaultListConfig
articlesPerPage =
limit feedSource
offset =
(page - 1) * articlesPerPage
listConfig =
{ defaultListConfig | offset = offset, limit = articlesPerPage }
task =
case feedSource of
YourFeed ->
let
defaultFeedConfig =
Request.Article.defaultFeedConfig
feedConfig =
{ defaultFeedConfig | offset = offset, limit = articlesPerPage }
in
token
|> Maybe.map (Request.Article.feed feedConfig >> Http.toTask)
|> Maybe.withDefault (Task.fail (Http.BadUrl "You need to be signed in to view your feed."))
GlobalFeed ->
Request.Article.list listConfig token
|> Http.toTask
TagFeed tagName ->
Request.Article.list { listConfig | tag = Just tagName } token
|> Http.toTask
FavoritedFeed username ->
Request.Article.list { listConfig | favorited = Just username } token
|> Http.toTask
AuthorFeed username ->
Request.Article.list { listConfig | author = Just username } token
|> Http.toTask
in
task
|> Task.map (\feed -> ( page, feed ))
replaceArticle : Article a -> Article a -> Article a
replaceArticle newArticle oldArticle =
if newArticle.slug == oldArticle.slug then
newArticle
else
oldArticle
selectFeedSource : FeedSource -> SelectList FeedSource -> SelectList FeedSource
selectFeedSource source sources =
let
withoutTags =
sources
|> SelectList.toList
|> List.filter (not << isTagFeed)
newSources =
case source of
YourFeed ->
withoutTags
GlobalFeed ->
withoutTags
FavoritedFeed _ ->
withoutTags
AuthorFeed _ ->
withoutTags
TagFeed _ ->
withoutTags ++ [ source ]
in
case newSources of
[] ->
-- This should never happen. If we had a logging service set up,
-- we would definitely want to report if it somehow did happen!
sources
first :: rest ->
SelectList.fromLists [] first rest
|> SelectList.select ((==) source)
isTagFeed : FeedSource -> Bool
isTagFeed source =
case source of
TagFeed _ ->
True
_ ->
False
-- FEEDSOURCE --
type FeedSource
= YourFeed
| GlobalFeed
| TagFeed Tag
| FavoritedFeed Username
| AuthorFeed Username
yourFeed : FeedSource
yourFeed =
YourFeed
globalFeed : FeedSource
globalFeed =
GlobalFeed
tagFeed : Tag -> FeedSource
tagFeed =
TagFeed
favoritedFeed : Username -> FeedSource
favoritedFeed =
FavoritedFeed
authorFeed : Username -> FeedSource
authorFeed =
AuthorFeed