Add beginning of todo example.

This commit is contained in:
Dillon Kearns 2022-05-23 19:45:52 -07:00
parent d79f8a5403
commit 56ee2cf145
3 changed files with 935 additions and 104 deletions

View File

@ -37,7 +37,20 @@ route =
RouteBuilder.serverRender
{ head = head
, data = data
, action = \_ -> Request.skip "No action."
, action =
\_ ->
MySession.withSession
(Request.expectFormPost (\{ field } -> field "name"))
(\name session ->
( session
|> Result.withDefault Nothing
|> Maybe.withDefault Session.empty
|> Session.insert "userId" name
|> Session.withFlash "message" ("Welcome " ++ name ++ "!")
, Route.redirectTo Route.Todos
)
|> DataSource.succeed
)
}
|> RouteBuilder.buildNoState { view = view }
@ -50,40 +63,26 @@ type alias Request =
data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage))
data routeParams =
Request.oneOf
[ MySession.withSession
(Request.expectFormPost (\{ field } -> field "name"))
(\name session ->
( session
|> Result.withDefault Nothing
|> Maybe.withDefault Session.empty
|> Session.insert "name" name
|> Session.withFlash "message" ("Welcome " ++ name ++ "!")
, Route.redirectTo Route.Index
)
|> DataSource.succeed
)
, MySession.withSession
(Request.succeed ())
(\() session ->
case session of
Ok (Just okSession) ->
( okSession
, okSession
|> Session.get "name"
|> Data
|> Server.Response.render
)
|> DataSource.succeed
MySession.withSession
(Request.succeed ())
(\() session ->
case session of
Ok (Just okSession) ->
( okSession
, okSession
|> Session.get "name"
|> Data
|> Server.Response.render
)
|> DataSource.succeed
_ ->
( Session.empty
, { username = Nothing }
|> Server.Response.render
)
|> DataSource.succeed
)
]
_ ->
( Session.empty
, { username = Nothing }
|> Server.Response.render
)
|> DataSource.succeed
)
head :

View File

