Modularise the UI with widgets

This commit is contained in:
Saku Laesvuori 2024-01-05 13:01:31 +02:00
parent 3e274d94f8
commit 7ebc6b0eda
Signed by: slaesvuo
GPG Key ID: 257D284A2A1D3A32
9 changed files with 327 additions and 223 deletions

View File

@ -49,6 +49,7 @@ executable datarekisteri-frontend
Datarekisteri.Frontend.Handlers.Profile, Datarekisteri.Frontend.Handlers.Profile,
Datarekisteri.Frontend.Handlers.VerifyEmail, Datarekisteri.Frontend.Handlers.VerifyEmail,
Datarekisteri.Frontend.Handlers.Members, Datarekisteri.Frontend.Handlers.Members,
Datarekisteri.Frontend.Types Datarekisteri.Frontend.Types,
Datarekisteri.Frontend.Widgets
hs-source-dirs: src hs-source-dirs: src
default-language: Haskell2010 default-language: Haskell2010

View File

@ -21,6 +21,8 @@ import Yesod.Auth
import qualified Yesod.Auth.Message as Msg import qualified Yesod.Auth.Message as Msg
import Datarekisteri.Frontend.Widgets
pluginName = "externalBasic" pluginName = "externalBasic"
loginR = PluginR pluginName ["login"] loginR = PluginR pluginName ["login"]
@ -53,9 +55,7 @@ loginForm :: YesodAuth site => (Route Auth -> Route site) -> WidgetFor site ()
loginForm toParent = do loginForm toParent = do
emailId <- newIdent emailId <- newIdent
passwordId <- newIdent passwordId <- newIdent
[whamlet| let formContent = [whamlet|
<h1>Kirjaudu
<form action=@{toParent loginR} method=post>
<label for="#{emailId}"> <label for="#{emailId}">
Sähköposti Sähköposti
<input id="#{emailId}" name="email" type="text" autofocus> <input id="#{emailId}" name="email" type="text" autofocus>
@ -63,4 +63,8 @@ loginForm toParent = do
Salasana Salasana
<input id="#{passwordId}" name="password" type="password"> <input id="#{passwordId}" name="password" type="password">
<input type="submit" value="Kirjaudu"> <input type="submit" value="Kirjaudu">
|]
[whamlet|
<h1>Kirjaudu
^{form (toParent loginR) UrlEncoded formContent}
|] |]

View File

