From 60575ed31c3cb0a48bdd7ba318a8bd15c40d6614 Mon Sep 17 00:00:00 2001 From: jatkowskee Date: Sat, 2 Dec 2017 18:43:06 +0100 Subject: [PATCH] Feature/Add ranking information to profile (#195) * Create user_ranking function that returns user scores per each category. * Create user_ranking action that responds with user scores for each category. * Create User Scores panel in Profile section. * Elm formatter --- aion/lib/aion/ranking.ex | 32 ++++++++---- aion/test/lib/ranking_test.exs | 19 ++++++- aion/web/controllers/ranking_controller.ex | 9 +++- aion/web/elm/src/General/Models.elm | 9 ++-- aion/web/elm/src/Msgs.elm | 3 +- aion/web/elm/src/Update.elm | 27 ++++++++-- aion/web/elm/src/Urls.elm | 5 ++ aion/web/elm/src/User/Api.elm | 31 ++++++++++-- aion/web/elm/src/User/Decoders.elm | 15 +++++- aion/web/elm/src/User/Models.elm | 18 +++++++ aion/web/elm/src/User/View.elm | 59 +++++++++++++++++++--- aion/web/router.ex | 1 + aion/web/static/css/app.css | 20 ++++++++ aion/web/views/ranking_view.ex | 8 +++ 14 files changed, 224 insertions(+), 32 deletions(-) diff --git a/aion/lib/aion/ranking.ex b/aion/lib/aion/ranking.ex index d745b81..3347afb 100644 --- a/aion/lib/aion/ranking.ex +++ b/aion/lib/aion/ranking.ex @@ -13,24 +13,38 @@ defmodule Aion.Ranking do @type category_score_t :: %{category_id: integer, category_name: String.t, scores: (list user_score_t)} @type user_score_t :: %{user_name: String.t, score: integer} + @type user_category_score_t :: %{category_id: integer, category_name: String.t, score: integer} @type raw_data_t :: %{category_id: integer, category_name: String.t, user_name: String.t, score: integer} - @spec data :: list category_score_t - def data do - query_data() + @spec general_ranking :: list category_score_t + def general_ranking do + general_ranking_query() |> Enum.group_by(&%{category_id: &1.category_id, category_name: &1.category_name}, &%{user_name: &1.user_name, score: &1.score}) |> Map.to_list() |> Enum.map(&Map.put(elem(&1, 0), :scores, elem(&1, 1))) end - @spec query_data :: list raw_data_t - def query_data do + @spec user_ranking(integer) :: list user_category_score_t + def user_ranking(user_id) do Repo.all( - from q in User, - join: ucs in UserCategoryScore, on: q.id == ucs.user_id, - join: c in Category, on: c.id == ucs.category_id, - select: %{category_id: c.id, category_name: c.name, user_name: q.name, score: ucs.score} + from [u, ucs, c] in base_query, + where: u.id == ^user_id, + select: %{category_id: c.id, category_name: c.name, score: ucs.score} ) end + + @spec general_ranking_query :: list raw_data_t + def general_ranking_query do + Repo.all( + from [u, ucs, c] in base_query, + select: %{category_id: c.id, category_name: c.name, user_name: u.name, score: ucs.score} + ) + end + + defp base_query do + from q in User, + join: ucs in UserCategoryScore, on: q.id == ucs.user_id, + join: c in Category, on: c.id == ucs.category_id + end end diff --git a/aion/test/lib/ranking_test.exs b/aion/test/lib/ranking_test.exs index 986870f..aff995f 100644 --- a/aion/test/lib/ranking_test.exs +++ b/aion/test/lib/ranking_test.exs @@ -5,7 +5,7 @@ defmodule Aion.RankingTest do @user1_attrs %{name: "John", email: "test1@example.com", password: "test123"} @user2_attrs %{name: "Michael", email: "test2@example.com", password: "test123"} - describe "Ranking.data" do + describe "Ranking.general_ranking/0" do test "returns player scores for each category" do user1 = Repo.insert! User.registration_changeset(%User{}, @user1_attrs) user2 = Repo.insert! User.registration_changeset(%User{}, @user2_attrs) @@ -15,10 +15,25 @@ defmodule Aion.RankingTest do Repo.insert! %UserCategoryScore{user: user1, category: category2, score: 4} Repo.insert! %UserCategoryScore{user: user2, category: category2, score: 5} - result = Ranking.data() + result = Ranking.general_ranking() assert result == [%{category_id: category1.id, category_name: "category1", scores: [%{user_name: "John", score: 2}]}, %{category_id: category2.id, category_name: "category2", scores: [%{user_name: "John", score: 4}, %{user_name: "Michael", score: 5}]}] end end + + describe "Ranking.user_ranking/1" do + test "returns user scores for each category" do + user1 = Repo.insert! User.registration_changeset(%User{}, @user1_attrs) + category1 = Repo.insert! %Category{name: "category1"} + category2 = Repo.insert! %Category{name: "category2"} + Repo.insert! %UserCategoryScore{user: user1, category: category1, score: 2} + Repo.insert! %UserCategoryScore{user: user1, category: category2, score: 4} + + result = Ranking.user_ranking(user1.id) + + assert result == [%{category_id: category1.id, category_name: "category1", score: 2}, + %{category_id: category2.id, category_name: "category2", score: 4}] + end + end end diff --git a/aion/web/controllers/ranking_controller.ex b/aion/web/controllers/ranking_controller.ex index 0355622..61fdd7e 100644 --- a/aion/web/controllers/ranking_controller.ex +++ b/aion/web/controllers/ranking_controller.ex @@ -5,11 +5,16 @@ defmodule Aion.RankingController do plug(Plug.EnsureAuthenticated, handler: __MODULE__) - def ranking(conn, _params) do - result = Ranking.data() + def general_ranking(conn, _params) do + result = Ranking.general_ranking() render(conn, "ranking.json", category_scores: result) end + def user_ranking(conn, _params) do + result = Ranking.user_ranking(Plug.current_resource(conn).id) + render(conn, "user_ranking.json", user_scores: result) + end + def unauthenticated(conn, _params) do Errors.unauthenticated(conn) end diff --git a/aion/web/elm/src/General/Models.elm b/aion/web/elm/src/General/Models.elm index b6e895c..a650196 100644 --- a/aion/web/elm/src/General/Models.elm +++ b/aion/web/elm/src/General/Models.elm @@ -15,11 +15,11 @@ import Ranking.Models exposing (RankingData) import Toasty import Toasty.Defaults import Urls exposing (hostname, websocketUrl) -import User.Models exposing (CurrentUser) +import User.Models exposing (UserData) type alias Model = - { user : WebData CurrentUser + { user : UserData , authData : AuthData , rooms : WebData RoomsData , categories : WebData CategoriesData @@ -69,7 +69,10 @@ initialModel flags route location = False -> Just flags.token in - { user = RemoteData.Loading + { user = + { details = RemoteData.Loading + , scores = RemoteData.Loading + } , authData = { loginForm = Forms.initForm loginForm , registrationForm = Forms.initForm registrationForm diff --git a/aion/web/elm/src/Msgs.elm b/aion/web/elm/src/Msgs.elm index ae28c3f..fd66e78 100644 --- a/aion/web/elm/src/Msgs.elm +++ b/aion/web/elm/src/Msgs.elm @@ -15,7 +15,7 @@ import Room.Models exposing (RoomId, RoomsData) import Time exposing (Time) import Toasty import Toasty.Defaults -import User.Models exposing (CurrentUser) +import User.Models exposing (CurrentUser, UserScores) type Msg @@ -24,6 +24,7 @@ type Msg | OnFetchRanking (WebData Ranking) | OnFetchCategories (WebData CategoriesData) | OnFetchCurrentUser (WebData CurrentUser) + | OnFetchUserScores (WebData UserScores) | OnQuestionCreated (WebData QuestionCreatedData) | OnCategoryCreated (WebData CategoryCreatedData) | OnRoomCreated (WebData RoomCreatedData) diff --git a/aion/web/elm/src/Update.elm b/aion/web/elm/src/Update.elm index 05d9d76..d6b2f49 100644 --- a/aion/web/elm/src/Update.elm +++ b/aion/web/elm/src/Update.elm @@ -7,7 +7,7 @@ import Delay import Dom exposing (focus) import Forms import General.Constants exposing (loginFormMsg, registerFormMsg) -import General.Models exposing (Model, Route(RankingRoute, RoomListRoute, RoomRoute), asEventLogIn, asProgressBarIn) +import General.Models exposing (Model, Route(RankingRoute, UserRoute, RoomListRoute, RoomRoute), asEventLogIn, asProgressBarIn) import General.Notifications exposing (toastsConfig) import Json.Decode as Decode import Json.Encode as Encode @@ -33,7 +33,7 @@ import Toasty import Multiselect import Socket exposing (initSocket, initializeRoom, leaveRoom, sendAnswer) import Urls exposing (host, websocketUrl) -import User.Api exposing (fetchCurrentUser) +import User.Api exposing (fetchCurrentUser, fetchUserScores) updateForm : String -> String -> Forms.Form -> Forms.Form @@ -263,7 +263,18 @@ update msg model = { newModel | panelData = { oldPanelData | categoryMultiSelect = updatedCategoryMultiselect } } ! [] OnFetchCurrentUser response -> - { model | user = response } ! [] + let + oldUserData = + model.user + in + { model | user = { oldUserData | details = response } } ! [] + + OnFetchUserScores response -> + let + oldUserData = + model.user + in + { model | user = { oldUserData | scores = response } } ! [] OnQuestionCreated response -> case response of @@ -374,6 +385,14 @@ update msg model = |> withToken model ] + UserRoute -> + { model | route = newRoute } + ! [ afterLeaveCmd + , fetchUserScores + |> withLocation model + |> withToken model + ] + _ -> { newModel | route = newRoute } ! [ afterLeaveCmd ] @@ -439,7 +458,7 @@ update msg model = model.eventLog log = - case model.user of + case model.user.details of RemoteData.Success currentUser -> { currentPlayer = currentUser.name , newPlayer = userJoinedInfo.user diff --git a/aion/web/elm/src/Urls.elm b/aion/web/elm/src/Urls.elm index 308c8dd..7a80f51 100644 --- a/aion/web/elm/src/Urls.elm +++ b/aion/web/elm/src/Urls.elm @@ -33,6 +33,11 @@ rankingUrl location = (host location) ++ "api/ranking" +userScoresUrl : Location -> String +userScoresUrl location = + (host location) ++ "api/user_ranking" + + loginUrl : Location -> String loginUrl location = (host location) ++ "sessions" diff --git a/aion/web/elm/src/User/Api.elm b/aion/web/elm/src/User/Api.elm index c4a0f08..d7ccbc1 100644 --- a/aion/web/elm/src/User/Api.elm +++ b/aion/web/elm/src/User/Api.elm @@ -6,9 +6,10 @@ import Json.Decode as Decode import Msgs exposing (Msg) import Navigation exposing (Location) import RemoteData -import Urls exposing (host) -import User.Decoders exposing (userDecoder) -import User.Models exposing (CurrentUser) +import Urls exposing (host, userScoresUrl) +import User.Decoders exposing (userDecoder, userScoresDecoder) +import User.Models exposing (CurrentUser, UserScores) +import Auth.Models exposing (Token) fetchCurrentUserUrl : Location -> String @@ -38,3 +39,27 @@ fetchCurrentUser location token = fetchCurrentUserRequest url token userDecoder |> RemoteData.sendRequest |> Cmd.map Msgs.OnFetchCurrentUser + + +fetchUserScoresRequest : String -> String -> Decode.Decoder UserScores -> Request UserScores +fetchUserScoresRequest url token decoder = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = url + , body = Http.emptyBody + , expect = Http.expectJson decoder + , timeout = Nothing + , withCredentials = True + } + + +fetchUserScores : Location -> Token -> Cmd Msg +fetchUserScores location token = + let + url = + userScoresUrl location + in + fetchUserScoresRequest url token userScoresDecoder + |> RemoteData.sendRequest + |> Cmd.map Msgs.OnFetchUserScores diff --git a/aion/web/elm/src/User/Decoders.elm b/aion/web/elm/src/User/Decoders.elm index 1b9f2a2..ae8c8e1 100644 --- a/aion/web/elm/src/User/Decoders.elm +++ b/aion/web/elm/src/User/Decoders.elm @@ -1,6 +1,6 @@ module User.Decoders exposing (..) -import User.Models exposing (CurrentUser) +import User.Models exposing (CurrentUser, UserScores, UserCategoryScore) import Json.Decode as Decode import Json.Decode.Pipeline exposing (decode, required) @@ -11,3 +11,16 @@ userDecoder = |> required "name" Decode.string |> required "id" Decode.int |> required "email" Decode.string + + +userScoresDecoder : Decode.Decoder UserScores +userScoresDecoder = + decode UserScores + |> required "categoryScores" (Decode.list (userCategoryScoreDecoder)) + + +userCategoryScoreDecoder : Decode.Decoder UserCategoryScore +userCategoryScoreDecoder = + decode UserCategoryScore + |> required "categoryName" Decode.string + |> required "score" Decode.int diff --git a/aion/web/elm/src/User/Models.elm b/aion/web/elm/src/User/Models.elm index cc792db..bfb9bf4 100644 --- a/aion/web/elm/src/User/Models.elm +++ b/aion/web/elm/src/User/Models.elm @@ -1,8 +1,26 @@ module User.Models exposing (..) +import RemoteData exposing (WebData) + + +type alias UserData = + { details : WebData CurrentUser + , scores : WebData UserScores + } + type alias CurrentUser = { name : String , id : Int , email : String } + + +type alias UserScores = + { categoryScores : List UserCategoryScore } + + +type alias UserCategoryScore = + { categoryName : String + , score : Int + } diff --git a/aion/web/elm/src/User/View.elm b/aion/web/elm/src/User/View.elm index dbe0909..32da393 100644 --- a/aion/web/elm/src/User/View.elm +++ b/aion/web/elm/src/User/View.elm @@ -1,6 +1,5 @@ module User.View exposing (..) -import Bootstrap.Alert as Alert import Bootstrap.Button as Button import General.Models exposing (Model) import Html exposing (..) @@ -9,14 +8,18 @@ import Msgs exposing (Msg(..)) import Navigation exposing (Location) import RemoteData import Urls exposing (host) -import User.Models exposing (CurrentUser) +import User.Models exposing (CurrentUser, UserCategoryScore) +import Bootstrap.Grid as Grid +import Bootstrap.Grid.Col as Col +import Bootstrap.Badge as Badge +import Bootstrap.Button as Button userView : Model -> Html Msg userView model = - case model.user of + case model.user.details of RemoteData.Success user -> - renderUserView model.location user + renderUserView model user RemoteData.NotAsked -> text "" @@ -28,13 +31,32 @@ userView model = text (toString error) -renderUserView : Location -> CurrentUser -> Html Msg -renderUserView location user = +renderUserView : Model -> CurrentUser -> Html Msg +renderUserView model user = + div [ class "profile-container" ] + [ Grid.container [] + [ Grid.row [] + [ Grid.col [] [ (userDetails model.location user) ] ] + , Grid.row [] + [ Grid.col [ Col.xs12 ] + [ div [ class "userScoreDetails" ] + [ h2 [] [ text "Category Scores" ] + , p [] [ text "List of your scores per category" ] + ] + ] + ] + , Grid.row [] (displayUserScores model) + ] + ] + + +userDetails : Location -> CurrentUser -> Html Msg +userDetails location user = let avatarPlaceholder = (host location) ++ "placeholders/avatar_placeholder.png" in - div [ class "profile-container" ] + div [] [ img [ src avatarPlaceholder, class "user-avatar" ] [] , div [ class "user-info-block" ] [ p [ class "user-name" ] [ text user.name ] @@ -47,3 +69,26 @@ renderUserView location user = [ text "Logout" ] ] ] + + +displayUserScores : Model -> List (Grid.Column Msg) +displayUserScores model = + case model.user.scores of + RemoteData.Success userScores -> + userScores.categoryScores + |> List.sortBy .score + |> List.reverse + |> List.map listSingleScore + + _ -> + [ Grid.col [] [ text "Loading..." ] ] + + +listSingleScore : UserCategoryScore -> Grid.Column Msg +listSingleScore userCategoryScore = + Grid.col [ Col.md3, Col.sm4, Col.xs12 ] + [ div [ class "userScoreBadge" ] + [ Button.button [ Button.success, Button.small ] + [ small [] [ (text userCategoryScore.categoryName) ], (Badge.badgeInfo [ class "ml-1" ] [ text (toString userCategoryScore.score) ]) ] + ] + ] diff --git a/aion/web/router.ex b/aion/web/router.ex index 0d31daf..a95123b 100644 --- a/aion/web/router.ex +++ b/aion/web/router.ex @@ -43,6 +43,7 @@ defmodule Aion.Router do get("/me", UserController, :get_user_info) get("/ranking", RankingController, :ranking) + get("/user_ranking", RankingController, :user_ranking) resources("/categories", CategoryController, only: [:index, :show]) resources("/questions", QuestionController, only: [:index, :show]) resources("/answers", AnswerController, only: [:index, :show]) diff --git a/aion/web/static/css/app.css b/aion/web/static/css/app.css index 4ba65ad..facda68 100644 --- a/aion/web/static/css/app.css +++ b/aion/web/static/css/app.css @@ -481,3 +481,23 @@ footer p { .medalColumn { width: 5%; } + +.userScoreDetails { + text-align: center; + margin-top: 40px; + margin-bottom: 25px; +} + +.userScoreBadge small { + font-weight: bold; +} + +.userScoreBadge button { + width: 85%; + margin-bottom: 10px; +} + +.userScoreBadge .badge-info { + color: #111; + background-color: #f8f9fa; +} diff --git a/aion/web/views/ranking_view.ex b/aion/web/views/ranking_view.ex index a187e21..77fda55 100644 --- a/aion/web/views/ranking_view.ex +++ b/aion/web/views/ranking_view.ex @@ -15,4 +15,12 @@ defmodule Aion.RankingView do def render("user_score.json", %{user_score: user_score}) do %{userName: user_score.user_name, score: user_score.score} end + + def render("user_ranking.json", %{user_scores: user_scores}) do + %{categoryScores: render_many(user_scores, Aion.RankingView, "user_category_score.json", as: :user_category_score)} + end + + def render("user_category_score.json", %{user_category_score: user_category_score}) do + %{categoryName: user_category_score.category_name, score: user_category_score.score} + end end