billstclair / elm-localstorage / PortFunnel.LocalStorage.Sequence

Make it easier to create complex state machines from LocalStorage contents.

This is too complicated for an example here, but see ZapMeme.Sequencer for a real-life example.

This does not support simulated LocalStorage. Certainly possible, but it was already plenty complicated without that.

Since an instance of LocalStorageStates is stored in your Model, and it contains two functions which take a model as argument, there needs to be a custom class to prevent infinite type recursion:

type WrappedModel =
    WrappedModel Model

type alias Model =
    { localStorageStates : LocalStorageStates WrappedModel Msg
    , ...
    }

type Msg =
    SequenceDone (WrappedModel -> ( WrappedModel, Cmd Msg ))
    | ProcessLocalStorage Value
    ...

Types


type alias Wrappers key state model msg =
{ sender : PortFunnel.LocalStorage.Message -> Platform.Cmd.Cmd msg
, injector : Injector msg
, localStorageStates : model -> LocalStorageStates key state model msg
, setLocalStorageStates : LocalStorageStates key state model msg -> model -> model
, sequenceDone : (model -> ( model
, Platform.Cmd.Cmd msg )) -> msg
, nullState : NullState key state model msg 
}

Communication between Sequence and your main Model and Msg.

sender is how you send messages out your command port. It's usually defined in your main program, with something like:

{-| The `model` parameter is necessary here for `PortFunnels.makeFunnelDict`.
-}
getCmdPort : String -> model -> (Value -> Cmd Msg)
getCmdPort moduleName _ =
    PortFunnels.getCmdPort ProcessLocalStorage moduleName False

localStorageSend : LocalStorage.Message -> Cmd Msg
localStorageSend message =
    LocalStorage.send (getCmdPort LocalStorage.moduleName ())
        message
        funnelState.storage

localStorageStates and setLocalStorageStates are functions that read/write a LocalStorageStates from/to your Model.

sequenceDone creates a Msg that calls its arg with the Model, and expects back the standard update result. E.g.:

type Msg
   = SequenceDone (WrappedModel -> (WrappedModel, Cmd Msg))
   ...

update model msg =
   SequenceDone updater ->
        let
            ( WrappedModel mdl, cmd ) =
                updater (WrappedModel model)
        in
        ( mdl, cmd )
   ...

Make a NullState with makeNullState.


type LocalStorageStates key state model msg

Opaque type used to store a table of state machines.

Create one with makeLocalStorageStates. Store it in your Model.


type alias State key state model msg =
{ state : state
, label : String
, process : Wrappers key state model msg -> DbResponse -> state -> ( DbRequest msg
, state ) 
}

Holds the state, label, and LocalStorage subscription port processing function for one state machine.

You will get very confused if you use the same label for two different State values passed to makeLocalStorageStates.

See the definition of initialStorageStates in ZapMeme.Sequencer.


type alias KeyPair =
{ prefix : String
, subkey : String 
}

These are converted to and from the strings sent as LocalStorage keys by encodePair and decodePair.


type DbRequest msg
    = DbNothing
    | DbGet KeyPair
    | DbPut KeyPair Json.Decode.Value
    | DbRemove KeyPair
    | DbListKeys KeyPair
    | DbClear KeyPair
    | DbCustomRequest (Platform.Cmd.Cmd msg)
    | DbBatch (List (DbRequest msg))

A high-level representation of a LocalStorage operation.

DbNothing does nothing.

DbCustomRequest allows you to send some command other than a message out the LocalStorage cmdPort.

DbBatch allows you to gang more than one request.

The others have obvious correlation to the LocalStorage messages, but DbPut and DbRemove are decoupled, instead of using a Maybe Value.


type DbResponse
    = DbNoResponse
    | DbGot KeyPair (Maybe Json.Decode.Value)
    | DbKeys KeyPair (List KeyPair)

A high-level represenation of a message returned from LocalStorage through the subscription port.

Since this module doesn't support simulated storage, only DbGot in response to DbGet and DbKeys in response to DbListKeys need to be distinguished.

The other messages are all turned into DbNoResponse, but there wont be any in a non-simulated environment.

Constructors

makeLocalStorageStates : Wrappers key state model msg -> List ( key, State key state model msg ) -> LocalStorageStates key state model msg

Create a LocalStorageStates to store in your Model.

The key type is usually a simple enumerator custom type. You could use strings, but then the compiler won't catch typos for you.

The state type is usually a custom type with one tag per key value.

the model here is usually WrappedModel in your code, to prevent type recursion through wrappers.localStorageStates and wrappers.setLocalStorageStates.

makeNullState : state -> NullState key state model msg

This is how you create a NullState for your Wrappers.

The state you pass will never be used for anything.

Processing incoming messages

