all repos

onasty @ cb40cf6937d81fe9c72342d74b5521d233e578b0

a one-time notes service

onasty/web/src/Effect.elm (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat(web): add dashboard (#214)..., 8 months ago
1
module Effect exposing
2
    ( Effect, none, batch, map, toCmd, sendCmd, sendMsg
3
    , pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back
4
    , sendApiRequest, sendToClipboard, confirmRequest
5
    , signin, logout, refreshTokens, saveUser, clearUser
6
    )
7
8
{-|
9
10
@docs Effect, none, batch, map, toCmd, sendCmd, sendMsg
11
@docs pushRoute, replaceRoute, pushRoutePath, replaceRoutePath, loadExternalUrl, back
12
@docs sendApiRequest, sendToClipboard, confirmRequest
13
@docs signin, logout, refreshTokens, saveUser, clearUser
14
15
-}
16
17
import Api
18
import Auth.User
19
import Browser.Navigation
20
import Data.Credentials exposing (Credentials)
21
import Data.Error
22
import Dict exposing (Dict)
23
import Http
24
import Json.Decode
25
import Json.Encode
26
import Ports
27
import Route
28
import Route.Path
29
import Shared.Model
30
import Shared.Msg
31
import Task
32
import Url exposing (Url)
33
34
35
type Effect msg
36
    = -- BASICS
37
      None
38
    | Batch (List (Effect msg))
39
    | SendCmd (Cmd msg)
40
      -- ROUTING
41
    | PushUrl String
42
    | ReplaceUrl String
43
    | LoadExternalUrl String
44
    | Back
45
      -- SHARED
46
    | SendSharedMsg Shared.Msg.Msg
47
    | SendToLocalStorage { key : String, value : Json.Encode.Value }
48
    | SendConfirmRequest String
49
    | SendToClipboard String
50
    | SendApiRequest
51
        { endpoint : String
52
        , method : String
53
        , body : Http.Body
54
        , decoder : Json.Decode.Decoder msg
55
        , onHttpError : Api.Error -> msg
56
        }
57
58
59
60
-- BASICS
61
62
63
{-| Don't send any effect.
64
-}
65
none : Effect msg
66
none =
67
    None
68
69
70
{-| Send multiple effects at once.
71
-}
72
batch : List (Effect msg) -> Effect msg
73
batch =
74
    Batch
75
76
77
{-| Send a normal `Cmd msg` as an effect, something like `Http.get` or `Random.generate`.
78
-}
79
sendCmd : Cmd msg -> Effect msg
80
sendCmd =
81
    SendCmd
82
83
84
{-| Send a message as an effect. Useful when emitting events from UI components.
85
-}
86
sendMsg : msg -> Effect msg
87
sendMsg msg =
88
    Task.succeed msg
89
        |> Task.perform identity
90
        |> SendCmd
91
92
93
94
-- ROUTING
95
96
97
{-| Set the new route, and make the back button go back to the current route.
98
-}
99
pushRoute :
100
    { path : Route.Path.Path
101
    , query : Dict String String
102
    , hash : Maybe String
103
    }
104
    -> Effect msg
105
pushRoute route =
106
    PushUrl (Route.toString route)
107
108
109
{-| Same as `Effect.pushRoute`, but without `query` or `hash` support
110
-}
111
pushRoutePath : Route.Path.Path -> Effect msg
112
pushRoutePath path =
113
    PushUrl (Route.Path.toString path)
114
115
116
{-| Set the new route, but replace the previous one, so clicking the back
117
button **won't** go back to the previous route.
118
-}
119
replaceRoute :
120
    { path : Route.Path.Path
121
    , query : Dict String String
122
    , hash : Maybe String
123
    }
124
    -> Effect msg
125
replaceRoute route =
126
    ReplaceUrl (Route.toString route)
127
128
129
{-| Same as `Effect.replaceRoute`, but without `query` or `hash` support
130
-}
131
replaceRoutePath : Route.Path.Path -> Effect msg
132
replaceRoutePath path =
133
    ReplaceUrl (Route.Path.toString path)
134
135
136
{-| Redirect users to a new URL, somewhere external to your web application.
137
-}
138
loadExternalUrl : String -> Effect msg
139
loadExternalUrl =
140
    LoadExternalUrl
141
142
143
{-| Navigate back one page
144
-}
145
back : Effect msg
146
back =
147
    Back
148
149
150
151
-- SHARED
152
153
154
sendApiRequest :
155
    { endpoint : String
156
    , method : String
157
    , body : Http.Body
158
    , decoder : Json.Decode.Decoder value
159
    , onResponse : Result Api.Error value -> msg
160
    }
161
    -> Effect msg
162
sendApiRequest opts =
163
    SendApiRequest
164
        { endpoint = opts.endpoint
165
        , method = opts.method
166
        , body = opts.body
167
        , onHttpError = \e -> opts.onResponse (Err e)
168
        , decoder =
169
            opts.decoder
170
                |> Json.Decode.map Ok
171
                |> Json.Decode.map opts.onResponse
172
        }
173
174
175
sendToClipboard : String -> Effect msg
176
sendToClipboard text =
177
    SendToClipboard text
178
179
180
confirmRequest : String -> Effect msg
181
confirmRequest msg =
182
    SendConfirmRequest msg
183
184
185
refreshTokens : Effect msg
186
refreshTokens =
187
    SendSharedMsg Shared.Msg.TriggerTokenRefresh
188
189
190
signin : Credentials -> Effect msg
191
signin credentials =
192
    SendSharedMsg (Shared.Msg.SignedIn credentials)
193
194
195
logout : Effect msg
196
logout =
197
    SendSharedMsg Shared.Msg.Logout
198
199
200
saveUser : String -> String -> Effect msg
201
saveUser accessToken refreshToken =
202
    batch
203
        [ SendToLocalStorage { key = "access_token", value = Json.Encode.string accessToken }
204
        , SendToLocalStorage { key = "refresh_token", value = Json.Encode.string refreshToken }
205
        ]
206
207
208
clearUser : Effect msg
209
clearUser =
210
    batch
211
        [ SendToLocalStorage { key = "access_token", value = Json.Encode.null }
212
        , SendToLocalStorage { key = "refresh_token", value = Json.Encode.null }
213
        ]
214
215
216
217
-- INTERNALS
218
219
220
map : (msg1 -> msg2) -> Effect msg1 -> Effect msg2
221
map fn effect =
222
    case effect of
223
        None ->
224
            None
225
226
        Batch list ->
227
            Batch (List.map (map fn) list)
228
229
        SendCmd cmd ->
230
            SendCmd (Cmd.map fn cmd)
231
232
        PushUrl url ->
233
            PushUrl url
234
235
        ReplaceUrl url ->
236
            ReplaceUrl url
237
238
        Back ->
239
            Back
240
241
        LoadExternalUrl url ->
242
            LoadExternalUrl url
243
244
        SendSharedMsg sharedMsg ->
245
            SendSharedMsg sharedMsg
246
247
        SendToLocalStorage options ->
248
            SendToLocalStorage options
249
250
        SendToClipboard text ->
251
            SendToClipboard text
252
253
        SendConfirmRequest msg ->
254
            SendConfirmRequest msg
255
256
        SendApiRequest opts ->
257
            SendApiRequest
258
                { endpoint = opts.endpoint
259
                , method = opts.method
260
                , body = opts.body
261
                , decoder = Json.Decode.map fn opts.decoder
262
                , onHttpError = \err -> fn (opts.onHttpError err)
263
                }
264
265
266
toCmd :
267
    { key : Browser.Navigation.Key
268
    , url : Url
269
    , shared : Shared.Model.Model
270
    , fromSharedMsg : Shared.Msg.Msg -> msg
271
    , batch : List msg -> msg
272
    , toCmd : msg -> Cmd msg
273
    }
274
    -> Effect msg
275
    -> Cmd msg
276
toCmd options effect =
277
    case effect of
278
        None ->
279
            Cmd.none
280
281
        Batch list ->
282
            Cmd.batch (List.map (toCmd options) list)
283
284
        SendCmd cmd ->
285
            cmd
286
287
        PushUrl url ->
288
            Browser.Navigation.pushUrl options.key url
289
290
        ReplaceUrl url ->
291
            Browser.Navigation.replaceUrl options.key url
292
293
        Back ->
294
            Browser.Navigation.back options.key 1
295
296
        LoadExternalUrl url ->
297
            Browser.Navigation.load url
298
299
        SendSharedMsg sharedMsg ->
300
            Task.succeed sharedMsg
301
                |> Task.perform options.fromSharedMsg
302
303
        SendToLocalStorage opts ->
304
            Ports.sendToLocalStorage opts
305
306
        SendToClipboard text ->
307
            Ports.sendToClipboard text
308
309
        SendConfirmRequest msg ->
310
            Ports.confirmRequest msg
311
312
        SendApiRequest opts ->
313
            let
314
                headers : List Http.Header
315
                headers =
316
                    case options.shared.user of
317
                        Auth.User.SignedIn cred ->
318
                            if not (String.contains opts.endpoint "refresh-tokens") then
319
                                [ Http.header "Authorization" ("Bearer " ++ cred.accessToken) ]
320
321
                            else
322
                                []
323
324
                        _ ->
325
                            []
326
            in
327
            Http.request
328
                { method = opts.method
329
                , url = opts.endpoint
330
                , headers = headers
331
                , body = opts.body
332
                , expect =
333
                    Http.expectStringResponse
334
                        (\httpResult ->
335
                            case httpResult of
336
                                Ok msg ->
337
                                    msg
338
339
                                Err err ->
340
                                    opts.onHttpError err
341
                        )
342
                        (\resp -> httpResponseToCustomError opts.decoder resp)
343
                , timeout = Just (1000 * 60) -- 60 second timeout
344
                , tracker = Nothing
345
                }
346
347
348
httpResponseToCustomError : Json.Decode.Decoder msg -> Http.Response String -> Result Api.Error msg
349
httpResponseToCustomError decoder response =
350
    case response of
351
        Http.GoodStatus_ _ body ->
352
            case
353
                Json.Decode.decodeString decoder
354
                    (if String.isEmpty body then
355
                        "\"\""
356
357
                     else
358
                        body
359
                    )
360
            of
361
                Ok value ->
362
                    Ok value
363
364
                Err err ->
365
                    Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err })
366
367
        Http.BadStatus_ { statusCode } body ->
368
            if String.isEmpty body then
369
                Err (Api.HttpError { message = "Unexpected empty response", reason = Http.BadStatus statusCode })
370
371
            else
372
                case Json.Decode.decodeString Data.Error.decode body of
373
                    Ok err ->
374
                        Err (Api.HttpError { message = err.message, reason = Http.BadStatus statusCode })
375
376
                    Err err ->
377
                        Err (Api.JsonDecodeError { message = "Failed to decode response", reason = err })
378
379
        Http.BadUrl_ url ->
380
            Err (Api.HttpError { message = "Unexpected URL format", reason = Http.BadUrl url })
381
382
        Http.Timeout_ ->
383
            Err (Api.HttpError { message = "Request timed out, please try again", reason = Http.Timeout })
384
385
        Http.NetworkError_ ->
386
            Err (Api.HttpError { message = "Could not connect, please try again", reason = Http.NetworkError })