all repos

onasty @ eb4c605

a one-time notes service

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

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