subtree(users/wpcarro): docking briefcase at '24f5a642'
git-subtree-dir: users/wpcarro git-subtree-mainline:464bbcb15cgit-subtree-split:24f5a642afChange-Id: I6105b3762b79126b3488359c95978cadb3efa789
This commit is contained in:
commit
019f8fd211
766 changed files with 175420 additions and 0 deletions
189
users/wpcarro/assessments/tt/client/src/Admin.elm
Normal file
189
users/wpcarro/assessments/tt/client/src/Admin.elm
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
module Admin exposing (render)
|
||||
|
||||
import Common
|
||||
import Date
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Maybe.Extra as ME
|
||||
import RemoteData
|
||||
import State
|
||||
import Tailwind
|
||||
import UI
|
||||
import Utils
|
||||
|
||||
|
||||
roleToggle : State.Model -> State.Role -> Html State.Msg
|
||||
roleToggle model role =
|
||||
div [ [ "px-1", "inline" ] |> Tailwind.use |> class ]
|
||||
[ UI.toggleButton
|
||||
{ toggled = model.inviteRole == Just role
|
||||
, label = State.roleToString role
|
||||
, handleEnable = State.UpdateInviteRole (Just role)
|
||||
, handleDisable = State.UpdateInviteRole Nothing
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
inviteUser : State.Model -> Html State.Msg
|
||||
inviteUser model =
|
||||
div [ [ "pb-6" ] |> Tailwind.use |> class ]
|
||||
[ UI.header 3 "Invite a user"
|
||||
, UI.textField
|
||||
{ handleInput = State.UpdateInviteEmail
|
||||
, inputId = "invite-email"
|
||||
, inputValue = model.inviteEmail
|
||||
, pholder = "Email..."
|
||||
}
|
||||
, div [ [ "pt-4" ] |> Tailwind.use |> class ]
|
||||
[ roleToggle model State.User
|
||||
, roleToggle model State.Manager
|
||||
, roleToggle model State.Admin
|
||||
]
|
||||
, UI.baseButton
|
||||
{ enabled =
|
||||
List.all
|
||||
identity
|
||||
[ String.length model.inviteEmail > 0
|
||||
, ME.isJust model.inviteRole
|
||||
]
|
||||
, extraClasses = [ "my-4" ]
|
||||
, label =
|
||||
case model.inviteResponseStatus of
|
||||
RemoteData.Loading ->
|
||||
"Sending..."
|
||||
|
||||
_ ->
|
||||
"Send invitation"
|
||||
, handleClick =
|
||||
case model.inviteRole of
|
||||
Nothing ->
|
||||
State.DoNothing
|
||||
|
||||
Just role ->
|
||||
State.AttemptInviteUser role
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
allTrips : State.Model -> Html State.Msg
|
||||
allTrips model =
|
||||
case model.trips of
|
||||
RemoteData.NotAsked ->
|
||||
UI.absentData { handleFetch = State.AttemptGetTrips }
|
||||
|
||||
RemoteData.Loading ->
|
||||
UI.paragraph "Loading..."
|
||||
|
||||
RemoteData.Failure e ->
|
||||
UI.paragraph ("Error: " ++ Utils.explainHttpError e)
|
||||
|
||||
RemoteData.Success xs ->
|
||||
ul []
|
||||
(xs
|
||||
|> List.map
|
||||
(\trip ->
|
||||
li []
|
||||
[ UI.paragraph (Date.toIsoString trip.startDate ++ " - " ++ Date.toIsoString trip.endDate ++ ", " ++ trip.username ++ " is going " ++ trip.destination)
|
||||
, UI.textButton
|
||||
{ label = "delete"
|
||||
, handleClick = State.AttemptDeleteTrip trip
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
allUsers : State.Model -> Html State.Msg
|
||||
allUsers model =
|
||||
case model.accounts of
|
||||
RemoteData.NotAsked ->
|
||||
UI.absentData { handleFetch = State.AttemptGetAccounts }
|
||||
|
||||
RemoteData.Loading ->
|
||||
UI.paragraph "Loading..."
|
||||
|
||||
RemoteData.Failure e ->
|
||||
UI.paragraph ("Error: " ++ Utils.explainHttpError e)
|
||||
|
||||
RemoteData.Success xs ->
|
||||
ul []
|
||||
(xs
|
||||
|> List.map
|
||||
(\account ->
|
||||
li []
|
||||
[ UI.paragraph
|
||||
(account.username
|
||||
++ " - "
|
||||
++ State.roleToString account.role
|
||||
)
|
||||
, UI.textButton
|
||||
{ label = "delete"
|
||||
, handleClick = State.AttemptDeleteAccount account.username
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
users : List String -> Html State.Msg
|
||||
users xs =
|
||||
ul []
|
||||
(xs
|
||||
|> List.map
|
||||
(\x ->
|
||||
li [ [ "py-4", "flex" ] |> Tailwind.use |> class ]
|
||||
[ p [ [ "flex-1" ] |> Tailwind.use |> class ] [ text x ]
|
||||
, div [ [ "flex-1" ] |> Tailwind.use |> class ]
|
||||
[ UI.simpleButton
|
||||
{ label = "Delete"
|
||||
, handleClick = State.AttemptDeleteAccount x
|
||||
}
|
||||
]
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
render : State.Model -> Html State.Msg
|
||||
render model =
|
||||
div
|
||||
[ [ "container"
|
||||
, "mx-auto"
|
||||
, "text-center"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ UI.header 2 "Welcome!"
|
||||
, div []
|
||||
[ UI.textButton
|
||||
{ label = "Logout"
|
||||
, handleClick = State.AttemptLogout
|
||||
}
|
||||
]
|
||||
, div [ [ "py-3" ] |> Tailwind.use |> class ]
|
||||
[ case model.adminTab of
|
||||
State.Accounts ->
|
||||
UI.textButton
|
||||
{ label = "Switch to trips"
|
||||
, handleClick = State.UpdateAdminTab State.Trips
|
||||
}
|
||||
|
||||
State.Trips ->
|
||||
UI.textButton
|
||||
{ label = "Switch to accounts"
|
||||
, handleClick = State.UpdateAdminTab State.Accounts
|
||||
}
|
||||
]
|
||||
, case model.adminTab of
|
||||
State.Accounts ->
|
||||
div []
|
||||
[ inviteUser model
|
||||
, allUsers model
|
||||
]
|
||||
|
||||
State.Trips ->
|
||||
allTrips model
|
||||
, Common.allErrors model
|
||||
]
|
||||
37
users/wpcarro/assessments/tt/client/src/Common.elm
Normal file
37
users/wpcarro/assessments/tt/client/src/Common.elm
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
module Common exposing (..)
|
||||
|
||||
import Html exposing (..)
|
||||
import Maybe.Extra as ME
|
||||
import State
|
||||
import UI
|
||||
import Utils
|
||||
|
||||
|
||||
allErrors : State.Model -> Html State.Msg
|
||||
allErrors model =
|
||||
div []
|
||||
(State.allErrors
|
||||
model
|
||||
|> List.map
|
||||
(\( mError, title ) ->
|
||||
case mError of
|
||||
Nothing ->
|
||||
text ""
|
||||
|
||||
Just err ->
|
||||
UI.errorBanner
|
||||
{ title = title
|
||||
, body = Utils.explainHttpError err
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
withSession : State.Model -> (State.Session -> Html State.Msg) -> Html State.Msg
|
||||
withSession model renderWithSession =
|
||||
case model.session of
|
||||
Nothing ->
|
||||
div [] [ UI.paragraph "You need a valid session to view this page. Please attempt to log in." ]
|
||||
|
||||
Just session ->
|
||||
renderWithSession session
|
||||
199
users/wpcarro/assessments/tt/client/src/Login.elm
Normal file
199
users/wpcarro/assessments/tt/client/src/Login.elm
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
module Login exposing (render)
|
||||
|
||||
import Common
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import State
|
||||
import Tailwind
|
||||
import UI
|
||||
import Utils
|
||||
|
||||
|
||||
googleSignIn : Html State.Msg
|
||||
googleSignIn =
|
||||
div
|
||||
[ class "g-signin2"
|
||||
, attribute "onsuccess" "onSignIn"
|
||||
, onClick State.GoogleSignIn
|
||||
]
|
||||
[]
|
||||
|
||||
|
||||
loginForm : State.Model -> Html State.Msg
|
||||
loginForm model =
|
||||
div
|
||||
[ [ "w-full"
|
||||
, "max-w-xs"
|
||||
, "mx-auto"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ div
|
||||
[ [ "bg-white"
|
||||
, "shadow-md"
|
||||
, "rounded"
|
||||
, "px-8"
|
||||
, "pt-6"
|
||||
, "pb-8"
|
||||
, "mb-4"
|
||||
, "text-left"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ div [ [ "text-center", "pb-6" ] |> Tailwind.use |> class ]
|
||||
[ UI.textButton
|
||||
{ handleClick = State.ToggleLoginForm
|
||||
, label =
|
||||
case model.loginTab of
|
||||
State.LoginForm ->
|
||||
"Switch to sign up"
|
||||
|
||||
State.SignUpForm ->
|
||||
"Switch to login"
|
||||
}
|
||||
]
|
||||
, div
|
||||
[ [ "mb-4" ] |> Tailwind.use |> class ]
|
||||
[ UI.label_ { for_ = "username", text_ = "Username" }
|
||||
, UI.textField
|
||||
{ inputId = "Username"
|
||||
, pholder = "Username"
|
||||
, handleInput = State.UpdateUsername
|
||||
, inputValue = model.username
|
||||
}
|
||||
]
|
||||
, case model.loginTab of
|
||||
State.LoginForm ->
|
||||
text ""
|
||||
|
||||
State.SignUpForm ->
|
||||
div
|
||||
[ [ "mb-4" ] |> Tailwind.use |> class ]
|
||||
[ UI.label_ { for_ = "email", text_ = "Email" }
|
||||
, input
|
||||
[ [ "shadow"
|
||||
, "appearance-none"
|
||||
, "border"
|
||||
, "rounded"
|
||||
, "w-full"
|
||||
, "py-2"
|
||||
, "px-3"
|
||||
, "text-gray-700"
|
||||
, "leading-tight"
|
||||
, "focus:outline-none"
|
||||
, "focus:shadow-outline"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, id "email"
|
||||
, placeholder "who@domain.tld"
|
||||
, onInput State.UpdateEmail
|
||||
]
|
||||
[]
|
||||
]
|
||||
, div
|
||||
[ [ "mb-4" ] |> Tailwind.use |> class ]
|
||||
[ UI.label_ { for_ = "password", text_ = "Password" }
|
||||
, input
|
||||
[ [ "shadow"
|
||||
, "appearance-none"
|
||||
, "border"
|
||||
, "rounded"
|
||||
, "w-full"
|
||||
, "py-2"
|
||||
, "px-3"
|
||||
, "text-gray-700"
|
||||
, "leading-tight"
|
||||
, "focus:outline-none"
|
||||
, "focus:shadow-outline"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, id "password"
|
||||
, type_ "password"
|
||||
, placeholder "******************"
|
||||
, onInput State.UpdatePassword
|
||||
]
|
||||
[]
|
||||
]
|
||||
, case model.loginTab of
|
||||
State.LoginForm ->
|
||||
div [ [ "flex", "space-around" ] |> Tailwind.use |> class ]
|
||||
[ UI.simpleButton
|
||||
{ handleClick = State.AttemptLogin
|
||||
, label = "Login"
|
||||
}
|
||||
, div [ [ "pl-4" ] |> Tailwind.use |> class ] [ googleSignIn ]
|
||||
]
|
||||
|
||||
State.SignUpForm ->
|
||||
if
|
||||
List.all identity
|
||||
[ String.length model.username > 0
|
||||
, String.length model.email > 0
|
||||
, String.length model.password > 0
|
||||
]
|
||||
then
|
||||
div []
|
||||
[ UI.simpleButton
|
||||
{ handleClick = State.AttemptSignUp
|
||||
, label = "Sign up"
|
||||
}
|
||||
]
|
||||
|
||||
else
|
||||
UI.disabledButton { label = "Sign up" }
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
login :
|
||||
State.Model
|
||||
-> Html State.Msg
|
||||
login model =
|
||||
div
|
||||
[ [ "text-center"
|
||||
, "py-20"
|
||||
, "bg-gray-200"
|
||||
, "h-screen"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ UI.header 3 "Welcome to Trip Planner"
|
||||
, loginForm model
|
||||
, Common.allErrors model
|
||||
]
|
||||
|
||||
|
||||
logout : State.Model -> Html State.Msg
|
||||
logout model =
|
||||
div
|
||||
[ [ "text-center"
|
||||
, "py-20"
|
||||
, "bg-gray-200"
|
||||
, "h-screen"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ UI.header 3 "Looks like you're already signed in..."
|
||||
, UI.simpleButton
|
||||
{ label = "Logout"
|
||||
, handleClick = State.AttemptLogout
|
||||
}
|
||||
, Common.allErrors model
|
||||
]
|
||||
|
||||
|
||||
render : State.Model -> Html State.Msg
|
||||
render model =
|
||||
case model.session of
|
||||
Nothing ->
|
||||
login model
|
||||
|
||||
Just x ->
|
||||
logout model
|
||||
62
users/wpcarro/assessments/tt/client/src/Main.elm
Normal file
62
users/wpcarro/assessments/tt/client/src/Main.elm
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
module Main exposing (main)
|
||||
|
||||
import Admin
|
||||
import Browser
|
||||
import Html exposing (..)
|
||||
import Login
|
||||
import Manager
|
||||
import State
|
||||
import Url
|
||||
import User
|
||||
|
||||
|
||||
viewForRoute : State.Route -> (State.Model -> Html State.Msg)
|
||||
viewForRoute route =
|
||||
case route of
|
||||
State.Login ->
|
||||
Login.render
|
||||
|
||||
State.UserHome ->
|
||||
User.render
|
||||
|
||||
State.ManagerHome ->
|
||||
Manager.render
|
||||
|
||||
State.AdminHome ->
|
||||
Admin.render
|
||||
|
||||
|
||||
view : State.Model -> Browser.Document State.Msg
|
||||
view model =
|
||||
{ title = "TripPlanner"
|
||||
, body =
|
||||
[ case ( model.session, model.route ) of
|
||||
-- Redirect to /login when someone is not authenticated.
|
||||
-- TODO(wpcarro): We should ensure that /login shows in the URL
|
||||
-- bar.
|
||||
( Nothing, _ ) ->
|
||||
Login.render model
|
||||
|
||||
( Just session, Nothing ) ->
|
||||
Login.render model
|
||||
|
||||
-- Authenticated
|
||||
( Just session, Just route ) ->
|
||||
if State.isAuthorized session.role route then
|
||||
viewForRoute route model
|
||||
|
||||
else
|
||||
text "Access denied. You are not authorized to be here. Evacuate the area immediately"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
main =
|
||||
Browser.application
|
||||
{ init = State.init
|
||||
, onUrlChange = State.UrlChanged
|
||||
, onUrlRequest = State.LinkClicked
|
||||
, subscriptions = \_ -> Sub.none
|
||||
, update = State.update
|
||||
, view = view
|
||||
}
|
||||
70
users/wpcarro/assessments/tt/client/src/Manager.elm
Normal file
70
users/wpcarro/assessments/tt/client/src/Manager.elm
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
module Manager exposing (render)
|
||||
|
||||
import Array
|
||||
import Common
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import RemoteData
|
||||
import State
|
||||
import Tailwind
|
||||
import UI
|
||||
import Utils
|
||||
|
||||
|
||||
allUsers : State.Model -> Html State.Msg
|
||||
allUsers model =
|
||||
case model.accounts of
|
||||
RemoteData.NotAsked ->
|
||||
UI.absentData { handleFetch = State.AttemptGetAccounts }
|
||||
|
||||
RemoteData.Loading ->
|
||||
UI.paragraph "Loading..."
|
||||
|
||||
RemoteData.Failure e ->
|
||||
UI.paragraph ("Error: " ++ Utils.explainHttpError e)
|
||||
|
||||
RemoteData.Success xs ->
|
||||
ul []
|
||||
(xs
|
||||
|> List.map
|
||||
(\account ->
|
||||
li []
|
||||
[ UI.paragraph
|
||||
(account.username
|
||||
++ " - "
|
||||
++ State.roleToString account.role
|
||||
)
|
||||
, UI.textButton
|
||||
{ label = "delete"
|
||||
, handleClick = State.AttemptDeleteAccount account.username
|
||||
}
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
render : State.Model -> Html State.Msg
|
||||
render model =
|
||||
Common.withSession model
|
||||
(\session ->
|
||||
div
|
||||
[ class
|
||||
([ "container"
|
||||
, "mx-auto"
|
||||
, "text-center"
|
||||
]
|
||||
|> Tailwind.use
|
||||
)
|
||||
]
|
||||
[ h1 []
|
||||
[ UI.header 2 ("Welcome back, " ++ session.username ++ "!")
|
||||
, UI.textButton
|
||||
{ label = "Logout"
|
||||
, handleClick = State.AttemptLogout
|
||||
}
|
||||
, allUsers model
|
||||
, Common.allErrors model
|
||||
]
|
||||
]
|
||||
)
|
||||
7
users/wpcarro/assessments/tt/client/src/Shared.elm
Normal file
7
users/wpcarro/assessments/tt/client/src/Shared.elm
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
module Shared exposing (..)
|
||||
|
||||
clientOrigin =
|
||||
"http://localhost:8000"
|
||||
|
||||
serverOrigin =
|
||||
"http://localhost:3000"
|
||||
1014
users/wpcarro/assessments/tt/client/src/State.elm
Normal file
1014
users/wpcarro/assessments/tt/client/src/State.elm
Normal file
File diff suppressed because it is too large
Load diff
29
users/wpcarro/assessments/tt/client/src/Tailwind.elm
Normal file
29
users/wpcarro/assessments/tt/client/src/Tailwind.elm
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
module Tailwind exposing (..)
|
||||
|
||||
{-| Functions to make Tailwind development in Elm even more pleasant.
|
||||
-}
|
||||
|
||||
|
||||
{-| Conditionally use `class` selection when `condition` is true.
|
||||
-}
|
||||
when : Bool -> String -> String
|
||||
when condition class =
|
||||
if condition then
|
||||
class
|
||||
|
||||
else
|
||||
""
|
||||
|
||||
|
||||
if_ : Bool -> String -> String -> String
|
||||
if_ condition whenTrue whenFalse =
|
||||
if condition then
|
||||
whenTrue
|
||||
|
||||
else
|
||||
whenFalse
|
||||
|
||||
|
||||
use : List String -> String
|
||||
use styles =
|
||||
String.join " " styles
|
||||
318
users/wpcarro/assessments/tt/client/src/UI.elm
Normal file
318
users/wpcarro/assessments/tt/client/src/UI.elm
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
module UI exposing (..)
|
||||
|
||||
import Date
|
||||
import DatePicker exposing (defaultSettings)
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import State
|
||||
import Tailwind
|
||||
|
||||
|
||||
label_ : { for_ : String, text_ : String } -> Html msg
|
||||
label_ { for_, text_ } =
|
||||
label
|
||||
[ [ "block"
|
||||
, "text-gray-700"
|
||||
, "text-sm"
|
||||
, "font-bold"
|
||||
, "mb-2"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, for for_
|
||||
]
|
||||
[ text text_ ]
|
||||
|
||||
|
||||
errorBanner : { title : String, body : String } -> Html msg
|
||||
errorBanner { title, body } =
|
||||
div
|
||||
[ [ "text-left"
|
||||
, "fixed"
|
||||
, "container"
|
||||
, "top-0"
|
||||
, "mt-6"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, style "left" "50%"
|
||||
|
||||
-- TODO(wpcarro): Consider supporting breakpoints, but for now
|
||||
-- don't.
|
||||
, style "width" "800px"
|
||||
, style "margin-left" "-400px"
|
||||
]
|
||||
[ div
|
||||
[ [ "bg-red-500"
|
||||
, "text-white"
|
||||
, "font-bold"
|
||||
, "rounded-t"
|
||||
, "px-4"
|
||||
, "py-2"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text title ]
|
||||
, div
|
||||
[ [ "border"
|
||||
, "border-t-0"
|
||||
, "border-red-400"
|
||||
, "rounded-b"
|
||||
, "bg-red-100"
|
||||
, "px-4"
|
||||
, "py-3"
|
||||
, "text-red-700"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ p [] [ text body ] ]
|
||||
]
|
||||
|
||||
|
||||
baseButton :
|
||||
{ label : String
|
||||
, enabled : Bool
|
||||
, handleClick : msg
|
||||
, extraClasses : List String
|
||||
}
|
||||
-> Html msg
|
||||
baseButton { label, enabled, handleClick, extraClasses } =
|
||||
button
|
||||
[ [ if enabled then
|
||||
"bg-blue-500"
|
||||
|
||||
else
|
||||
"bg-gray-500"
|
||||
, if enabled then
|
||||
"hover:bg-blue-700"
|
||||
|
||||
else
|
||||
""
|
||||
, if enabled then
|
||||
""
|
||||
|
||||
else
|
||||
"cursor-not-allowed"
|
||||
, "text-white"
|
||||
, "font-bold"
|
||||
, "py-1"
|
||||
, "shadow-lg"
|
||||
, "px-4"
|
||||
, "rounded"
|
||||
, "focus:outline-none"
|
||||
, "focus:shadow-outline"
|
||||
]
|
||||
++ extraClasses
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, onClick handleClick
|
||||
, disabled (not enabled)
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
simpleButton :
|
||||
{ label : String
|
||||
, handleClick : msg
|
||||
}
|
||||
-> Html msg
|
||||
simpleButton { label, handleClick } =
|
||||
baseButton
|
||||
{ label = label
|
||||
, enabled = True
|
||||
, handleClick = handleClick
|
||||
, extraClasses = []
|
||||
}
|
||||
|
||||
|
||||
disabledButton :
|
||||
{ label : String }
|
||||
-> Html State.Msg
|
||||
disabledButton { label } =
|
||||
baseButton
|
||||
{ label = label
|
||||
, enabled = False
|
||||
, handleClick = State.DoNothing
|
||||
, extraClasses = []
|
||||
}
|
||||
|
||||
|
||||
textButton :
|
||||
{ label : String
|
||||
, handleClick : msg
|
||||
}
|
||||
-> Html msg
|
||||
textButton { label, handleClick } =
|
||||
button
|
||||
[ [ "text-blue-600"
|
||||
, "hover:text-blue-500"
|
||||
, "font-bold"
|
||||
, "hover:underline"
|
||||
, "focus:outline-none"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, onClick handleClick
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
textField :
|
||||
{ pholder : String
|
||||
, inputId : String
|
||||
, handleInput : String -> msg
|
||||
, inputValue : String
|
||||
}
|
||||
-> Html msg
|
||||
textField { pholder, inputId, handleInput, inputValue } =
|
||||
input
|
||||
[ [ "shadow"
|
||||
, "appearance-none"
|
||||
, "border"
|
||||
, "rounded"
|
||||
, "w-full"
|
||||
, "py-2"
|
||||
, "px-3"
|
||||
, "text-gray-700"
|
||||
, "leading-tight"
|
||||
, "focus:outline-none"
|
||||
, "focus:shadow-outline"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, id inputId
|
||||
, value inputValue
|
||||
, placeholder pholder
|
||||
, onInput handleInput
|
||||
]
|
||||
[]
|
||||
|
||||
|
||||
toggleButton :
|
||||
{ toggled : Bool
|
||||
, label : String
|
||||
, handleEnable : msg
|
||||
, handleDisable : msg
|
||||
}
|
||||
-> Html msg
|
||||
toggleButton { toggled, label, handleEnable, handleDisable } =
|
||||
button
|
||||
[ [ if toggled then
|
||||
"bg-blue-700"
|
||||
|
||||
else
|
||||
"bg-blue-500"
|
||||
, "hover:bg-blue-700"
|
||||
, "text-white"
|
||||
, "font-bold"
|
||||
, "py-2"
|
||||
, "px-4"
|
||||
, "rounded"
|
||||
, "focus:outline-none"
|
||||
, "focus:shadow-outline"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
, onClick
|
||||
(if toggled then
|
||||
handleDisable
|
||||
|
||||
else
|
||||
handleEnable
|
||||
)
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
paragraph : String -> Html msg
|
||||
paragraph x =
|
||||
p [ [ "text-xl" ] |> Tailwind.use |> class ] [ text x ]
|
||||
|
||||
|
||||
header : Int -> String -> Html msg
|
||||
header which x =
|
||||
let
|
||||
hStyles =
|
||||
case which of
|
||||
1 ->
|
||||
[ "text-6xl"
|
||||
, "py-12"
|
||||
]
|
||||
|
||||
2 ->
|
||||
[ "text-3xl"
|
||||
, "py-6"
|
||||
]
|
||||
|
||||
_ ->
|
||||
[ "text-2xl"
|
||||
, "py-2"
|
||||
]
|
||||
in
|
||||
h1
|
||||
[ hStyles
|
||||
++ [ "font-bold"
|
||||
, "text-gray-700"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text x ]
|
||||
|
||||
|
||||
link : String -> String -> Html msg
|
||||
link path label =
|
||||
a
|
||||
[ href path
|
||||
, [ "underline"
|
||||
, "text-blue-600"
|
||||
, "text-xl"
|
||||
]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ text label ]
|
||||
|
||||
|
||||
absentData : { handleFetch : msg } -> Html msg
|
||||
absentData { handleFetch } =
|
||||
div []
|
||||
[ paragraph "Welp... it looks like you've caught us in a state that we considered impossible: we did not fetch the data upon which this page depends. Maybe you can help us out by clicking the super secret, highly privileged \"Fetch data\" button below (we don't normally show people this)."
|
||||
, div [ [ "py-4" ] |> Tailwind.use |> class ]
|
||||
[ simpleButton
|
||||
{ label = "Fetch data"
|
||||
, handleClick = handleFetch
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
datePicker :
|
||||
{ mDate : Maybe Date.Date
|
||||
, prompt : String
|
||||
, prefix : String
|
||||
, picker : DatePicker.DatePicker
|
||||
, onUpdate : DatePicker.Msg -> State.Msg
|
||||
}
|
||||
-> Html State.Msg
|
||||
datePicker { mDate, prompt, prefix, picker, onUpdate } =
|
||||
let
|
||||
settings =
|
||||
{ defaultSettings
|
||||
| placeholder = prompt
|
||||
, inputClassList =
|
||||
[ ( "text-center", True )
|
||||
, ( "py-2", True )
|
||||
]
|
||||
}
|
||||
in
|
||||
div [ [ "w-1/2", "py-4", "mx-auto" ] |> Tailwind.use |> class ]
|
||||
[ DatePicker.view mDate settings picker |> Html.map onUpdate ]
|
||||
|
||||
|
||||
wrapNoPrint : Html State.Msg -> Html State.Msg
|
||||
wrapNoPrint component =
|
||||
div [ [ "no-print" ] |> Tailwind.use |> class ] [ component ]
|
||||
245
users/wpcarro/assessments/tt/client/src/User.elm
Normal file
245
users/wpcarro/assessments/tt/client/src/User.elm
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
module User exposing (render)
|
||||
|
||||
import Common
|
||||
import Date
|
||||
import DatePicker
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import Maybe.Extra as ME
|
||||
import RemoteData
|
||||
import State
|
||||
import Tailwind
|
||||
import UI
|
||||
import Utils
|
||||
|
||||
|
||||
createTrip : State.Model -> Html State.Msg
|
||||
createTrip model =
|
||||
div []
|
||||
[ UI.header 3 "Plan Upcoming Trip"
|
||||
, UI.textField
|
||||
{ pholder = "Where are you going?"
|
||||
, inputId = "destination"
|
||||
, handleInput = State.UpdateTripDestination
|
||||
, inputValue = model.tripDestination
|
||||
}
|
||||
, div [ [ "flex" ] |> Tailwind.use |> class ]
|
||||
[ UI.datePicker
|
||||
{ mDate = model.tripStartDate
|
||||
, prompt = "Set departure date"
|
||||
, prefix = "Departure: "
|
||||
, picker = model.startDatePicker
|
||||
, onUpdate = State.UpdateTripStartDate
|
||||
}
|
||||
, UI.datePicker
|
||||
{ mDate = model.tripEndDate
|
||||
, prompt = "Set return date"
|
||||
, prefix = "Return: "
|
||||
, picker = model.endDatePicker
|
||||
, onUpdate = State.UpdateTripEndDate
|
||||
}
|
||||
]
|
||||
, UI.textField
|
||||
{ pholder = "Comments?"
|
||||
, inputId = "comment"
|
||||
, handleInput = State.UpdateTripComment
|
||||
, inputValue = model.tripComment
|
||||
}
|
||||
, UI.baseButton
|
||||
{ enabled =
|
||||
List.all
|
||||
identity
|
||||
[ String.length model.tripDestination > 0
|
||||
, String.length model.tripComment > 0
|
||||
, ME.isJust model.tripStartDate
|
||||
, ME.isJust model.tripEndDate
|
||||
]
|
||||
, extraClasses = [ "my-4" ]
|
||||
, handleClick =
|
||||
case ( model.tripStartDate, model.tripEndDate ) of
|
||||
( Nothing, _ ) ->
|
||||
State.DoNothing
|
||||
|
||||
( _, Nothing ) ->
|
||||
State.DoNothing
|
||||
|
||||
( Just startDate, Just endDate ) ->
|
||||
State.AttemptCreateTrip startDate endDate
|
||||
, label = "Schedule trip"
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
renderEditTrip : State.Model -> State.Trip -> Html State.Msg
|
||||
renderEditTrip model trip =
|
||||
li []
|
||||
[ div []
|
||||
[ UI.textField
|
||||
{ handleInput = State.UpdateEditTripDestination
|
||||
, inputId = "edit-trip-destination"
|
||||
, inputValue = model.editTripDestination
|
||||
, pholder = "Destination"
|
||||
}
|
||||
, UI.textField
|
||||
{ handleInput = State.UpdateEditTripComment
|
||||
, inputId = "edit-trip-comment"
|
||||
, inputValue = model.editTripComment
|
||||
, pholder = "Comment"
|
||||
}
|
||||
]
|
||||
, div []
|
||||
[ UI.baseButton
|
||||
{ enabled =
|
||||
case model.updateTripStatus of
|
||||
RemoteData.Loading ->
|
||||
False
|
||||
|
||||
_ ->
|
||||
True
|
||||
, extraClasses = []
|
||||
, label =
|
||||
case model.updateTripStatus of
|
||||
RemoteData.Loading ->
|
||||
"Saving..."
|
||||
|
||||
_ ->
|
||||
"Save"
|
||||
, handleClick =
|
||||
State.AttemptUpdateTrip
|
||||
{ username = trip.username
|
||||
, destination = trip.destination
|
||||
, startDate = trip.startDate
|
||||
}
|
||||
{ username = trip.username
|
||||
, destination = model.editTripDestination
|
||||
, startDate = trip.startDate
|
||||
, endDate = trip.endDate
|
||||
, comment = model.editTripComment
|
||||
}
|
||||
}
|
||||
, UI.simpleButton
|
||||
{ label = "Cancel"
|
||||
, handleClick = State.CancelEditTrip
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
renderTrip : Date.Date -> State.Trip -> Html State.Msg
|
||||
renderTrip today trip =
|
||||
li
|
||||
[ [ "py-2" ]
|
||||
|> Tailwind.use
|
||||
|> class
|
||||
]
|
||||
[ if Date.compare today trip.startDate == GT then
|
||||
UI.paragraph
|
||||
(String.fromInt (Date.diff Date.Days trip.startDate today)
|
||||
++ " days until you're travelling to "
|
||||
++ trip.destination
|
||||
++ " for "
|
||||
++ String.fromInt
|
||||
(Date.diff
|
||||
Date.Days
|
||||
trip.startDate
|
||||
trip.endDate
|
||||
)
|
||||
++ " days."
|
||||
)
|
||||
|
||||
else
|
||||
UI.paragraph
|
||||
(String.fromInt (Date.diff Date.Days today trip.endDate)
|
||||
++ " days ago you returned from your trip to "
|
||||
++ trip.destination
|
||||
)
|
||||
, UI.paragraph ("\"" ++ trip.comment ++ "\"")
|
||||
, UI.wrapNoPrint
|
||||
(UI.textButton
|
||||
{ label = "Edit"
|
||||
, handleClick = State.EditTrip trip
|
||||
}
|
||||
)
|
||||
, UI.wrapNoPrint
|
||||
(UI.textButton
|
||||
{ label = "Delete"
|
||||
, handleClick = State.AttemptDeleteTrip trip
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
trips : State.Model -> Html State.Msg
|
||||
trips model =
|
||||
div []
|
||||
[ UI.header 3 "Your Trips"
|
||||
, case model.trips of
|
||||
RemoteData.NotAsked ->
|
||||
UI.paragraph "Somehow we've reached the user home page without requesting your trips data. Please report this to our engineering team at bugs@tripplaner.tld"
|
||||
|
||||
RemoteData.Loading ->
|
||||
UI.paragraph "Loading your trips..."
|
||||
|
||||
RemoteData.Failure e ->
|
||||
UI.paragraph ("Error: " ++ Utils.explainHttpError e)
|
||||
|
||||
RemoteData.Success xs ->
|
||||
case model.todaysDate of
|
||||
Nothing ->
|
||||
text ""
|
||||
|
||||
Just today ->
|
||||
div [ [ "mb-10" ] |> Tailwind.use |> class ]
|
||||
[ ul [ [ "my-4" ] |> Tailwind.use |> class ]
|
||||
(xs
|
||||
|> List.sortWith (\x y -> Date.compare y.startDate x.startDate)
|
||||
|> List.map
|
||||
(\trip ->
|
||||
case model.editingTrip of
|
||||
Nothing ->
|
||||
renderTrip today trip
|
||||
|
||||
Just x ->
|
||||
if x == trip then
|
||||
renderEditTrip model trip
|
||||
|
||||
else
|
||||
renderTrip today trip
|
||||
)
|
||||
)
|
||||
, UI.wrapNoPrint
|
||||
(UI.simpleButton
|
||||
{ label = "Print iternary"
|
||||
, handleClick = State.PrintPage
|
||||
}
|
||||
)
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
render : State.Model -> Html State.Msg
|
||||
render model =
|
||||
Common.withSession model
|
||||
(\session ->
|
||||
div
|
||||
[ class
|
||||
([ "container"
|
||||
, "mx-auto"
|
||||
, "text-center"
|
||||
]
|
||||
|> Tailwind.use
|
||||
)
|
||||
]
|
||||
[ UI.wrapNoPrint (UI.header 2 ("Welcome, " ++ session.username ++ "!"))
|
||||
, UI.wrapNoPrint (createTrip model)
|
||||
, trips model
|
||||
, UI.wrapNoPrint
|
||||
(UI.textButton
|
||||
{ label = "Logout"
|
||||
, handleClick = State.AttemptLogout
|
||||
}
|
||||
)
|
||||
, Common.allErrors model
|
||||
]
|
||||
)
|
||||
109
users/wpcarro/assessments/tt/client/src/Utils.elm
Normal file
109
users/wpcarro/assessments/tt/client/src/Utils.elm
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
module Utils exposing (..)
|
||||
|
||||
import DateFormat
|
||||
import Http
|
||||
import Time
|
||||
import Shared
|
||||
|
||||
|
||||
explainHttpError : Http.Error -> String
|
||||
explainHttpError e =
|
||||
case e of
|
||||
Http.BadUrl _ ->
|
||||
"Bad URL: you may have supplied an improperly formatted URL"
|
||||
|
||||
Http.Timeout ->
|
||||
"Timeout: the resource you requested did not arrive within the interval of time that you claimed it should"
|
||||
|
||||
Http.BadStatus s ->
|
||||
"Bad Status: the server returned a bad status code: " ++ String.fromInt s
|
||||
|
||||
Http.BadBody b ->
|
||||
"Bad Body: our application had trouble decoding the body of the response from the server: " ++ b
|
||||
|
||||
Http.NetworkError ->
|
||||
"Network Error: something went awry in the network stack. I recommend checking the server logs if you can."
|
||||
|
||||
|
||||
getWithCredentials :
|
||||
{ url : String
|
||||
, expect : Http.Expect msg
|
||||
}
|
||||
-> Cmd msg
|
||||
getWithCredentials { url, expect } =
|
||||
Http.riskyRequest
|
||||
{ url = url
|
||||
, headers = [ Http.header "Origin" Shared.clientOrigin ]
|
||||
, method = "GET"
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
, body = Http.emptyBody
|
||||
, expect = expect
|
||||
}
|
||||
|
||||
|
||||
postWithCredentials :
|
||||
{ url : String
|
||||
, body : Http.Body
|
||||
, expect : Http.Expect msg
|
||||
}
|
||||
-> Cmd msg
|
||||
postWithCredentials { url, body, expect } =
|
||||
Http.riskyRequest
|
||||
{ url = url
|
||||
, headers = [ Http.header "Origin" Shared.clientOrigin ]
|
||||
, method = "POST"
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
, body = body
|
||||
, expect = expect
|
||||
}
|
||||
|
||||
|
||||
deleteWithCredentials :
|
||||
{ url : String
|
||||
, body : Http.Body
|
||||
, expect : Http.Expect msg
|
||||
}
|
||||
-> Cmd msg
|
||||
deleteWithCredentials { url, body, expect } =
|
||||
Http.riskyRequest
|
||||
{ url = url
|
||||
, headers = [ Http.header "Origin" Shared.clientOrigin ]
|
||||
, method = "DELETE"
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
, body = body
|
||||
, expect = expect
|
||||
}
|
||||
|
||||
putWithCredentials :
|
||||
{ url : String
|
||||
, body : Http.Body
|
||||
, expect : Http.Expect msg
|
||||
}
|
||||
-> Cmd msg
|
||||
putWithCredentials { url, body, expect } =
|
||||
Http.riskyRequest
|
||||
{ url = url
|
||||
, headers = [ Http.header "Origin" Shared.clientOrigin ]
|
||||
, method = "PUT"
|
||||
, timeout = Nothing
|
||||
, tracker = Nothing
|
||||
, body = body
|
||||
, expect = expect
|
||||
}
|
||||
|
||||
|
||||
|
||||
formatTime : Time.Posix -> String
|
||||
formatTime ts =
|
||||
DateFormat.format
|
||||
[ DateFormat.monthNameFull
|
||||
, DateFormat.text " "
|
||||
, DateFormat.dayOfMonthSuffix
|
||||
, DateFormat.text ", "
|
||||
, DateFormat.yearNumber
|
||||
]
|
||||
Time.utc
|
||||
ts
|
||||
Loading…
Add table
Add a link
Reference in a new issue