git.iko.soy/typeable/blog-posts-ru
on main help sign in
Add code for reflex-vs-elm post
commitfff27347133fb7e2e1087b0bb1297db838a59fba authorVladimir Kalnitsky <klntsky@typeable.io> dateSat 20 Feb 2021, 17:26 (6 years ago) parentd03fcb2
25 files changed+897
elm-vs-reflex/elm-quiz-demo/.gitignore+6 elm-vs-reflex/elm-quiz-demo/README.md+9 elm-vs-reflex/elm-quiz-demo/elm.json+25 elm-vs-reflex/elm-quiz-demo/index.html+19 elm-vs-reflex/elm-quiz-demo/main.css+82 elm-vs-reflex/elm-quiz-demo/src/Main.elm+197 elm-vs-reflex/reflex-quiz-demo/.gitignore+15 elm-vs-reflex/reflex-quiz-demo/README.md+3 elm-vs-reflex/reflex-quiz-demo/backend/backend.cabal+29 elm-vs-reflex/reflex-quiz-demo/backend/frontendJs/frontend.jsexe+1 elm-vs-reflex/reflex-quiz-demo/backend/src-bin/main.hs+6 elm-vs-reflex/reflex-quiz-demo/backend/src/Backend.hs+10 elm-vs-reflex/reflex-quiz-demo/backend/static+1 elm-vs-reflex/reflex-quiz-demo/cabal.project+3 elm-vs-reflex/reflex-quiz-demo/common/common.cabal+15 elm-vs-reflex/reflex-quiz-demo/common/src/Common/Api.hs+4 elm-vs-reflex/reflex-quiz-demo/common/src/Common/Route.hs+47 elm-vs-reflex/reflex-quiz-demo/config/common/example+1 elm-vs-reflex/reflex-quiz-demo/config/common/route+1 elm-vs-reflex/reflex-quiz-demo/config/readme.md+9 elm-vs-reflex/reflex-quiz-demo/default.nix+23 elm-vs-reflex/reflex-quiz-demo/frontend/frontend.cabal+48 elm-vs-reflex/reflex-quiz-demo/frontend/src-bin/main.hs+10 elm-vs-reflex/reflex-quiz-demo/frontend/src/Frontend.hs+251 elm-vs-reflex/reflex-quiz-demo/static/main.css+82
elm-vs-reflex/elm-quiz-demo/.gitignorenew file+6
@@ -0,0 +1,6 @@
1+# elm-package generated files
2+elm-stuff
3+# elm-repl generated files
4+repl-temp-*
5+# generated file
6+output.js
elm-vs-reflex/elm-quiz-demo/README.mdnew file+9
@@ -0,0 +1,9 @@
1+# Running
2+
3+Install elm and execute the following:
4+
5+```bash
6+elm make src/Main.elm --output output.js
7+```
8+
9+Then open `index.html` in your browser.
elm-vs-reflex/elm-quiz-demo/elm.jsonnew file+25
@@ -0,0 +1,25 @@
1+{
2+ "type": "application",
3+ "source-directories": [
4+ "src"
5+ ],
6+ "elm-version": "0.19.1",
7+ "dependencies": {
8+ "direct": {
9+ "elm/browser": "1.0.2",
10+ "elm/core": "1.0.5",
11+ "elm/html": "1.0.0",
12+ "elm-community/list-extra": "8.2.4"
13+ },
14+ "indirect": {
15+ "elm/json": "1.1.3",
16+ "elm/time": "1.0.0",
17+ "elm/url": "1.0.0",
18+ "elm/virtual-dom": "1.0.2"
19+ }
20+ },
21+ "test-dependencies": {
22+ "direct": {},
23+ "indirect": {}
24+ }
25+}
elm-vs-reflex/elm-quiz-demo/index.htmlnew file+19
@@ -0,0 +1,19 @@
1+<!DOCTYPE html>
2+<html>
3+ <head>
4+ <link rel="stylesheet" type="text/css" href="main.css">
5+ <meta charset="utf-8">
6+ <title>Quiz Demo (Elm)</title>
7+ </head>
8+ <body>
9+ <script src="output.js"></script>
10+ <h1>Quiz demo using <a href="https://elm-lang.org/" target="_blank">Elm</a></h1>
11+ <div id="container"></div>
12+ <script>
13+ var app = Elm.Main.init({
14+ node: document.getElementById('container')
15+ });
16+ </script>
17+ <h3>Demo made by <a href="https://typeable.io/" target="_blank">Typeable</a<</h3>
18+ </body>
19+</html>
elm-vs-reflex/elm-quiz-demo/main.cssnew file+82
@@ -0,0 +1,82 @@
1+body{margin:40px
2+auto;max-width:650px;line-height:1.6;color:#444;padding:0
3+10px}h1,h2,h3{line-height:1.2}
4+
5+button {
6+ padding: 10px;
7+ cursor: pointer;
8+}
9+
10+h1, h3 {
11+ text-align: center;
12+}
13+
14+#container {
15+ width: 800px;
16+ margin: auto;
17+}
18+
19+.answers {
20+ margin-left: 10px;
21+ margin-top: 10px;
22+ margin-bottom: 20px;
23+}
24+
25+.question {
26+ font-size: 20px;
27+}
28+
29+.answer {
30+ font-weight: bold;
31+ cursor: pointer;
32+ border-top: 0.1px dashed rgba(0,0,0,0.15);
33+}
34+
35+.check-answers-button-container {
36+ height: 100px;
37+ width: 100%;
38+}
39+
40+.unfinished-quiz-notice {
41+ font-style: italic;
42+}
43+
44+.answer::before {
45+ content: "☐ ";
46+}
47+
48+.answer.answer-chosen::before {
49+ content: "🗹 ";
50+}
51+
52+.answer-chosen.answer-correct.answer-shown::before {
53+ content: "✓ ";
54+}
55+
56+.answer-chosen.answer-correct.answer-shown::after {
57+ content: " [correct!]";
58+}
59+
60+.answer-chosen.answer-incorrect.answer-shown::after {
61+ content: " [wrong!]";
62+}
63+
64+.answer-chosen.answer-incorrect.answer-shown::before {
65+ content: "✗ ";
66+}
67+
68+.answer-correct.answer-shown {
69+ color: lightgreen;
70+}
71+
72+.answer-incorrect.answer-shown {
73+ color: pink;
74+}
75+
76+.answer-correct.answer-chosen.answer-shown {
77+ color: green;
78+}
79+
80+.answer-incorrect.answer-chosen.answer-shown {
81+ color: red;
82+}
elm-vs-reflex/elm-quiz-demo/src/Main.elmnew file+197
@@ -0,0 +1,197 @@
1+module Main exposing (main)
2+
3+import Browser
4+import Html exposing (Html, button, div, text)
5+import Html.Events exposing (onClick)
6+import List.Extra exposing (updateAt)
7+import List
8+import Tuple
9+import Html.Attributes exposing (style, class)
10+import String
11+
12+-- * Типы данных, описывающие состояние виджета.
13+
14+type alias QuestionText = String
15+
16+type alias AnswerText = String
17+
18+type IsChosen = Chosen | NotChosen
19+
20+type IsCorrect = Correct | Incorrect
21+
22+type CanCheckAnswers = CanCheckAnswers | CantCheckAnswers
23+
24+type AreAnswersShown = AnswersShown | AnswersHidden
25+
26+type alias Answer =
27+ { answerText : AnswerText
28+ , isCorrect : IsCorrect
29+ }
30+
31+type Score
32+ = NoScore
33+ | Score { totalQuestions : Int, correctAnswers : Int }
34+
35+type alias Questions = List (QuestionText, List (Answer, IsChosen))
36+
37+type alias Model
38+ = { areAnswersShown : AreAnswersShown
39+ , allQuestions : Questions
40+ , canCheckAnswers : CanCheckAnswers
41+ , score : Score
42+ }
43+
44+-- ** Тип данных, описывающий payload события (Message).
45+
46+type Msg
47+ = SelectAnswer
48+ { questionNumber : Int
49+ , answerNumber : Int
50+ , isChosen : IsChosen }
51+ | CheckAnswers
52+
53+update : Msg -> Model -> Model
54+update msg = case msg of
55+ SelectAnswer { questionNumber, answerNumber, isChosen } ->
56+ updateCanCheckAnswers <<
57+ ( mapAllQuestions
58+ <| updateAt questionNumber
59+ <| Tuple.mapSecond
60+ <| updateAnswers answerNumber isChosen )
61+ CheckAnswers -> mapAnswersShown (\_ -> AnswersShown) >> updateScore
62+
63+updateCanCheckAnswers : Model -> Model
64+updateCanCheckAnswers model =
65+ { model | canCheckAnswers =
66+ if List.all hasChosenAnswer model.allQuestions
67+ then CanCheckAnswers
68+ else CantCheckAnswers }
69+
70+updateScore : Model -> Model
71+updateScore model =
72+ let
73+ isCorrectAnswer (answer, isChosen) =
74+ case (answer.isCorrect, isChosen) of
75+ (Correct, Chosen) -> True
76+ _ -> False
77+ hasCorrectAnswer (_, answers) =
78+ List.any isCorrectAnswer answers
79+ correctAnswers =
80+ List.length <| List.filter hasCorrectAnswer model.allQuestions
81+ totalQuestions = List.length model.allQuestions
82+ in
83+ { model | score =
84+ Score { correctAnswers = correctAnswers
85+ , totalQuestions = totalQuestions } }
86+
87+hasChosenAnswer : (QuestionText, List (Answer, IsChosen)) -> Bool
88+hasChosenAnswer (_, answers) =
89+ List.any (\(_, isChosen) -> isChosen == Chosen) answers
90+
91+updateAnswers : Int -> IsChosen -> List (Answer, IsChosen) -> List (Answer, IsChosen)
92+updateAnswers answerIx newIsChosen =
93+ List.indexedMap <| \aix ->
94+ Tuple.mapSecond <| \isChosen ->
95+ if aix /= answerIx
96+ then
97+ if newIsChosen == Chosen
98+ then NotChosen
99+ else isChosen
100+ else newIsChosen
101+
102+view : Model -> Html Msg
103+view model =
104+ div [] <|
105+ List.indexedMap (viewQuestion model.areAnswersShown) model.allQuestions ++
106+ [ div [ class "check-answers-button-container" ] [ viewFooter model ] ]
107+
108+viewQuestion : AreAnswersShown -> Int -> (QuestionText, List (Answer, IsChosen)) -> Html Msg
109+viewQuestion areShown questionIx (question, answers) =
110+ div [class "question"] <|
111+ [ text question ] ++
112+ [ div [class "answers"]
113+ <| List.indexedMap (viewAnswer areShown questionIx) answers ]
114+
115+viewAnswer : AreAnswersShown -> Int -> Int -> (Answer, IsChosen) -> Html Msg
116+viewAnswer areShown questionIx answerIx (answer, isChosen) =
117+ let
118+ events = [ onClick <|
119+ SelectAnswer { questionNumber = questionIx
120+ , answerNumber = answerIx
121+ , isChosen = toggleChosen isChosen
122+ } ]
123+ className = String.join " " <|
124+ ["answer"] ++
125+ ( if isChosen == Chosen
126+ then ["answer-chosen"]
127+ else [] ) ++
128+ ( if areShown == AnswersShown
129+ then ["answer-shown"]
130+ else ["answer-hidden"] ) ++
131+ ( if answer.isCorrect == Correct
132+ then ["answer-correct"]
133+ else ["answer-incorrect"] )
134+ attrs = [ class className ]
135+ in
136+ div (attrs ++ events) [ text answer.answerText ]
137+
138+viewFooter : Model -> Html Msg
139+viewFooter model =
140+ case model.score of
141+ NoScore ->
142+ case model.canCheckAnswers of
143+ CanCheckAnswers ->
144+ button [ onClick CheckAnswers ] [ text "Check answers" ]
145+ CantCheckAnswers ->
146+ div [ class "unfinished-quiz-notice" ]
147+ [ text "Select answers for all questions before you can get the results." ]
148+ Score { totalQuestions, correctAnswers } ->
149+ text <|
150+ "Your score: " ++ String.fromInt correctAnswers ++
151+ " of " ++ String.fromInt totalQuestions
152+
153+-- * Вспомогательные функции
154+
155+toggleChosen : IsChosen -> IsChosen
156+toggleChosen isChosen =
157+ case isChosen of
158+ Chosen -> NotChosen
159+ NotChosen -> Chosen
160+
161+mapAllQuestions : (Questions -> Questions) -> Model -> Model
162+mapAllQuestions f model = { model | allQuestions = f model.allQuestions }
163+
164+mapAnswersShown : (AreAnswersShown -> AreAnswersShown) -> Model -> Model
165+mapAnswersShown f model = { model | areAnswersShown = f model.areAnswersShown }
166+
167+mapCanCheckAnswers : (CanCheckAnswers -> CanCheckAnswers) -> Model -> Model
168+mapCanCheckAnswers f model = { model | canCheckAnswers = f model.canCheckAnswers }
169+
170+allQuestions : Questions
171+allQuestions =
172+ [ ( "How many arguments does update function have?"
173+ , [ ({ answerText = "1", isCorrect = Incorrect }, NotChosen)
174+ , ({ answerText = "2", isCorrect = Correct }, NotChosen)
175+ , ({ answerText = "3", isCorrect = Incorrect }, NotChosen)
176+ , ({ answerText = "4", isCorrect = Incorrect }, NotChosen)
177+ ] )
178+ , ( "Which of the following does not contain a value at any point of time?"
179+ , [ ({ answerText = "Behavior", isCorrect = Incorrect }, NotChosen)
180+ , ({ answerText = "Event", isCorrect = Correct }, NotChosen)
181+ , ({ answerText = "Dynamic", isCorrect = Correct }, NotChosen)
182+ ] )
183+ , ( "Which framework does not use Virtual DOM?"
184+ , [ ({ answerText = "Elm", isCorrect = Incorrect }, NotChosen)
185+ , ({ answerText = "Reflex", isCorrect = Correct }, NotChosen)
186+ ] )
187+ ]
188+
189+initialModel =
190+ { areAnswersShown = AnswersHidden
191+ , allQuestions = allQuestions
192+ , score = NoScore
193+ , canCheckAnswers = CantCheckAnswers
194+ }
195+
196+main =
197+ Browser.sandbox { init = initialModel, update = update, view = view }
elm-vs-reflex/reflex-quiz-demo/.gitignorenew file+15
@@ -0,0 +1,15 @@
1+**/dist
2+**/.cabal-sandbox
3+**/cabal.sandbox.config
4+result
5+result-*
6+*.o
7+*.hi
8+TAGS
9+tags
10+ctags
11+dist-newstyle
12+cabal.project.local
13+.ghc.environment.*
14+**/ghcid-output.txt
15+.obelisk
elm-vs-reflex/reflex-quiz-demo/README.mdnew file+3
@@ -0,0 +1,3 @@
1+# Running
2+
3+Install [obelisk framework](https://github.com/obsidiansystems/obelisk) an execute `ob run`
elm-vs-reflex/reflex-quiz-demo/backend/backend.cabalnew file+29
@@ -0,0 +1,29 @@
1+name: backend
2+version: 0.1
3+cabal-version: >= 1.8
4+build-type: Simple
5+
6+library
7+ hs-source-dirs: src
8+ if impl(ghcjs)
9+ buildable: False
10+ build-depends: base
11+ , common
12+ , frontend
13+ , obelisk-backend
14+ , obelisk-route
15+ exposed-modules:
16+ Backend
17+ ghc-options: -Wall -Wredundant-constraints -Wincomplete-uni-patterns -Wincomplete-record-updates -O -fno-show-valid-hole-fits
18+
19+executable backend
20+ main-is: main.hs
21+ hs-source-dirs: src-bin
22+ ghc-options: -Wall -Wredundant-constraints -Wincomplete-uni-patterns -Wincomplete-record-updates -O -threaded -fno-show-valid-hole-fits
23+ if impl(ghcjs)
24+ buildable: False
25+ build-depends: base
26+ , backend
27+ , common
28+ , frontend
29+ , obelisk-backend
elm-vs-reflex/reflex-quiz-demo/backend/frontendJs/frontend.jsexenew file+1
@@ -0,0 +1 @@
1+../../frontend-js/bin/frontend.jsexe
elm-vs-reflex/reflex-quiz-demo/backend/src-bin/main.hsnew file+6
@@ -0,0 +1,6 @@
1+import Backend
2+import Frontend
3+import Obelisk.Backend
4+
5+main :: IO ()
6+main = runBackend backend frontend
elm-vs-reflex/reflex-quiz-demo/backend/src/Backend.hsnew file+10
@@ -0,0 +1,10 @@
1+module Backend where
2+
3+import Common.Route
4+import Obelisk.Backend
5+
6+backend :: Backend BackendRoute FrontendRoute
7+backend = Backend
8+ { _backend_run = \serve -> serve $ const $ return ()
9+ , _backend_routeEncoder = fullRouteEncoder
10+ }
elm-vs-reflex/reflex-quiz-demo/backend/staticnew file+1
@@ -0,0 +1 @@
1+../static
elm-vs-reflex/reflex-quiz-demo/cabal.projectnew file+3
@@ -0,0 +1,3 @@
1+optional-packages:
2+ *
3+write-ghc-environment-files: never
elm-vs-reflex/reflex-quiz-demo/common/common.cabalnew file+15
@@ -0,0 +1,15 @@
1+name: common
2+version: 0.1
3+cabal-version: >= 1.2
4+build-type: Simple
5+
6+library
7+ hs-source-dirs: src
8+ build-depends: base
9+ , obelisk-route
10+ , mtl
11+ , text
12+ exposed-modules:
13+ Common.Api
14+ Common.Route
15+ ghc-options: -Wall -Wredundant-constraints -Wincomplete-uni-patterns -Wincomplete-record-updates -O -fno-show-valid-hole-fits
elm-vs-reflex/reflex-quiz-demo/common/src/Common/Api.hsnew file+4
@@ -0,0 +1,4 @@
1+module Common.Api where
2+
3+commonStuff :: String
4+commonStuff = "Here is a string defined in Common.Api"
elm-vs-reflex/reflex-quiz-demo/common/src/Common/Route.hsnew file+47
@@ -0,0 +1,47 @@
1+{-# LANGUAGE EmptyCase #-}
2+{-# LANGUAGE FlexibleContexts #-}
3+{-# LANGUAGE FlexibleInstances #-}
4+{-# LANGUAGE GADTs #-}
5+{-# LANGUAGE KindSignatures #-}
6+{-# LANGUAGE LambdaCase #-}
7+{-# LANGUAGE MultiParamTypeClasses #-}
8+{-# LANGUAGE OverloadedStrings #-}
9+{-# LANGUAGE RankNTypes #-}
10+{-# LANGUAGE TemplateHaskell #-}
11+{-# LANGUAGE TypeFamilies #-}
12+module Common.Route where
13+
14+{- -- You will probably want these imports for composing Encoders.
15+import Prelude hiding (id, (.))
16+import Control.Category
17+-}
18+
19+import Data.Text (Text)
20+import Data.Functor.Identity
21+
22+import Obelisk.Route
23+import Obelisk.Route.TH
24+
25+data BackendRoute :: * -> * where
26+ -- | Used to handle unparseable routes.
27+ BackendRoute_Missing :: BackendRoute ()
28+ -- You can define any routes that will be handled specially by the backend here.
29+ -- i.e. These do not serve the frontend, but do something different, such as serving static files.
30+
31+data FrontendRoute :: * -> * where
32+ FrontendRoute_Main :: FrontendRoute ()
33+ -- This type is used to define frontend routes, i.e. ones for which the backend will serve the frontend.
34+
35+fullRouteEncoder
36+ :: Encoder (Either Text) Identity (R (FullRoute BackendRoute FrontendRoute)) PageName
37+fullRouteEncoder = mkFullRouteEncoder
38+ (FullRoute_Backend BackendRoute_Missing :/ ())
39+ (\case
40+ BackendRoute_Missing -> PathSegment "missing" $ unitEncoder mempty)
41+ (\case
42+ FrontendRoute_Main -> PathEnd $ unitEncoder mempty)
43+
44+concat <$> mapM deriveRouteComponent
45+ [ ''BackendRoute
46+ , ''FrontendRoute
47+ ]
elm-vs-reflex/reflex-quiz-demo/config/common/examplenew file+1
@@ -0,0 +1 @@
1+This string comes from config/common/example
elm-vs-reflex/reflex-quiz-demo/config/common/routenew file+1
@@ -0,0 +1 @@
1+http://localhost:8000
elm-vs-reflex/reflex-quiz-demo/config/readme.mdnew file+9
@@ -0,0 +1,9 @@
1+### Config
2+
3+Obelisk projects should contain a config folder with the following subfolders: common, frontend, and backend.
4+
5+Things that should never be transmitted to the frontend belong in backend/ (e.g., email credentials)
6+
7+Frontend-only configuration belongs in frontend/.
8+
9+Shared configuration files (e.g., the route config) belong in common/
elm-vs-reflex/reflex-quiz-demo/default.nixnew file+23
@@ -0,0 +1,23 @@
1+{ system ? builtins.currentSystem
2+, obelisk ? import ./.obelisk/impl {
3+ inherit system;
4+ iosSdkVersion = "13.2";
5+
6+ # You must accept the Android Software Development Kit License Agreement at
7+ # https://developer.android.com/studio/terms in order to build Android apps.
8+ # Uncomment and set this to `true` to indicate your acceptance:
9+ # config.android_sdk.accept_license = false;
10+
11+ # In order to use Let's Encrypt for HTTPS deployments you must accept
12+ # their terms of service at https://letsencrypt.org/repository/.
13+ # Uncomment and set this to `true` to indicate your acceptance:
14+ # terms.security.acme.acceptTerms = false;
15+ }
16+}:
17+with obelisk;
18+project ./. ({ ... }: {
19+ android.applicationId = "typeable.examples.quiz";
20+ android.displayName = "Reflex Quiz Demo";
21+ ios.bundleIdentifier = "typeable.examples.quiz";
22+ ios.bundleName = "Reflex Quiz Demo";
23+})
elm-vs-reflex/reflex-quiz-demo/frontend/frontend.cabalnew file+48
@@ -0,0 +1,48 @@
1+name: frontend
2+version: 0.1
3+cabal-version: >= 1.8
4+build-type: Simple
5+
6+library
7+ hs-source-dirs: src
8+ build-depends: base
9+ , common
10+ , obelisk-frontend
11+ , obelisk-route
12+ , jsaddle
13+ , reflex-dom
14+ , obelisk-executable-config-lookup
15+ , obelisk-generated-static
16+ , text
17+ exposed-modules:
18+ Frontend
19+ ghc-options: -Wall -Wredundant-constraints -Wincomplete-uni-patterns -Wincomplete-record-updates -O -fno-show-valid-hole-fits
20+ default-extensions:
21+ BlockArguments
22+ DataKinds
23+ FlexibleContexts
24+ GADTs
25+ LambdaCase
26+ NamedFieldPuns
27+ OverloadedStrings
28+ RecordWildCards
29+ RecursiveDo
30+ TupleSections
31+ TypeApplications
32+
33+executable frontend
34+ main-is: main.hs
35+ hs-source-dirs: src-bin
36+ build-depends: base
37+ , common
38+ , obelisk-frontend
39+ , obelisk-route
40+ , reflex-dom
41+ , obelisk-generated-static
42+ , frontend
43+ ghc-options: -threaded -O -Wall -Wredundant-constraints -Wincomplete-uni-patterns -Wincomplete-record-updates -fno-show-valid-hole-fits
44+ if impl(ghcjs)
45+ ghc-options: -dedupe
46+ cpp-options: -DGHCJS_BROWSER
47+ if os(darwin)
48+ ghc-options: -dynamic
elm-vs-reflex/reflex-quiz-demo/frontend/src-bin/main.hsnew file+10
@@ -0,0 +1,10 @@
1+import Frontend
2+import Common.Route
3+import Obelisk.Frontend
4+import Obelisk.Route.Frontend
5+import Reflex.Dom
6+
7+main :: IO ()
8+main = do
9+ let Right validFullEncoder = checkEncoder fullRouteEncoder
10+ run $ runFrontend validFullEncoder frontend
elm-vs-reflex/reflex-quiz-demo/frontend/src/Frontend.hsnew file+251
@@ -0,0 +1,251 @@
1+module Frontend where
2+
3+import Common.Route
4+import Control.Monad
5+import Control.Monad.Fix
6+import Control.Monad.State
7+import Control.Monad.Writer
8+import Data.Foldable
9+import Data.Functor
10+import qualified Data.Text as T
11+import Data.Traversable
12+import Obelisk.Frontend
13+import Obelisk.Generated.Static
14+import Obelisk.Route
15+import Reflex.Dom.Core
16+
17+-- * Типы данных, описывающие состояние виджета.
18+
19+type QuestionText = T.Text
20+
21+type AnswerText = T.Text
22+
23+data IsChosen = Chosen | NotChosen
24+ deriving (Show, Eq)
25+
26+data IsCorrect = Correct | Incorrect
27+ deriving (Show, Eq)
28+
29+data CanCheckAnswers = CanCheckAnswers | CantCheckAnswers
30+ deriving (Show, Eq)
31+
32+data AreAnswersShown = AnswersShown | AnswersHidden
33+ deriving (Show, Eq)
34+
35+data Answer = Answer
36+ { answerText :: AnswerText
37+ , isCorrect :: IsCorrect }
38+ deriving (Show, Eq)
39+
40+data Score
41+ = NoScore
42+ | Score
43+ { totalQuestions :: Int
44+ , correctAnswers :: Int }
45+ deriving (Eq, Show)
46+
47+-- ** Типы данных, описывающие payload различных событий.
48+
49+-- | Выбор варианта ответа.
50+data SelectAnswer
51+ = SelectAnswer
52+ { questionNumber :: Int
53+ , answerNumber :: Int }
54+
55+-- | Payload для события "показать ответы"
56+data CheckAnswers = CheckAnswers
57+
58+-- ** Типы данных, описывающие внутренние интерфейсы нашего виджета.
59+
60+-- | Тип, содержащий все события, с которыми мы будем работать.
61+data Events t = Events
62+ { selectAnswer :: Event t SelectAnswer
63+ , showAnswers :: Event t CheckAnswers }
64+
65+-- | Тип, содержащий все изменяющиеся (Dynamic) данные.
66+data Model t
67+ = Model
68+ { areAnswersShown :: Dynamic t AreAnswersShown
69+ , allQuestions :: [(QuestionText, [(Answer, Dynamic t IsChosen)])]
70+ , canCheckAnswers :: Dynamic t CanCheckAnswers
71+ , score :: Dynamic t Score }
72+
73+-- * Model
74+
75+-- | Функция, описывающая всю внутреннюю логику работы виджета. Она принимает
76+-- тип данных 'Events', хранящий все события и возвращает динамическую модель.
77+mkModel :: ObeliskWidget js t route m
78+ => [(QuestionText, [Answer])]
79+ -- ^ Список вопросов с ответами
80+ -> Events t
81+ -> m (Model t)
82+mkModel questions evs = do
83+ areAnswersShown <- holdDyn AnswersHidden $ showAnswers evs $> AnswersShown
84+ allQuestions <- do
85+ for (enumerate questions) \(qNum, (questionText, answers)) -> do
86+ (questionText, ) <$> for (enumerate answers) \(aNum, Answer{..}) -> do
87+ let
88+ updChosenState SelectAnswer{questionNumber,answerNumber} isChosen = do
89+ guard (questionNumber == qNum)
90+ return
91+ if answerNumber == aNum
92+ then toggleChosen isChosen
93+ else NotChosen
94+ isChosenDyn <- foldDynMaybe updChosenState NotChosen (selectAnswer evs)
95+ return (Answer{..}, isChosenDyn)
96+ let
97+ canCheckAnswers = do
98+ -- Для каждого вопроса хотя бы один ответ выбран
99+ status <- all (Chosen `elem`) <$> do
100+ for allQuestions \(_, answers) -> do
101+ for answers \(_, dynIsChosen) -> dynIsChosen
102+ return if status then CanCheckAnswers else CantCheckAnswers
103+ score = do
104+ areAnswersShown >>= \case
105+ AnswersHidden -> return NoScore
106+ AnswersShown -> do
107+ correctAnswers <- flip execStateT 0 do
108+ for_ allQuestions \(_, answers) -> do
109+ for_ answers \(answer, dynIsChosen) -> do
110+ isChosen <- lift dynIsChosen
111+ when (isChosen == Chosen && isCorrect answer == Correct) do
112+ modify (+ 1)
113+ return Score {correctAnswers, totalQuestions = length allQuestions}
114+ return Model{..}
115+
116+-- * Интерфейс пользователя
117+
118+quizUI :: ObeliskWidget js t route m => Model t -> m (Events t)
119+quizUI Model{..} = wrapUI do
120+ selectAnswer <- leftmost <$> for (enumerate allQuestions)
121+ \(qNum, (questionText, answers)) -> do
122+ divClass "question" do
123+ text questionText
124+ answersUI qNum areAnswersShown answers
125+ showAnswers <- footerUI canCheckAnswers score
126+ return Events{..}
127+
128+answersUI :: ObeliskWidget js t route m
129+ => Int
130+ -> Dynamic t AreAnswersShown
131+ -> [(Answer, Dynamic t IsChosen)]
132+ -> m (Event t SelectAnswer)
133+answersUI qNum areAnswersShown answers = do
134+ leftmost <$> elClass "div" "answers" do
135+ for (enumerate answers)
136+ \(aNum, (Answer{answerText,isCorrect}, dynIsChosen)) -> do
137+ let
138+ dynAttrs = do
139+ isChosen <- dynIsChosen
140+ areShown <- areAnswersShown
141+ let
142+ className = T.intercalate " " $ execWriter do
143+ tell ["answer"]
144+ when (isChosen == Chosen) $ tell ["answer-chosen"]
145+ tell [ if areShown == AnswersShown
146+ then "answer-shown"
147+ else "answer-hidden" ]
148+ tell [ if isCorrect == Correct
149+ then "answer-correct"
150+ else "answer-incorrect" ]
151+ return $ "class" =: className
152+ event <- domEvent Click . fst <$> elDynAttr' "div" dynAttrs do
153+ text answerText
154+ return $ event $>
155+ SelectAnswer { questionNumber = qNum, answerNumber = aNum }
156+
157+-- | Виджет, содержащий либо текст с предложением ответить на все вопросы, либо
158+-- кнопку проверки ответов, либо информацию о результатах.
159+footerUI :: ObeliskWidget js t route m
160+ => Dynamic t CanCheckAnswers -> Dynamic t Score -> m (Event t CheckAnswers)
161+footerUI canCheckAnswersDyn dynScore = wrapContainer do
162+ evt <- switchHold never <=< dyn $ do
163+ canCheckAnswers <- canCheckAnswersDyn
164+ score <- dynScore
165+ return if score /= NoScore
166+ then return never
167+ else canCheckAnswers & \case
168+ CanCheckAnswers -> do
169+ domEvent Click . fst <$> do
170+ el' "button" do
171+ text "Check answers"
172+ CantCheckAnswers -> do
173+ divClass "unfinished-quiz-notice" do
174+ text "Select answers for all questions before you can get the results."
175+ return never
176+ dyn_ $ dynScore <&> \case
177+ NoScore -> blank
178+ Score{totalQuestions, correctAnswers} -> do
179+ text "Your score: "
180+ text . T.pack $ show correctAnswers
181+ text " of "
182+ text . T.pack $ show totalQuestions
183+ return $ evt $> CheckAnswers
184+ where wrapContainer = divClass "check-answers-button-container"
185+
186+wrapUI :: ObeliskWidget js t route m => m a -> m a
187+wrapUI contents = do
188+ el "h1" do
189+ text "Quiz Demo using "
190+ mkLink "Reflex framework" "https://reflex-frp.org/"
191+ result <- elAttr "div" ("id" =: "container") contents
192+ el "h3" do
193+ text "Demo made by "
194+ mkLink "Typeable" "https://typeable.io/"
195+ return result
196+ where
197+ mkLink linkText url = do
198+ elAttr "a" ("href" =: url <> "target" =: "blank") do
199+ text linkText
200+
201+-- * Main widget
202+
203+questions :: [(QuestionText, [Answer])]
204+questions =
205+ [ ( "How many arguments does update function have?"
206+ , [ Answer "1" Incorrect
207+ , Answer "2" Correct
208+ , Answer "3" Incorrect
209+ , Answer "4" Incorrect
210+ ] )
211+ , ( "Which of the following does not contain a value at any point of time?"
212+ , [ Answer "Behaviour" Incorrect
213+ , Answer "Event" Correct
214+ , Answer "Dynamic" Incorrect
215+ ] )
216+ , ( "Which framework does not use Virtual DOM?"
217+ , [ Answer "Elm" Incorrect
218+ , Answer "Reflex" Correct
219+ ] )
220+ ]
221+
222+mkQuizWidget :: ObeliskWidget js t route m => [(QuestionText, [Answer])] -> m ()
223+mkQuizWidget qs =
224+ mkWidget (mkModel qs) quizUI
225+
226+-- * Вспомогательные функции.
227+
228+mkWidget :: ObeliskWidget js t route m
229+ => (events -> m model) -> (model -> m events) -> m ()
230+mkWidget model ui = void $ mfix (model >=> ui)
231+
232+enumerate :: [a] -> [(Int, a)]
233+enumerate = zip [0..]
234+
235+toggleChosen :: IsChosen -> IsChosen
236+toggleChosen Chosen = NotChosen
237+toggleChosen NotChosen = Chosen
238+
239+frontend :: Frontend (R FrontendRoute)
240+frontend = Frontend
241+ { _frontend_head = do
242+ el "title" $ text "Quiz Demo (Reflex)"
243+ elAttr "link"
244+ ( "href" =: static @"main.css" <>
245+ "type" =: "text/css" <>
246+ "rel" =: "stylesheet" ) blank
247+ , _frontend_body = do
248+ el "div" do
249+ mkQuizWidget questions
250+ return ()
251+ }
elm-vs-reflex/reflex-quiz-demo/static/main.cssnew file+82
@@ -0,0 +1,82 @@
1+body{margin:40px
2+auto;max-width:650px;line-height:1.6;color:#444;padding:0
3+10px}h1,h2,h3{line-height:1.2}
4+
5+button {
6+ padding: 10px;
7+ cursor: pointer;
8+}
9+
10+h1, h3 {
11+ text-align: center;
12+}
13+
14+#container {
15+ width: 800px;
16+ margin: auto;
17+}
18+
19+.answers {
20+ margin-left: 10px;
21+ margin-top: 10px;
22+ margin-bottom: 20px;
23+}
24+
25+.question {
26+ font-size: 20px;
27+}
28+
29+.answer {
30+ font-weight: bold;
31+ cursor: pointer;
32+ border-top: 0.1px dashed rgba(0,0,0,0.15);
33+}
34+
35+.check-answers-button-container {
36+ height: 100px;
37+ width: 100%;
38+}
39+
40+.unfinished-quiz-notice {
41+ font-style: italic;
42+}
43+
44+.answer::before {
45+ content: "☐ ";
46+}
47+
48+.answer.answer-chosen::before {
49+ content: "🗹 ";
50+}
51+
52+.answer-chosen.answer-correct.answer-shown::before {
53+ content: "✓ ";
54+}
55+
56+.answer-chosen.answer-correct.answer-shown::after {
57+ content: " [correct!]";
58+}
59+
60+.answer-chosen.answer-incorrect.answer-shown::after {
61+ content: " [wrong!]";
62+}
63+
64+.answer-chosen.answer-incorrect.answer-shown::before {
65+ content: "✗ ";
66+}
67+
68+.answer-correct.answer-shown {
69+ color: lightgreen;
70+}
71+
72+.answer-incorrect.answer-shown {
73+ color: pink;
74+}
75+
76+.answer-correct.answer-chosen.answer-shown {
77+ color: green;
78+}
79+
80+.answer-incorrect.answer-chosen.answer-shown {
81+ color: red;
82+}