You will usually use only update, but the others let you do lower-level processing yourself.

update : Wrappers key state model msg -> model -> PortFunnel.LocalStorage.Response -> ( model, Platform.Cmd.Cmd msg )

Modulo wrapping and unwrapping, designed to be the entire handler for incoming LocalStorage responses.

funnelDict : FunnelDict Model Msg
funnelDict =
    PortFunnels.makeFunnelDict
        [ LocalStorageHandler wrappedStorageHandler ]
        getCmdPort

wrappedStorageHandler : LocalStorage.Response -> PortFunnels.State -> Model -> ( Model, Cmd Msg )
wrappedStorageHandler response _ model =
    let
        ( WrappedModel mdl, cmd ) =
            Sequence.storageHandler
                sequenceWrappers
                (WrappedModel model)
                response
    in
    ( mdl, cmd )

processStates : PortFunnel.LocalStorage.Response -> LocalStorageStates key state model msg -> ( LocalStorageStates key state model msg, Platform.Cmd.Cmd msg )

Calls process on each of the States registered in the LocalStorageStates.

Accumulates the results, but in practice, only one of them will do anything other than note that the LocalStorage.Response's label doesn't match, and return the model unchanged with Cmd.none.

You will usually call this via update, not directly.

process : Wrappers key state model msg -> PortFunnel.LocalStorage.Response -> State key state model msg -> Maybe ( State key state model msg, Platform.Cmd.Cmd msg )

Process a response for a single State.

If the response's label matches the State's, calls state.process, updates the user state inside the State, and turns the returned DbRequest into a Cmd msg (usually by calling send). Otherwise, returns Nothing.

You will usually call this indirectly, via update.

LocalStorage interaction.

send : Wrappers key state model msg -> State key state model msg -> DbRequest msg -> Platform.Cmd.Cmd msg

Turn a DbRequest into a Cmd msg

that sends a LocalStorage.Message out to the JavaScript through the Cmd port.

Uses the sender in Wrappers and label in State.

inject : Wrappers key state model msg -> State key state model msg -> DbResponse -> Platform.Cmd.Cmd msg

Call injectTask, and use Task.perform to turn that Task into a Cmd.

The (Value -> msg) function will usually be the Msg that receives subscription input from your LocalStorage port.

injectTask : Wrappers key state model msg -> State key state model msg -> DbResponse -> Task Basics.Never Json.Decode.Value

If you receive something outside of a LocalStorage return, and want to get it back into your state machine, use this.

It returns a Task that, if you send it with your LocalStorage sub port message, will make it seem as if the given DbResponse was received from LocalStorage.

It's a trivial task wrapper on the result of dbResponseToValue, using label property of the State.

State accessors

getState : Wrappers key state model msg -> key -> model -> Maybe state

Look up the State associated with key in model's LocalStorageStates

(with getFullState). If found, return itsstate field.

getFullState : Wrappers key state model msg -> key -> model -> Maybe (State key state model msg)

Look up the State associated with key in model's LocalStorageStates

setState : Wrappers key state model msg -> key -> state -> model -> ( model, State key state model msg )

Update one State inside the LocalStorageStates inside of the model.

Returns the updated model and State. This version is usually used when you start up a state machine, since you need the State to call send.

Use setStateOnly if you need only the updated model.

setStateOnly : Wrappers key state model msg -> key -> state -> model -> model

Update one State inside the LocalStorageStates inside of the model.

Return the updated model.

Use setState if you also need the updated State (or call getFullState).

Utilities

decodeExpectedDbGot : Json.Decode.Decoder value -> String -> DbResponse -> Maybe ( KeyPair, Maybe value )

Eliminate error checking boilerplate inside State process functions.

decodeExpectedDbGot decoder expectedSubkey response

If response is not a DbGot, returns Nothing.

If expectedSubkey is not "", and also not the subkey of that DbGot's KeyPair, returns Just (KeyPair, Nothing).

If the DbGot's value is Nothing, returns Just (KeyPair, Nothing).

Otherwise, uses decoder to decode that value. If the result is an Err, returns Just (KeyPair, Nothing).

If all goes well, returns Just (KeyPair, value).

dbResponseToValue : String -> String -> DbResponse -> Json.Decode.Value

Turn a DbResponse into the Value that would create it,

if received from the LocalStorage port code, in response to a DbGet or DbListKeys request.

NoResponse is treated as DbGot with empty string components in its KeyPair and Nothing as its value. You will likely never want to do that.

decodePair : String -> KeyPair

Turn a string that comes back from the subscription port into a KeyPair.

Splits it on the first ".".

encodePair : KeyPair -> String

Turn a KeyPair into a string.

That string is used as the Key arg to LocalStorage.getLabeled or the Prefix arg to LocalStorage.listKeysLabeled.