|
module URLCoders exposing (CustomTypeRouter, Remote, Router, RoutingError, application, bimap, bimap2, buildCustom, custom, customRouter, default, incidental, int, partial, pushUrlOnChange, string, variant0, variant1, variant2) |
|
|
|
{-| |
|
|
|
@docs CustomTypeRouter, Remote, Router, RoutingError, application, bimap, bimap2, buildCustom, custom, customRouter, default, incidental, int, partial, pushUrlOnChange, string, variant0, variant1, variant2 |
|
|
|
-} |
|
|
|
import Browser |
|
import Browser.Navigation |
|
import Dict exposing (Dict) |
|
import Html |
|
import Platform exposing (Task) |
|
import Task |
|
import Url exposing (Url) |
|
|
|
|
|
|
|
-- Router API |
|
|
|
|
|
{-| TODO: Make a richer error type |
|
-} |
|
type RoutingError |
|
= RoutingError |
|
|
|
|
|
{-| URLCoder or Router? Not sure about the terminology yet, but this is the main type here |
|
-} |
|
type alias Router flags model = |
|
{ encode : model -> Encoding |
|
, decode : Parser flags model |
|
} |
|
|
|
|
|
type alias Parser flags a = |
|
State flags -> Task RoutingError ( State flags, a ) |
|
|
|
|
|
type alias State flags = |
|
{ pathSegments : List String |
|
, queryParams : Dict String (List String) |
|
, flags : flags |
|
, focus : Focus |
|
} |
|
|
|
|
|
type Encoded |
|
= None |
|
| Value String |
|
| Concatenation (List Encoding) |
|
|
|
|
|
type alias Encoding = |
|
{ encoded : Encoded |
|
, push : Bool |
|
} |
|
|
|
|
|
type Focus |
|
= Path |
|
| Query |
|
| QueryKey String |
|
|
|
|
|
mkEncoding : Encoded -> Encoding |
|
mkEncoding enc = |
|
{ encoded = enc, push = False } |
|
|
|
|
|
{-| This is a coder that deals with a piece of model state that simply has no place in the URL. |
|
|
|
It allows you to read it in from flags or run an effect to fetch it. |
|
|
|
Examples: current user, current time, device class |
|
|
|
-} |
|
incidental : (flags -> Task RoutingError model) -> Router flags model |
|
incidental initializer = |
|
{ encode = always (mkEncoding None) |
|
, decode = \state -> Task.map (\val -> ( state, val )) (initializer state.flags) |
|
} |
|
|
|
|
|
{-| Like incidental, but a for a value that is OK to be some static default value. |
|
|
|
Examples: isAnimating, dragState |
|
|
|
-} |
|
default : a -> Router flags a |
|
default val = |
|
incidental (always (Task.succeed val)) |
|
|
|
|
|
{-| A piece in the model where we want to encode only some small part (like an ID), that |
|
we can then restore the full value from. |
|
|
|
Examples: Any thing that we want to fetch from the server. |
|
|
|
-} |
|
partial : (model -> a) -> (flags -> a -> Task RoutingError model) -> Router flags a -> Router flags model |
|
partial get build subRouter = |
|
{ encode = get >> subRouter.encode |
|
, decode = |
|
\state -> |
|
subRouter.decode state |
|
|> Task.andThen |
|
(\( newState, value ) -> |
|
Task.map (\val -> ( newState, val )) (build newState.flags value) |
|
) |
|
} |
|
|
|
|
|
{-| Like map, but you have to provide functions that go both ways. |
|
-} |
|
bimap : (b -> a) -> (a -> b) -> Router flags a -> Router flags b |
|
bimap fn2 fn1 router = |
|
{ encode = \model -> router.encode (fn2 model) |
|
, decode = |
|
\state -> |
|
router.decode state |
|
|> Task.map (Tuple.mapSecond fn1) |
|
} |
|
|
|
|
|
bimap2 : (model -> a) -> (model -> b) -> (a -> b -> model) -> Router flags a -> Router flags b -> Router flags model |
|
bimap2 fst sec compose a b = |
|
{ encode = |
|
\model -> |
|
mkEncoding (Concatenation [ a.encode (fst model), b.encode (sec model) ]) |
|
, decode = |
|
\state -> |
|
a.decode state |
|
|> Task.andThen |
|
(\( newState, aVal ) -> |
|
Task.map (\( newerState, bVal ) -> ( newerState, compose aVal bVal )) (b.decode newState) |
|
) |
|
} |
|
|
|
|
|
{-| Build a router for a custom thing. |
|
|
|
Not sure this is safe at the moment... |
|
|
|
-} |
|
customRouter : (a -> String) -> (String -> Maybe a) -> Router flags a |
|
customRouter encoder parser = |
|
{ encode = |
|
\v -> |
|
mkEncoding (Value (encoder v)) |
|
, decode = |
|
\state -> |
|
case state.focus of |
|
Path -> |
|
case state.pathSegments of |
|
[] -> |
|
Task.fail RoutingError |
|
|
|
next :: rest -> |
|
case parser next of |
|
Just res -> |
|
Task.succeed ( { state | pathSegments = rest }, res ) |
|
|
|
Nothing -> |
|
Task.fail RoutingError |
|
|
|
Query -> |
|
Task.fail RoutingError |
|
|
|
QueryKey key -> |
|
case Dict.get key state.queryParams of |
|
Just (val :: other) -> |
|
case parser val of |
|
Just res -> |
|
Task.succeed ( { state | queryParams = Dict.insert key other state.queryParams }, res ) |
|
|
|
Nothing -> |
|
Task.fail RoutingError |
|
|
|
_ -> |
|
Task.fail RoutingError |
|
} |
|
|
|
|
|
{-| -} |
|
int : Router flags Int |
|
int = |
|
customRouter String.fromInt String.toInt |
|
|
|
|
|
{-| -} |
|
string : Router flags String |
|
string = |
|
customRouter identity Just |
|
|
|
|
|
type CustomTypeRouter flags match value |
|
= CustomTypeRouter |
|
{ match : match |
|
, decode : Dict String (Parser flags value) |
|
} |
|
|
|
|
|
{-| Build a custom type encoder. See elm-json-codec for docs. |
|
-} |
|
custom : match -> CustomTypeRouter flags match value |
|
custom match = |
|
CustomTypeRouter { match = match, decode = Dict.empty } |
|
|
|
|
|
variant : String -> ((List Encoding -> Encoding) -> a) -> Parser flags v -> CustomTypeRouter flags (a -> b) v -> CustomTypeRouter flags b v |
|
variant name matchPiece decoderPiece (CustomTypeRouter am) = |
|
let |
|
enc v = |
|
mkEncoding (Concatenation (mkEncoding (Value name) :: v)) |
|
in |
|
CustomTypeRouter |
|
{ match = am.match <| matchPiece enc |
|
, decode = Dict.insert name decoderPiece am.decode |
|
} |
|
|
|
|
|
variant0 : String -> v -> CustomTypeRouter flags (Encoding -> a) v -> CustomTypeRouter flags a v |
|
variant0 name ctor = |
|
variant name (\c -> c []) (\state -> Task.succeed ( state, ctor )) |
|
|
|
|
|
variant1 : String -> (a -> v) -> Router flags a -> CustomTypeRouter flags ((a -> Encoding) -> b) v -> CustomTypeRouter flags b v |
|
variant1 name ctor m1 = |
|
variant name |
|
(\c v -> c [ m1.encode v ]) |
|
(\state -> |
|
m1.decode state |
|
|> Task.map (Tuple.mapSecond ctor) |
|
) |
|
|
|
|
|
variant2 : String -> (a -> b -> v) -> Router flags a -> Router flags b -> CustomTypeRouter flags ((a -> b -> Encoding) -> x) v -> CustomTypeRouter flags x v |
|
variant2 name ctor m1 m2 = |
|
variant name |
|
(\c v1 v2 -> c [ m1.encode v1, m2.encode v2 ]) |
|
(\state -> |
|
m1.decode state |
|
|> Task.andThen |
|
(\( newState, aVal ) -> |
|
Task.map (\( newerState, bVal ) -> ( newerState, ctor aVal bVal )) (m2.decode newState) |
|
) |
|
) |
|
|
|
|
|
buildCustom : CustomTypeRouter flags (a -> Encoding) a -> Router flags a |
|
buildCustom (CustomTypeRouter am) = |
|
{ encode = \v -> am.match v |
|
, decode = |
|
\state -> |
|
string.decode state |
|
|> Task.andThen |
|
(\( newState, tag ) -> |
|
case Dict.get tag am.decode of |
|
Nothing -> |
|
Task.fail RoutingError |
|
|
|
Just dec -> |
|
dec newState |
|
) |
|
} |
|
|
|
|
|
{-| Codecs will normally call replaceUrl for you. You can change a Router to call pushState when the piece it's managing changes. |
|
-} |
|
pushUrlOnChange : Router flags model -> Router flags model |
|
pushUrlOnChange router = |
|
{ router |
|
| encode = |
|
\v -> |
|
let |
|
encoded = |
|
router.encode v |
|
in |
|
{ encoded | push = True } |
|
} |
|
|
|
|
|
|
|
-- Runtime |
|
|
|
|
|
type Remote model |
|
= Loading |
|
| Loaded model |
|
| Error |
|
|
|
|
|
type alias Model model flags = |
|
{ key : Browser.Navigation.Key |
|
, url : String |
|
, model : Remote model |
|
, processingUrlChange : Bool |
|
, flags : flags |
|
} |
|
|
|
|
|
type URLAction |
|
= Same |
|
| Replace |
|
| Push |
|
|
|
|
|
diffEncoding : Encoding -> Encoding -> URLAction |
|
diffEncoding old new = |
|
case ( old.encoded, new.encoded ) of |
|
( None, None ) -> |
|
Same |
|
|
|
( Value a, Value b ) -> |
|
if a == b then |
|
Same |
|
|
|
else |
|
toAction new |
|
|
|
( Concatenation aList, Concatenation bList ) -> |
|
if List.length aList == List.length bList then |
|
List.map2 diffEncoding aList bList |
|
|> List.foldr concatUrlAction Same |
|
|
|
else |
|
toAction new |
|
|
|
_ -> |
|
toAction new |
|
|
|
|
|
concatUrlAction : URLAction -> URLAction -> URLAction |
|
concatUrlAction a b = |
|
case a of |
|
Push -> |
|
Push |
|
|
|
Replace -> |
|
if b == Push then |
|
Push |
|
|
|
else |
|
Replace |
|
|
|
Same -> |
|
b |
|
|
|
|
|
toAction : { a | push : Bool } -> URLAction |
|
toAction { push } = |
|
if push then |
|
Push |
|
|
|
else |
|
Replace |
|
|
|
|
|
encodingToStr : Encoding -> String |
|
encodingToStr { encoded } = |
|
case encoded of |
|
None -> |
|
"" |
|
|
|
Value str -> |
|
Url.percentEncode str |
|
|
|
Concatenation segments -> |
|
String.join "/" (List.map encodingToStr segments) |
|
|
|
|
|
decodeUrl : Router flags model -> flags -> Url.Url -> Task RoutingError model |
|
decodeUrl { decode } flags url = |
|
decode |
|
{ pathSegments = |
|
case String.split "/" url.path of |
|
"" :: segments -> |
|
removeFinalEmpty segments |
|
|
|
segments -> |
|
removeFinalEmpty segments |
|
, queryParams = |
|
case url.query of |
|
Nothing -> |
|
Dict.empty |
|
|
|
Just qry -> |
|
List.foldr addParam Dict.empty (String.split "&" qry) |
|
, flags = flags |
|
, focus = Path |
|
} |
|
|> Task.map Tuple.second |
|
|
|
|
|
removeFinalEmpty : List String -> List String |
|
removeFinalEmpty segments = |
|
case segments of |
|
[] -> |
|
[] |
|
|
|
"" :: [] -> |
|
[] |
|
|
|
segment :: rest -> |
|
segment :: removeFinalEmpty rest |
|
|
|
|
|
addParam : String -> Dict String (List String) -> Dict String (List String) |
|
addParam segment dict = |
|
case String.split "=" segment of |
|
[ rawKey, rawValue ] -> |
|
case Url.percentDecode rawKey of |
|
Nothing -> |
|
dict |
|
|
|
Just key -> |
|
case Url.percentDecode rawValue of |
|
Nothing -> |
|
dict |
|
|
|
Just value -> |
|
Dict.update key (addToParametersHelp value) dict |
|
|
|
_ -> |
|
dict |
|
|
|
|
|
addToParametersHelp : a -> Maybe (List a) -> Maybe (List a) |
|
addToParametersHelp value maybeList = |
|
case maybeList of |
|
Nothing -> |
|
Just [ value ] |
|
|
|
Just list -> |
|
Just (value :: list) |
|
|
|
|
|
{-| The entrypoint. Notice how straightforward this is compared to Browswer.application. |
|
-} |
|
application : |
|
{ view : model -> Browser.Document msg |
|
, update : msg -> model -> ( model, Cmd msg ) |
|
, subscriptions : model -> Sub msg |
|
, router : Router flags model |
|
} |
|
-> Program flags (Model model flags) (Msg msg model) |
|
application config = |
|
Browser.application |
|
{ init = init config.router |
|
, view = view config.view |
|
, update = update config.update config.router |
|
, subscriptions = subscriptions config.subscriptions |
|
, onUrlRequest = \urlRequest -> loadURL urlRequest |
|
, onUrlChange = OnUrlChange |
|
} |
|
|
|
|
|
init coder flags url key = |
|
( { key = key |
|
, url = Url.toString url |
|
, model = Loading |
|
, processingUrlChange = False |
|
, flags = flags |
|
} |
|
, decodeUrl coder flags url |
|
|> Task.attempt LoadingComplete |
|
) |
|
|
|
|
|
view userView model = |
|
case model.model of |
|
Loading -> |
|
{ title = "", body = [ Html.text "Loading..." ] } |
|
|
|
Error -> |
|
{ title = "", body = [ Html.text "Something went wrong..." ] } |
|
|
|
Loaded m -> |
|
let |
|
res = |
|
userView m |
|
in |
|
{ title = res.title, body = List.map (Html.map UserMsg) res.body } |
|
|
|
|
|
loadURL : Browser.UrlRequest -> Msg msg model |
|
loadURL = |
|
LoadRequest |
|
|
|
|
|
type Msg msg model |
|
= UserMsg msg |
|
| LoadRequest Browser.UrlRequest |
|
| LoadingComplete (Result RoutingError model) |
|
| OnUrlChange Url |
|
|
|
|
|
update : (msg -> model -> ( model, Cmd msg )) -> Router flags model -> Msg msg model -> Model model flags -> ( Model model flags, Cmd (Msg msg model) ) |
|
update userUpdate coder msg model = |
|
case msg of |
|
LoadingComplete (Ok newModel) -> |
|
( { model | model = Loaded newModel }, Cmd.none ) |
|
|
|
LoadingComplete (Err _) -> |
|
( { model | model = Error }, Cmd.none ) |
|
|
|
LoadRequest urlRequest -> |
|
case urlRequest of |
|
Browser.Internal url -> |
|
let |
|
_ = |
|
Debug.log "internal" url |
|
in |
|
( model, Cmd.none ) |
|
|
|
Browser.External str -> |
|
( model, Browser.Navigation.load str ) |
|
|
|
OnUrlChange url -> |
|
if model.processingUrlChange then |
|
( { model | processingUrlChange = False }, Cmd.none ) |
|
|
|
else |
|
init coder model.flags url model.key |
|
|
|
UserMsg userMsg -> |
|
case model.model of |
|
Loaded userModel -> |
|
let |
|
( newUserModel, cmd ) = |
|
userUpdate userMsg userModel |
|
|
|
newEncoding = |
|
coder.encode newUserModel |
|
|
|
( processing, urlCommand ) = |
|
case diffEncoding (coder.encode userModel) newEncoding of |
|
Same -> |
|
( False, Cmd.none ) |
|
|
|
Replace -> |
|
( True, Browser.Navigation.replaceUrl model.key ("/" ++ encodingToStr newEncoding |> Debug.log "replace") ) |
|
|
|
Push -> |
|
( True, Browser.Navigation.pushUrl model.key ("/" ++ encodingToStr newEncoding |> Debug.log "push") ) |
|
in |
|
( { model | model = Loaded newUserModel, processingUrlChange = processing }, Cmd.batch [ Cmd.map UserMsg cmd, urlCommand ] ) |
|
|
|
Loading -> |
|
Debug.todo """ |
|
we need to process returning msg from commands here in order to construct |
|
the initial model here, however, we need to do it without the model. |
|
|
|
The ideal option here is that initialization is just a `Task Error Model`, |
|
which would work for: |
|
|
|
- HTTP |
|
- Viewport |
|
- Time |
|
|
|
But would not work for: |
|
|
|
- Ports |
|
- Random |
|
- Complex HTTP |
|
|
|
I think the killer here really is ports. But can ports even be used for "automatic" initialization? |
|
Surely, that would require request/response semantics... |
|
|
|
I'll currently go with a task based approach, but if you need something to do with request/response ports, |
|
then checkout brian-watkins/elm-procedure for an approach of having a seperate hidden model to manage |
|
series of commands, tasks and subscriptions in transparent form. |
|
|
|
""" |
|
|
|
_ -> |
|
Debug.todo "I think this should be impossible... Probably we're ok to ignore?" |
|
|
|
|
|
subscriptions : (model -> Sub msg) -> Model model flags -> Sub (Msg msg model) |
|
subscriptions userSubscriptions model = |
|
case model.model of |
|
Loaded userModel -> |
|
Sub.map UserMsg (userSubscriptions userModel) |
|
|
|
_ -> |
|
Sub.none |