@ -0,0 +1,392 @@
module Route.Todos exposing (ActionData, Data, Model, Msg, route)
import Api.InputObject
import Api.Mutation
import Api.Object exposing (Todos)
import Api.Object.Todos
import Api.Query
import DataSource exposing (DataSource)
import Effect exposing (Effect)
import ErrorPage exposing (ErrorPage)
import Graphql.Operation exposing (RootQuery)
import Graphql.OptionalArgument exposing (OptionalArgument(..))
import Graphql.SelectionSet as SelectionSet exposing (SelectionSet)
import Head
import Head.Seo as Seo
import Html exposing (Html)
import Html.Attributes as Attr
import Html.Events
import MySession
import Pages.Msg
import Pages.PageUrl exposing (PageUrl)
import Pages.Url
import Path exposing (Path)
import Request.Hasura
import Route
import RouteBuilder exposing (StatefulRoute, StatelessRoute, StaticPayload)
import Server.Request as Request
import Server.Response as Response exposing (Response)
import Server.Session as Session
import Shared
import Time
import View exposing (View)
type alias Model =
{}
type Msg
= NoOp
type alias RouteParams =
{}
route : StatefulRoute RouteParams Data ActionData Model Msg
route =
RouteBuilder.serverRender
{ head = head
, data = data
, action = action
}
|> RouteBuilder.buildWithLocalState
{ view = view
, update = update
, subscriptions = subscriptions
, init = init
}
init :
Maybe PageUrl
-> Shared.Model
-> StaticPayload Data ActionData RouteParams
-> ( Model, Effect Msg )
init maybePageUrl sharedModel static =
( {}, Effect.none )
update :
PageUrl
-> Shared.Model
-> StaticPayload Data ActionData RouteParams
-> Msg
-> Model
-> ( Model, Effect Msg )
update pageUrl sharedModel static msg model =
case msg of
NoOp ->
( model, Effect.none )
subscriptions : Maybe PageUrl -> RouteParams -> Path -> Shared.Model -> Model -> Sub Msg
subscriptions maybePageUrl routeParams path sharedModel model =
Sub.none
type alias Data =
{ todos : List Todo
}
type alias ActionData =
{}
todosByUserId : Int -> SelectionSet (List Todo) RootQuery
todosByUserId userId =
Api.Query.todos
(\optionals ->
{ optionals
| where_ =
Present
(Api.InputObject.buildTodos_bool_exp
(\whereOptionals ->
{ whereOptionals
| user_id =
Api.InputObject.buildInt_comparison_exp
(\intOptionals ->
{ intOptionals | eq_ = Present <| userId }
)
|> Present
}
)
)
}
)
(SelectionSet.map3 Todo
Api.Object.Todos.title
Api.Object.Todos.is_completed
Api.Object.Todos.id
)
data : RouteParams -> Request.Parser (DataSource (Response Data ErrorPage))
data routeParams =
Request.requestTime
|> MySession.expectSessionOrRedirect
(\requestTime session ->
let
maybeUserId : Maybe Int
maybeUserId =
session
|> Session.get "userId"
|> Maybe.andThen String.toInt
in
case maybeUserId of
Just userId ->
todosByUserId userId
|> Request.Hasura.dataSource (requestTime |> Time.posixToMillis |> String.fromInt)
|> DataSource.map
(\todos ->
( session
, Response.render { todos = todos }
)
)
Nothing ->
( session, Route.redirectTo Route.Login )
|> DataSource.succeed
)
action : RouteParams -> Request.Parser (DataSource (Response ActionData ErrorPage))
action routeParams =
let
userId =
1
in
Request.expectFormPost
(\{ field } ->
Request.oneOf
[ field "newTodo"
|> Request.map
(\title ->
createTodo userId title
|> Request.Hasura.mutationDataSource ""
|> DataSource.map
(\_ -> Response.render {})
)
, field "deleteId"
|> Request.map
(\deleteId ->
-- TODO use RBAC here in Hasura?
deleteTodo userId (deleteId |> String.toInt |> Maybe.withDefault 0)
|> Request.Hasura.mutationDataSource ""
|> DataSource.map
(\_ -> Response.render {})
)
]
)
createTodo : Int -> String -> SelectionSet (Maybe ()) Graphql.Operation.RootMutation
createTodo userId title =
Api.Mutation.insert_todos_one identity
{ object =
Api.InputObject.buildTodos_insert_input
(\optionals ->
{ optionals
| title = Present title
, user_id = Present userId
}
)
}
SelectionSet.empty
deleteTodo : Int -> Int -> SelectionSet (Maybe ()) Graphql.Operation.RootMutation
deleteTodo userId todoId =
Api.Mutation.delete_todos_by_pk { id = todoId }
SelectionSet.empty
head :
StaticPayload Data ActionData RouteParams
-> List Head.Tag
head static =
Seo.summary
{ canonicalUrlOverride = Nothing
, siteName = "elm-pages"
, image =
{ url = Pages.Url.external "TODO"
, alt = "elm-pages logo"
, dimensions = Nothing
, mimeType = Nothing
}
, description = "TODO"
, locale = Nothing
, title = "Todo List"
}
|> Seo.website
view :
Maybe PageUrl
-> Shared.Model
-> Model
-> StaticPayload Data ActionData RouteParams
-> View (Pages.Msg.Msg Msg)
view maybeUrl sharedModel model static =
{ title = "Todo List"
, body =
[ Html.div
[ Attr.class "todomvc-wrapper"
]
[ Html.section
[ Attr.class "todoapp"
]
[ Html.header
[ Attr.class "header"
]
[ Html.h1 []
[ Html.text "todos" ]
, Html.form
[ Attr.method "POST"
, Pages.Msg.fetcherOnSubmit
]
[ Html.input
[ Attr.class "new-todo"
, Attr.placeholder "What needs to be done?"
, Attr.autofocus True
, Attr.name "newTodo"
]
[]
, Html.button [] [ Html.text "Create" ]
]
]
, Html.section
[ Attr.class "main"
, Attr.style "visibility" "visible"
]
[ Html.input
[ Attr.class "toggle-all"
, Attr.id "toggle-all"
, Attr.type_ "checkbox"
, Attr.name "toggle"
]
[]
, Html.label
[ Attr.for "toggle-all"
]
[ Html.text "Mark all as complete" ]
, Html.ul
[ Attr.class "todo-list"
]
(static.data.todos
|> List.map todoItemView
)
]
, Html.footer
[ Attr.class "footer"
]
[ Html.span
[ Attr.class "todo-count"
]
[ Html.strong []
[ Html.text "3" ]
, Html.text " items left"
]
, Html.ul
[ Attr.class "filters"
]
[ Html.li []
[ Html.a
[ Attr.class "selected"
, Attr.href "#/"
]
[ Html.text "All" ]
]
, Html.li []
[ Html.a
[ Attr.class ""
, Attr.href "#/active"
]
[ Html.text "Active" ]
]
, Html.li []
[ Html.a
[ Attr.class ""
, Attr.href "#/completed"
]
[ Html.text "Completed" ]
]
]
, Html.button
[ Attr.class "clear-completed"
, Attr.hidden True
]
[ Html.text "Clear completed (0)" ]
]
]
, Html.footer
[ Attr.class "info"
]
[ Html.p []
[ Html.text "Double-click to edit a todo" ]
, Html.p []
[ Html.text "Written by "
, Html.a
[ Attr.href "https://github.com/dillonkearns"
]
[ Html.text "Dillon Kearns" ]
]
, Html.p []
[ Html.text "Part of "
, Html.a
[ Attr.href "http://todomvc.com"
]
[ Html.text "TodoMVC" ]
]
]
]
]
}
type alias Todo =
{ title : String
, complete : Bool
, id : Int
}
todoItemView : Todo -> Html (Pages.Msg.Msg Msg)
todoItemView todo =
Html.li []
[ Html.div
[ Attr.class "view"
, Pages.Msg.fetcherOnSubmit
]
[ Html.form
[ Attr.method "POST"
]
[ Html.input
[ Attr.class "toggle"
, Attr.type_ "checkbox"
, Attr.checked todo.complete
--, Html.Events.onCheck (\_ -> Pages.Msg.Submit )
]
[]
, Html.label []
[ Html.text todo.title ]
]
, Html.form [ Attr.method "POST", Pages.Msg.fetcherOnSubmit ]
[ Html.button
[ Attr.class "destroy"
]
[]
, Html.input [ Attr.type_ "hidden", Attr.name "deleteId", Attr.value (String.fromInt todo.id) ] []
]
]
, Html.input
[ Attr.class "edit"
, Attr.name "title"
--, Attr.id "todo-0"
]
[]
]

