-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathColonoscopy.elm
291 lines (235 loc) · 8.53 KB
/
Colonoscopy.elm
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import Array exposing (..)
import Either exposing (Either)
import Either.Decode exposing (either)
import Html exposing (..)
import Html.Attributes exposing (class, href)
import Html.Events exposing (..)
import Http exposing (get, send)
import Json.Decode exposing (Decoder, array, decodeString, lazy, int, map, oneOf, string)
import Json.Decode.Pipeline exposing (decode, required)
main =
Html.program
{ init = init
, view = view
, update = update
, subscriptions = (\_ -> Sub.none)
}
-- Model
type Model
= Loading
| Loaded (Result Http.Error LoadedModel)
type alias LoadedModel =
{ tree: QuestionTree
, currentNode: Either ItemNumber Question
, nodesHistory: Array (Either ItemNumber Question)
}
pop : Array a -> Array a
pop array =
slice 0 -1 array
getLast : Array a -> Maybe a
getLast array =
Array.get (length array - 1) array
init : (Model, Cmd Msg)
init = (Loading, getJSON)
getJSON : Cmd Msg
getJSON =
let
url = "https://s3-ap-southeast-2.amazonaws.com/static.mbssearch.com/colonoscopy_decision_tree.json"
request =
Http.get url questionTreeDecoder
in
Http.send NewJSON request
-- Update
type Msg
= Load
| NewJSON (Result Http.Error QuestionTree)
| SelectAnswer Int
| GoBack
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Load ->
(Loading, getJSON)
NewJSON (Ok tree) ->
(Loaded (Ok { tree = tree, currentNode = (Either.Right tree.root), nodesHistory = fromList [] }), Cmd.none)
NewJSON (Err error) ->
(Loaded (Err error), Cmd.none)
SelectAnswer index ->
case model of
Loaded (Ok loadedModel) ->
(Loaded (Ok (updateWithSelectedAnswerIndex index loadedModel)), Cmd.none)
_ -> (model, Cmd.none)
GoBack ->
case model of
Loaded (Ok loadedModel) ->
case getLast loadedModel.nodesHistory of
Just previousNode ->
(Loaded (Ok { tree = loadedModel.tree, currentNode = previousNode, nodesHistory = pop loadedModel.nodesHistory }), Cmd.none)
Nothing ->
-- Previous history was empty. This is a case in which we should
-- never end up.
(model, Cmd.none)
_ ->
-- We should never end up in this state, a `GoBack` message should be
-- sent only from the state describe above.
(model, Cmd.none)
updateWithSelectedAnswerIndex : Int -> LoadedModel -> LoadedModel
updateWithSelectedAnswerIndex index model =
case getAnswer index model.currentNode of
-- The given index did not match any of the answer in the current node.
-- This is a case in which we should never end up.
Nothing ->
model
Just nextQuestionOrItem ->
{ tree = model.tree
, currentNode = nextQuestionOrItem
, nodesHistory = append model.nodesHistory (fromList [model.currentNode])
}
getAnswer : Int -> Either ItemNumber Question -> Maybe (Either ItemNumber Question)
getAnswer index node =
case node of
-- If the node is an `ItemNumber` then we are at the end of the tree, and
-- there's nothing left to select. This is a case in which we should never
-- end up.
Either.Left item ->
Nothing
Either.Right question ->
Maybe.map (\a -> a.next) <| get question.answers index
-- View
view : Model -> Html Msg
view model =
case model of
Loading ->
p [] [text "Loading..."]
Loaded (Ok loadedModel) ->
div []
[ h1 [] [text "Colonoscopy"]
, backButtonIfNeeded loadedModel.nodesHistory
, toHTML loadedModel.currentNode
]
Loaded (Err error) ->
div [] [
h3 [] [text "Error"]
, p [] [text <| toString error]
]
backButtonIfNeeded : Array a -> Html Msg
backButtonIfNeeded array =
case getLast array of
Nothing -> span [] []
Just _ ->
a [href "#", onClick GoBack] [text "Back"]
toHTML : Either ItemNumber Question -> Html Msg
toHTML itemOrQuestion =
case itemOrQuestion of
Either.Left itemNumber ->
h2 [] [text <| toString itemNumber.number]
Either.Right question ->
questionToHTML question
questionToHTML : Question -> Html Msg
questionToHTML question =
div []
[ h2 [] [text question.text]
-- Look, I don't know how I feel about leaking CSS in here... Apparently
-- that's what "components" are all about, still.
, ul [class "list-group"] (toList <| indexedMap (\index answer -> li [class "list-group-item"] [a [href "#", onClick
(SelectAnswer index)] [text answer.text]]) question.answers)
]
-- Types
{- The JSON describing the decision tree looks like this:
"root": {
"text": "What did you find at Colonoscopy?",
"answers": [
{
"text": "Polyps",
"next": {
"text": "What was the clinical indication?",
"answers": [
{ "text": "Polyps previously or IBD", "next": { ... } },
...
]
}
},
{ "text": "Failed bowel prep", "next": { "item_number": 32231 } },
...
]
}
The "answers" key of a question can be either an item number or a question
itself. This makes the representation recursive. -}
{- `QuestionTree` is nothing but the container of the questions. I think the
JSON looks neater with a "root" key, but in this domain there doesn't seem to
be a need for this type, other than for mapping the JSON structure.
I'd like to remove it, but I don't yet know how to write a `Decoder` that takes
a JSON with nested objects and returns a decoded object one or more levels
deep. -}
type alias QuestionTree =
{ root: Question }
type alias Question =
{ text : String
, answers: Answers
}
-- The opaque type `Answers` allows us to _hide_ the recursion of nested
-- questions in a question's answer.
-- See https://github.com/elm-lang/elm-compiler/blob/0.18.0/hints/recursive-alias.md
type Answers = Answers (Array Answer)
-- Because `Answers` hides `Array Answer` from the rest of the code, in order to
-- `map` on the list we need implement our own function.
-- See this post for more info on implementing functions for opaque types:
-- https://medium.com/@ghivert/designing-api-in-elm-opaque-types-ce9d5f113033
map : (Answer -> a) -> Answers -> Array a
map f (Answers l) = Array.map f l
indexedMap : (Int -> Answer -> a) -> Answers -> Array a
indexedMap f (Answers l) = Array.indexedMap f l
get : Answers -> Int -> Maybe Answer
get (Answers a) index =
Array.get index a
type alias Answer =
{ text : String
{- Interesting note. After initially failing to compile the decoding with
`Either` I went through the path of defining an `AnswerNext` union type for
this property. I dropped the approach because it resulted in the runtime
error described by these posts:
- https://github.com/elm-lang/core/issues/686
- https://github.com/elm-lang/core/issues/703
- https://github.com/elm-lang/elm-compiler/issues/1591
Evan reckons the issues is fixed in Elm 0.19. Would be interseting to try it out.
Anyway I think the `Either` approach reads better and requires less work, I'd
rather stick with it. -}
, next : Either ItemNumber Question
}
{- As for `QuestionTree` above, the type defined in this way is a bit
redundant. I'd like to remove it, but I don't yet know how to write a `Decoder`
that takes a JSON with nested objects and returns a decoded object one or more
levels deep.
It would have been better to have `ItemNumber` as type alias for `Int`. It
would have made it clear what the numeric value was, without having to drill
into a type with a single property.-}
type alias ItemNumber =
{ number: Int }
-- JSON Decoders
questionTreeDecoder : Decoder QuestionTree
questionTreeDecoder =
decode QuestionTree
|> required "root" question
{- Unlike `questionTreeDecoder`, this and all the other `Decoder`s for the
custom types are simply called like the type they decode. The rationale for
this is to keep them in line with the other `Decoder` functions exposed by
`Json.Decode`, and to make the definition simpler to read.
`questionTreeDecoder` has the `Decoder` prefix because it's used outside the
scope of just decoding. Making the fact that it is a `Decoder` explicit helps
with readbility.
Is this an OK practice? Is the inconsistency in naming worth the readbility
gain? -}
question : Decoder Question
question =
decode Question
|> required "text" string
|> required "answers" (Json.Decode.map Answers (array <| lazy (\_ -> answer)))
answer : Decoder Answer
answer =
decode Answer
|> required "text" string
|> required "next" (lazy (\_ -> either itemNumber question))
itemNumber : Decoder ItemNumber
itemNumber =
decode ItemNumber
|> required "item_number" int