@ -25,6 +25,7 @@ import Datarekisteri.Core.Types hiding (Applications)
import Datarekisteri.Frontend.ApiRequests import Datarekisteri.Frontend.ApiRequests
import Datarekisteri.Frontend.FormFields import Datarekisteri.Frontend.FormFields
import Datarekisteri.Frontend.Types import Datarekisteri.Frontend.Types
import Datarekisteri.Frontend.Widgets
declareLocalTypesInline "schema.gql" [raw| declareLocalTypesInline "schema.gql" [raw|
query Applications { query Applications {
@ -65,41 +66,54 @@ applicationsW applications = do
acceptRoute <- handlerToWidget $ maybeAuthorized AcceptR True acceptRoute <- handlerToWidget $ maybeAuthorized AcceptR True
forM_ applications $ \ApplicationsApplications {..} -> do forM_ applications $ \ApplicationsApplications {..} -> do
(widget, enctype) <- handlerToWidget $ generateFormPost $ resolveApplicationForm $ Just id (widget, enctype) <- handlerToWidget $ generateFormPost $ resolveApplicationForm $ Just id
let keysAndValues =
[ ("Nimi" :: Text, name)
, ("Kutsumanimi", nickname)
, ("Kotipaikka", homeplace)
, ("Syntymäaika", renderDate birthdate)
, ("Sähköposti", renderEmail $ fromJust email)
, ("Puhelinnumero", renderPhoneNumber phoneNumber)
]
acceptFormContent = [whamlet|
^{widget}
^{submitButton "Hyväksy"}
|]
rejectFormContent = [whamlet|
^{widget}
^{submitButtonBad "Hylkää"}
|]
detailsClass <- newIdent
[whamlet| [whamlet|
<details> <details .#{detailsClass}>
<summary> <summary>
<h2>#{name} <h2>#{name}
<article> <article>
<h3>Hakemus <h3>Hakemus
<p>#{Textarea application} <p>#{Textarea application}
<table> ^{keyValueTable keysAndValues}
<tr>
<th scope="row">Nimi
<td>#{name}
<tr>
<th scope="row">Kutsumanimi
<td>#{nickname}
<tr>
<th scope="row">Kotipaikka
<td>#{homeplace}
<tr>
<th scope="row">Syntymäaika
<td>#{renderDate $ birthdate}
<tr>
<th scope="row">Sähköposti
<td>#{renderEmail $ fromJust $ email}
<tr>
<th scope="row">Puhelinnumero
<td>#{renderPhoneNumber $ phoneNumber}
$maybe route <- acceptRoute $maybe route <- acceptRoute
<form action="@{route}" method="post" enctype="#{enctype}"> ^{form route enctype acceptFormContent}
^{widget}
<input type="submit" value="Hyväksy">
$maybe route <- rejectRoute $maybe route <- rejectRoute
<form action="@{route}" method="post" enctype="#{enctype}"> ^{form route enctype rejectFormContent}
^{widget} |]
<input type="submit" value="Hylkää" class="reject-button"> toWidget [lucius|
|] .#{detailsClass} {
padding: 1.3em;
margin: 0.6em;
border-color: #00838a;
border-width: 0 0 0 0.6em;
border-style: solid;
border-radius: 0.4em;
background-color: #f6f8f8;
> summary > h2 {
display: inline-block;
margin-top: 0;
margin-bottom: 0;
}
> article { margin-bottom: 1.5em }
}
.#{detailsClass}[open] { border-color: #339ca1; }
|]
getApplicationsR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html getApplicationsR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
getApplicationsR = do getApplicationsR = do

View File

@ -24,6 +24,7 @@ import Datarekisteri.Core.Types
import Datarekisteri.Frontend.ApiRequests import Datarekisteri.Frontend.ApiRequests
import Datarekisteri.Frontend.FormFields import Datarekisteri.Frontend.FormFields
import Datarekisteri.Frontend.Types import Datarekisteri.Frontend.Types
import Datarekisteri.Frontend.Widgets
declareLocalTypesInline "schema.gql" [raw| declareLocalTypesInline "schema.gql" [raw|
mutation Apply($name: String!, $nickname: String, $homeplace: String!, mutation Apply($name: String!, $nickname: String, $homeplace: String!,
@ -60,17 +61,19 @@ applyForm = renderDivs $ ApplyArgs
applyW :: (Widget, Enctype) -> Widget applyW :: (Widget, Enctype) -> Widget
applyW (applyWidget, applyEnctype) = do applyW (applyWidget, applyEnctype) = do
setTitle "Jäsenhakemus" setTitle "Jäsenhakemus"
[whamlet| let formContent = [whamlet|
<h1>
Jäsenhakemus
<form action="@{ApplyR}" method="post" enctype="#{applyEnctype}">
^{applyWidget} ^{applyWidget}
<p> <p>
Lähettämällä jäsenhakemuksen vakuutat antamiesi tietojen oikeellisuuden ja puuttettomuuden Lähettämällä jäsenhakemuksen vakuutat antamiesi tietojen oikeellisuuden ja puuttettomuuden
sekä sitoudut pitämään ne ajan tasalla. Rekisteriin kirjattuja tietoja käsitellään sekä sitoudut pitämään ne ajan tasalla. Rekisteriin kirjattuja tietoja käsitellään
<a href="https://datat.fi/rekisteriseloste">rekisteriselosteen</a> mukaisesti. <a href="https://datat.fi/rekisteriseloste">rekisteriselosteen</a> mukaisesti.
<input type="submit" value="Hae jäseneksi"> ^{submitButton "Hae Jäseneksi"}
|] |]
[whamlet|
<h1>
Jäsenhakemus
^{form ApplyR applyEnctype formContent}
|]
getApplyR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html getApplyR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
getApplyR = do getApplyR = do

View File

@ -1,5 +1,6 @@
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
@ -19,6 +20,7 @@ import Yesod.Auth
import Datarekisteri.Frontend.Types import Datarekisteri.Frontend.Types
import Datarekisteri.Frontend.ApiRequests import Datarekisteri.Frontend.ApiRequests
import Datarekisteri.Frontend.Widgets
import Datarekisteri.Core.Types import Datarekisteri.Core.Types
declareLocalTypesInline "schema.gql" [raw| declareLocalTypesInline "schema.gql" [raw|
@ -32,6 +34,10 @@ query MembersPage {
} }
|] |]
instance ToTableRow DataIdClient MembersPageUsers where
tableHeader _ = toWidget <$> ["Nimi" :: Text, "Kutsumanimi", "Kotipaikka"]
toCells MembersPageUsers {..} = toWidget <$> [name, nickname, homeplace]
getMembersR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html getMembersR :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => Handler Html
getMembersR = do getMembersR = do
MembersPage {..} <- apiRequest True () MembersPage {..} <- apiRequest True ()

View File

@ -23,6 +23,7 @@ import Datarekisteri.Core.Types
import Datarekisteri.Frontend.ApiRequests import Datarekisteri.Frontend.ApiRequests
import Datarekisteri.Frontend.Types import Datarekisteri.Frontend.Types
import Datarekisteri.Frontend.FormFields import Datarekisteri.Frontend.FormFields
import Datarekisteri.Frontend.Widgets
declareLocalTypesInline "schema.gql" [raw| declareLocalTypesInline "schema.gql" [raw|
query ProfilePage($id: UserID) { query ProfilePage($id: UserID) {
@ -100,20 +101,24 @@ profile user (profileWidget, profileEnctype) (passwordWidget, passwordEnctype) =
setTitle "Muokkaa profiilia" setTitle "Muokkaa profiilia"
let userID = let ProfilePageUser {..} = user in id let userID = let ProfilePageUser {..} = user in id
passwordRoute <- handlerToWidget $ maybeAuthorized (UpdatePasswordR userID) True passwordRoute <- handlerToWidget $ maybeAuthorized (UpdatePasswordR userID) True
let profileFormContent = [whamlet|
^{profileWidget}
^{submitButton "Päivitä tiedot"}
|]
passwordFormContent = [whamlet|
^{passwordWidget}
^{submitButton "Vaihda salasana"}
|]
[whamlet| [whamlet|
<h1> <h1>
$if isMember user $if isMember user
Jäsentiedot Jäsentiedot
$else $else
Jäsenhakemuksen tiedot Jäsenhakemuksen tiedot
<form action="@{ProfileR userID}" method="post" enctype="#{profileEnctype}"> ^{form (ProfileR userID) profileEnctype profileFormContent}
^{profileWidget}
<input type="submit" value="Päivitä tiedot">
$maybe route <- passwordRoute $maybe route <- passwordRoute
<form action="@{route}" method="post" enctype="#{passwordEnctype}"> ^{form route passwordEnctype passwordFormContent}
^{passwordWidget} |]
<input type="submit" value="Vaihda salasana">
|]
getProfile :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => (Maybe UserID) -> Handler Html getProfile :: (YesodAuth DataIdClient, AuthId DataIdClient ~ Text) => (Maybe UserID) -> Handler Html
getProfile userID = do getProfile userID = do

View File

@ -20,6 +20,7 @@ import Yesod.Auth
import Datarekisteri.Frontend.ApiRequests import Datarekisteri.Frontend.ApiRequests
import Datarekisteri.Frontend.Types import Datarekisteri.Frontend.Types
import Datarekisteri.Frontend.Widgets
import Datarekisteri.Core.Types import Datarekisteri.Core.Types
declareLocalTypesInline "schema.gql" [raw| declareLocalTypesInline "schema.gql" [raw|
@ -45,12 +46,14 @@ postVerifyEmailR = do
verifyEmailW (codeWidget, codeEnctype) = do verifyEmailW (codeWidget, codeEnctype) = do
setTitle "Vahvista sähköpostiosoite" setTitle "Vahvista sähköpostiosoite"
let formContent = [whamlet|
^{codeWidget}
^{submitButton "Vahvista"}
|]
[whamlet| [whamlet|
<h1> <h1>
Vahvista sähköpostiosoite Vahvista sähköpostiosoite
<form action="@{VerifyEmailR}" method="post" enctype="#{codeEnctype}"> ^{form VerifyEmailR codeEnctype formContent}
^{codeWidget}
<input type="submit" value="Vahvista">
|] |]
verifyForm = renderDivs $ VerifyEmailArgs <$> areq textField "Vahvistuskoodi" Nothing verifyForm = renderDivs $ VerifyEmailArgs <$> areq textField "Vahvistuskoodi" Nothing

View File

@ -163,47 +163,105 @@ instance Yesod DataIdClient where
errorHandler x = defaultErrorHandler x errorHandler x = defaultErrorHandler x
addStaticContent = addStaticContentExternal Right base64md5 "/tmp/data-id" (StaticR . flip StaticRoute []) addStaticContent = addStaticContentExternal Right base64md5 "/tmp/data-id" (StaticR . flip StaticRoute [])
defaultLayout widget = do defaultLayout widget = do
let messages :: Widget
messages = do
msgs <- getMessages msgs <- getMessages
applicationsRoute <- maybeAuthorized ApplicationsR False messagesClass <- newIdent
profileRoute <- maybeAuthorized OwnProfileR False messageClass <- newIdent
membersRoute <- maybeAuthorized MembersR False toWidget [hamlet|
$if not $ null msgs
<aside class="#{messagesClass}">
<ul>
$forall (_, msg) <- msgs
<li class="#{messageClass}">#{msg}
|]
toWidget [lucius|
aside.#{messagesClass} > ul {
list-style: none;
padding: 0;
margin: 0;
li.#{messageClass} {
display: block;
padding: 0.7em;
background-color: #3b4553;
color: white;
border-radius: 0.3em;
}
}
|]
navigationBar :: Widget
navigationBar = do
applicationsRoute <- handlerToWidget $ maybeAuthorized ApplicationsR False
profileRoute <- handlerToWidget $ maybeAuthorized OwnProfileR False
membersRoute <- handlerToWidget $ maybeAuthorized MembersR False
currentRoute <- getCurrentRoute currentRoute <- getCurrentRoute
loggedIn <- isJust <$> maybeAuthId loggedIn <- isJust <$> maybeAuthId
navClass <- newIdent
activeClass <- newIdent
rightClass <- newIdent
let navItem :: Bool -> Maybe (Route DataIdClient) -> Text -> HtmlUrl (Route DataIdClient)
navItem alignRight maybeRoute name = [hamlet|
$maybe route <- maybeRoute
<li :Just route == currentRoute:class="#{activeClass}" :alignRight:class="#{rightClass}">
<a href="@{route}">#{name}
|]
rightNavItem = navItem True
leftNavItem = navItem False
toWidget [hamlet|
<nav .#{navClass}>
<ul>
^{leftNavItem profileRoute "Profiili"}
^{leftNavItem membersRoute "Jäsenet"}
^{leftNavItem applicationsRoute "Hakemukset"}
$if loggedIn
^{rightNavItem (Just (AuthR LogoutR)) "Kirjaudu ulos"}
$else
^{rightNavItem (Just (AuthR LoginR)) "Kirjaudu sisään"}
^{rightNavItem (Just ApplyR) "Hae jäseneksi"}
|]
toWidget [lucius|
.#{navClass} {
display: block;
position: fixed;
top: 0;
width: 100%;
padding: 0;
background-color: #{fgColor};
ul {
list-style-type: none;
margin: 0;
padding: 0;
}
li {
display: block;
float: left;
padding: 0;
margin: 0;
a {
color: #{bgColor};
text-decoration: none;
display: inline-block;
padding: 0.7em 1em;
}
a:hover {
background-color: #00838a;
color: #ffffff;
}
}
.#{activeClass} { font-weight: 500; }
.#{rightClass} { float: right; }
}
|]
p <- widgetToPageContent $ do p <- widgetToPageContent $ do
[whamlet| [whamlet|
<header> <header>
<nav> ^{navigationBar}
<ul>
$maybe route <- profileRoute
<li :Just route == currentRoute:class="active-nav">
<a href="@{route}">Profiili
$maybe route <- membersRoute
<li :Just route == currentRoute:class="active-nav">
<a href="@{route}">Jäsenet
$maybe route <- applicationsRoute
<li :Just route == currentRoute:class="active-nav">
<a href="@{route}">Hakemukset
$if loggedIn
<li class="left-nav">
<a href="@{AuthR LogoutR}">Kirjaudu ulos
$else
<li :Just (AuthR LoginR) == currentRoute:class="active-nav" class="left-nav">
<a href="@{AuthR LoginR}">Kirjaudu sisään
<li :Just ApplyR == currentRoute:class="active-nav" class="left-nav">
<a href="@{ApplyR}">Hae jäseneksi
<main> <main>
$if not $ null msgs ^{messages}
<aside class="messages">
<ul>
$forall (_, msg) <- msgs
<li class="message">#{msg}
^{widget} ^{widget}
|] :: Widget |] :: Widget
toWidget $ [lucius| toWidget $ [lucius|
:root {
--bg-colour: #ffffff;
--fg-colour: #181c22;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -211,135 +269,17 @@ instance Yesod DataIdClient where
font-family: "Fira Sans", sans-serif; font-family: "Fira Sans", sans-serif;
height: 100%; height: 100%;
} }
th[scope="row"] {
text-align: right;
}
th[scope="col"] {
text-align: center;
}
body { body {
background-color: #e8eaef; background-color: #e8eaef;
color: var(--fg-colour); color: #{fgColor};
margin: 0; margin: 0;
height: 100%; height: 100%;
} }
label {
display: block;
padding-top: 0.3em;
padding-bottom: 0.3em;
}
input {
width: 100%;
padding: 0.4em;
margin-top: 0.3em;
margin-bottom: 0.3em;
border-radius: 0.25em;
}
textarea {
width: 100%;
padding: 0.4em;
margin-top: 0.3em;
margin-bottom: 0.3em;
}
form {
max-width: min(30em,100%);
}
summary > h2 {
display: inline-block;
margin-top: 0;
margin-bottom: 0;
}
details > table th {
text-align: left;
padding-left: 0;
}
details {
padding: 1.3em;
margin: 0.6em;
border-color: #00838a;
border-width: 0 0 0 0.6em;
border-style: solid;
border-radius: 0.4em;
background-color: #f6f8f8;
}
details[open] {
border-color: #339ca1;
}
details > article {
margin-bottom: 1.5em
}
input[type="submit"] {
background-color: #00838a;
color: #ffffff;
padding: 0.5em 1em;
border: 0;
cursor: pointer;
font-weight: bold;
}
input[type="submit"]:hover {
background-color: #339ca1;
}
input[type="submit"].reject-button {
background-color: #8a003a;
}
input[type="submit"].reject-button:hover {
background-color: #aa3968;
}
input:invalid {
border-color: #8a003a;
}
nav {
display: block;
position: fixed;
top: 0;
width: 100%;
padding: 0;
background-color: var(--fg-colour);
}
nav ul {
list-style-type: none;
margin: 0;
padding: 0;
}
nav li {
display: block;
float: left;
padding: 0;
margin: 0;
}
nav li a {
color: var(--bg-colour);
text-decoration: none;
display: inline-block;
padding: 0.7em 1em;
}
nav li a:hover {
background-color: #00838a;
color: #ffffff;
}
.active-nav {
font-weight: 500;
}
.left-nav {
float: right;
}
aside.messages > ul {
list-style: none;
padding: 0;
margin: 0;
}
aside.messages li.message {
display: block;
padding: 0.7em;
background-color: #3b4553;
color: white;
border-radius: 0.3em;
}
main { main {
margin: 0 auto; margin: 0 auto;
max-width: 50em; max-width: 50em;
padding: 5em 1em 3em 1em; padding: 5em 1em 3em 1em;
background-color: var(--bg-colour); background-color: #{bgColor};
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@ -355,6 +295,10 @@ instance Yesod DataIdClient where
^{pageBody p} ^{pageBody p}
|] |]
bgColor, fgColor :: Text
bgColor = "#ffffff"
fgColor = "#181c22"
instance ApiRequest DataIdClient where instance ApiRequest DataIdClient where
getApiUrl = configServerUrl . getConfig <$> getYesod getApiUrl = configServerUrl . getConfig <$> getYesod
authIdToAuthorization = flip const authIdToAuthorization = flip const

View File

@ -0,0 +1,124 @@
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Datarekisteri.Frontend.Widgets where
import Data.Text (Text)
import Yesod
type Widget' a = WidgetFor a ()
submitButton :: Text -> Widget' a
submitButton text = do
toWidget [hamlet|<input type="submit" value="#{text}">|]
submitButtonBad :: Text -> Widget' a
submitButtonBad text = do
buttonId <- newIdent
toWidget [hamlet|<input id="#{buttonId}" type="submit" value="#{text}">|]
toWidget [lucius|
input[type="submit"]##{buttonId} {
background-color: #8a003a;
}
input[type="submit"]##{buttonId}:hover {
background-color: #aa3968;
}
|]
keyValueTable :: (ToWidget site a, ToWidget site b) => [(a, b)] -> Widget' site
keyValueTable rows = do
tableClass <- newIdent
[whamlet|
<table .#{tableClass}>
$forall row <- rows
<tr>
<th scope="row">^{fst row}
<td>^{snd row}
|]
toWidget [lucius|
.#{tableClass} {
th { text-align: right; }
td { text-align: left; }
}
|]
form :: Route a -> Enctype -> Widget' a -> Widget' a
form route enctype content = do
formClass <- newIdent
[whamlet|
<form .#{formClass} action="@{route}" method="post" enctype="#{enctype}">
^{content}
|]
toWidget [lucius|
form.#{formClass} {
max-width: min(30em,100%);
label {
display: block;
padding-top: 0.3em;
padding-bottom: 0.3em;
}
textarea {
width: 100%;
padding: 0.4em;
margin-top: 0.3em;
margin-bottom: 0.3em;
}
input {
width: 100%;
padding: 0.4em;
margin-top: 0.3em;
margin-bottom: 0.3em;
border-radius: 0.25em;
}
input:invalid {
border-color: #8a003a;
}
input[type="submit"] {
background-color: #00838a;
color: #ffffff;
padding: 0.5em 1em;
border: 0;
cursor: pointer;
font-weight: bold;
}
input[type="submit"]:hover {
background-color: #339ca1;
}
}
|]
table' :: [Widget' a] -> [[Widget' a]] -> Widget' a
table' header rows = do
tableClass <- newIdent
thClass <- newIdent
[whamlet|
<table .#{tableClass}>
<thead>
<tr>
$forall cell <- header
<th .#{thClass} scope="col">
^{cell}
$forall row <- rows
<tr>
$forall cell <- row
<td>
^{cell}
|]
toWidget [lucius|
table.#{tableClass} {
th[scope="col"].#{thClass} {
text-align: center;
}
}
|]
table :: forall a site. ToTableRow site a => [a] -> Widget' site
table rows = table' (tableHeader (undefined :: a)) (toCells <$> rows)
class ToTableRow site a where
tableHeader :: a -> [Widget' site]
toCells :: a -> [Widget' site]