View File

@ -1,80 +1,520 @@
@import url("https://rsms.me/inter/inter.css");
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap");
hr {
margin: 20px 0;
border: 0;
border-top: 1px dashed #c5c5c5;
border-bottom: 1px dashed #f7f7f7;
}
.learn a {
font-weight: normal;
text-decoration: none;
color: #b83f45;
}
.learn a:hover {
text-decoration: underline;
color: #787e7e;
}
.learn h3,
.learn h4,
.learn h5 {
margin: 10px 0;
font-weight: 500;
line-height: 1.2;
color: #000;
}
.learn h3 {
font-size: 24px;
}
.learn h4 {
font-size: 18px;
}
.learn h5 {
margin-bottom: 0;
font-size: 14px;
}
.learn ul {
padding: 0;
margin: 0 0 30px 25px;
}
.learn li {
line-height: 20px;
}
.learn p {
font-size: 15px;
font-weight: 300;
line-height: 1.3;
margin-top: 0;
margin-bottom: 0;
}
#issue-count {
display: none;
}
.quote {
border: none;
margin: 20px 0 60px 0;
}
.quote p {
font-style: italic;
}
.quote p:before {
content: '“';
font-size: 50px;
opacity: .15;
position: absolute;
top: -20px;
left: 3px;
}
.quote p:after {
content: '”';
font-size: 50px;
opacity: .15;
position: absolute;
bottom: -42px;
right: 3px;
}
.quote footer {
position: absolute;
bottom: -40px;
right: 0;
}
.quote footer img {
border-radius: 3px;
}
.quote footer a {
margin-left: 5px;
vertical-align: middle;
}
.speech-bubble {
position: relative;
padding: 10px;
background: rgba(0, 0, 0, .04);
border-radius: 5px;
}
.speech-bubble:after {
content: '';
position: absolute;
top: 100%;
right: 30px;
border: 13px solid transparent;
border-top-color: rgba(0, 0, 0, .04);
}
.learn-bar > .learn {
position: absolute;
width: 272px;
top: 8px;
left: -300px;
padding: 10px;
border-radius: 5px;
background-color: rgba(255, 255, 255, .6);
transition-property: left;
transition-duration: 500ms;
}
@media (min-width: 899px) {
.learn-bar {
width: auto;
padding-left: 300px;
}
.learn-bar > .learn {
left: 8px;
}
}
html,
body {
margin: 0;
padding: 0;
}
button {
margin: 0;
padding: 0;
border: 0;
background: none;
font-size: 100%;
vertical-align: baseline;
font-family: inherit;
font-weight: inherit;
color: inherit;
-webkit-appearance: none;
appearance: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: "Inter var" !important;
font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
line-height: 1.4em;
background: #f5f5f5;
color: #4d4d4d;
min-width: 230px;
max-width: 550px;
margin: 0 auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-weight: 300;
width: auto;
}
:focus {
outline: 0;
}
.hidden {
display: none;
}
.todoapp {
background: #fff;
margin: 130px 0 40px 0;
position: relative;
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
0 25px 50px 0 rgba(0, 0, 0, 0.1);
}
.todoapp input::-webkit-input-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp input::-moz-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp input::input-placeholder {
font-style: italic;
font-weight: 300;
color: #e6e6e6;
}
.todoapp h1 {
position: absolute;
top: -155px;
width: 100%;
font-size: 100px;
font-weight: 100;
text-align: center;
color: rgba(175, 47, 47, 0.15);
-webkit-text-rendering: optimizeLegibility;
-moz-text-rendering: optimizeLegibility;
text-rendering: optimizeLegibility;
}
.new-todo,
.edit {
position: relative;
margin: 0;
width: 100%;
font-size: 24px;
font-family: inherit;
font-weight: inherit;
line-height: 1.4em;
border: 0;
color: inherit;
padding: 6px;
border: 1px solid #999;
box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.new-todo {
padding: 16px 16px 16px 60px;
border: none;
background: rgba(0, 0, 0, 0.003);
box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
}
.main {
position: relative;
z-index: 2;
border-top: 1px solid #e6e6e6;
}
.toggle-all {
text-align: center;
border: none; /* Mobile Safari */
opacity: 0;
position: absolute;
}
.toggle-all + label {
width: 60px;
height: 34px;
font-size: 0;
position: absolute;
top: -52px;
left: -13px;
-webkit-transform: rotate(90deg);
transform: rotate(90deg);
}
.toggle-all + label:before {
content: '';
font-size: 22px;
color: #e6e6e6;
padding: 10px 27px 10px 27px;
}
.toggle-all:checked + label:before {
color: #737373;
}
.todo-list {
margin: 0;
padding: 0;
list-style: none;
}
.todo-list li {
position: relative;
font-size: 24px;
border-bottom: 1px solid #ededed;
}
.todo-list li:last-child {
border-bottom: none;
}
.todo-list li.editing {
border-bottom: none;
padding: 0;
}
.todo-list li.editing .edit {
display: block;
width: 506px;
padding: 12px 16px;
margin: 0 0 0 43px;
}
.todo-list li.editing .view {
display: none;
}
.todo-list li .toggle {
text-align: center;
width: 40px;
/* auto, since non-WebKit browsers doesn't support input styling */
height: auto;
position: absolute;
top: 0;
bottom: 0;
margin: auto 0;
border: none; /* Mobile Safari */
-webkit-appearance: none;
appearance: none;
}
.todo-list li .toggle {
opacity: 0;
}
.todo-list li .toggle + label {
/*
Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
*/
background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
background-repeat: no-repeat;
background-position: center left;
}
.todo-list li .toggle:checked + label {
background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E');
}
.todo-list li label {
word-break: break-all;
padding: 15px 15px 15px 60px;
display: block;
line-height: 1.2;
transition: color 0.4s;
}
.todo-list li.completed label {
color: #d9d9d9;
text-decoration: line-through;
}
.todo-list li .destroy {
display: none;
position: absolute;
top: 0;
right: 10px;
bottom: 0;
width: 40px;
height: 40px;
margin: auto 0;
font-size: 30px;
color: #cc9a9a;
margin-bottom: 11px;
transition: color 0.2s ease-out;
}
.todo-list li .destroy:hover {
color: #af5b5e;
}
.todo-list li .destroy:after {
content: '×';
}
.todo-list li:hover .destroy {
display: block;
}
.todo-list li .edit {
display: none;
}
.todo-list li.editing:last-child {
margin-bottom: -1px;
}
.footer {
color: #777;
padding: 10px 15px;
height: 20px;
text-align: center;
border-top: 1px solid #e6e6e6;
}
.footer:before {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 50px;
overflow: hidden;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
0 8px 0 -3px #f6f6f6,
0 9px 1px -3px rgba(0, 0, 0, 0.2),
0 16px 0 -6px #f6f6f6,
0 17px 2px -6px rgba(0, 0, 0, 0.2);
}
.todo-count {
float: left;
text-align: left;
}
.todo-count strong {
font-weight: 300;
}
.filters {
margin: 0;
padding: 0;
list-style: none;
position: absolute;
right: 0;
left: 0;
}
.filters li {
display: inline;
}
.filters li a {
color: inherit;
margin: 3px;
padding: 3px 7px;
text-decoration: none;
border: 1px solid transparent;
border-radius: 3px;
}
.filters li a:hover {
border-color: rgba(175, 47, 47, 0.1);
}
.filters li a.selected {
border-color: rgba(175, 47, 47, 0.2);
}
.clear-completed,
html .clear-completed:active {
float: right;
position: relative;
line-height: 20px;
text-decoration: none;
cursor: pointer;
}
.clear-completed:hover {
text-decoration: underline;
}
.info {
margin: 65px auto 0;
color: #bfbfbf;
font-size: 10px;
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
text-align: center;
}
.info p {
line-height: 1;
}
.info a {
color: inherit;
text-decoration: none;
font-weight: 400;
}
.info a:hover {
text-decoration: underline;
}
/*
input:invalid {
border: 2px dashed red;
}
input:valid {
border: 2px solid black;
}
Hack to remove background from Mobile Safari.
Can't use it globally since it destroys checkboxes in Firefox
*/
@media screen and (-webkit-min-device-pixel-ratio:0) {
.toggle-all,
.todo-list li .toggle {
background: none;
}
input[type=password], input[type=text], input[type=date], input[type=email] {
border-radius: 10px !important;
border-color: #ccc !important;
.todo-list li .toggle {
height: 40px;
}
}
input[type=checkbox] {
border-radius: 4px !important;
border-color: #ccc !important;
}
@media (max-width: 430px) {
.footer {
height: 50px;
}
main.color-app {
align-items: center;
display: flex;
justify-content: center;
min-height: 100vh;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 18px;
margin: 0;
padding: 0;
}
main.color-app {
background: rgba(0, 0, 0, 0.15);
border-radius: 0.8em;
padding: 0.8em;
}
main.color-app .content {
background: rgba(255, 255, 255, 0.9);
border-radius: 0.5em;
max-width: 90vw;
padding: 2rem 3rem;
width: 475px;
color: var(--selected-color);
}
main.color-app .content h1 {
margin: 0;
}
main.color-app .content ul {
list-style: none;
padding: 0;
}
main.color-app .content li {
margin-left: -1rem;
margin-right: -1rem;
padding: 1rem;
}
main.color-app .content li:nth-child(odd) {
background: rgb(100 10 80 / 0.1);
}
.timestamp {
font-size: 0.75rem;
}
.filters {
bottom: 10px;
}
}