Functional component model.

This commit is contained in:
Søren Debois 2016-04-07 09:52:11 +02:00
parent dfda38d0c1
commit 1fdcc78ba7
6 changed files with 249 additions and 192 deletions

View file

@ -15,7 +15,7 @@ import Material.Button as Button
type alias Model = type alias Model =
{ count : Int { count : Int
, mdl : Material.Model Action , mdl : Material.Model Action
-- Boilerplate: Model store for any and all MDL components you need. -- Boilerplate: mdl is the Model store for any and all MDL components you need.
} }
@ -24,7 +24,7 @@ model : Model
model = model =
{ count = 0 { count = 0
, mdl = Material.model , mdl = Material.model
-- Always use this initial MDL component model store. -- Boilerplate: Always use this initial MDL model store.
} }
@ -36,7 +36,6 @@ type Action
| Reset | Reset
| MDL (Material.Action Action) | MDL (Material.Action Action)
-- Boilerplate: Action for MDL actions (ripple animations etc.). -- Boilerplate: Action for MDL actions (ripple animations etc.).
-- It should always look like this.
update : Action -> Model -> (Model, Effects.Effects Action) update : Action -> Model -> (Model, Effects.Effects Action)
@ -83,8 +82,7 @@ button. The arguments are:
-} -}
increase : Button.Instance Mdl Action increase : Button.Instance Mdl Action
increase = increase =
Button.instance 0 MDL Button.instance 0 MDL Button.flat (Button.model True)
Button.flat (Button.model True)
[ Button.fwdClick Increase ] [ Button.fwdClick Increase ]
@ -93,8 +91,7 @@ click event to our Reset action.
-} -}
reset : Button.Instance Mdl Action reset : Button.Instance Mdl Action
reset = reset =
Button.instance 1 MDL Button.instance 1 MDL Button.flat (Button.model False)
Button.flat (Button.model False)
[ Button.fwdClick Reset ] [ Button.fwdClick Reset ]

View file

@ -20,7 +20,8 @@ import Demo.Page as Page
-- MODEL -- MODEL
type alias Mdl = Material.Model Action type alias Mdl =
Material.Model Action
type alias Model = type alias Model =
@ -48,22 +49,8 @@ type Action
| MDL (Material.Action Action) | MDL (Material.Action Action)
snackbar : Int -> Snackbar.Contents Action add : Model -> (Int -> Snackbar.Contents Action) -> (Model, Effects Action)
snackbar k = add model f =
Snackbar.snackbar
("Snackbar message #" ++ toString k)
"UNDO"
(Undo k)
toast : Int -> Snackbar.Contents Action
toast k =
Snackbar.toast
<| "Toast message #" ++ toString k
add : (Int -> Snackbar.Contents Action) -> Model -> (Model, Effects Action)
add f model =
let let
(mdl', fx) = (mdl', fx) =
Snackbar.add (f model.count) snackbarComponent model.mdl Snackbar.add (f model.count) snackbarComponent model.mdl
@ -81,10 +68,12 @@ update : Action -> Model -> (Model, Effects Action)
update action model = update action model =
case action of case action of
AddSnackbar -> AddSnackbar ->
add snackbar model add model
<| \k -> Snackbar.snackbar ("Snackbar message #" ++ toString k) "UNDO" (Undo k)
AddToast -> AddToast ->
add toast model add model
<| \k -> Snackbar.toast <| "Toast message #" ++ toString k
Undo k -> Undo k ->
({ model ({ model
@ -96,18 +85,19 @@ update action model =
Material.update MDL action' model.mdl Material.update MDL action' model.mdl
|> map1st (\m -> { model | mdl = m }) |> map1st (\m -> { model | mdl = m })
-- VIEW -- VIEW
addSnackbar : Button.Instance Mdl Action addSnackbarButton : Button.Instance Mdl Action
addSnackbar = addSnackbarButton =
Button.instance 0 MDL Button.instance 0 MDL
Button.raised (Button.model True) Button.raised (Button.model True)
[ Button.fwdClick AddSnackbar ] [ Button.fwdClick AddSnackbar ]
addToast : Button.Instance Mdl Action addToastButton : Button.Instance Mdl Action
addToast = addToastButton =
Button.instance 1 MDL Button.instance 1 MDL
Button.raised (Button.model True) Button.raised (Button.model True)
[ Button.fwdClick AddToast ] [ Button.fwdClick AddToast ]
@ -115,8 +105,7 @@ addToast =
snackbarComponent : Snackbar.Instance Mdl Action snackbarComponent : Snackbar.Instance Mdl Action
snackbarComponent = snackbarComponent =
Snackbar.instance 2 MDL Snackbar.model Snackbar.instance MDL Snackbar.model
clickView : Model -> Int -> Html clickView : Model -> Int -> Html
@ -164,11 +153,11 @@ view addr model =
-- to add css/classes to top-level element of components (div -- to add css/classes to top-level element of components (div
-- in grid, button in button, div in textfield etc.) -- in grid, button in button, div in textfield etc.)
[ cell [ size All 2, size Phone 2, align Top ] [ cell [ size All 2, size Phone 2, align Top ]
[ addToast.view addr model.mdl [] [ text "Toast" ] [ addToastButton.view addr model.mdl [] [ text "Toast" ]
] ]
, cell , cell
[ size All 2, size Phone 2, align Top ] [ size All 2, size Phone 2, align Top ]
[ addSnackbar.view addr model.mdl [] [ text "Snackbar" ] [ addSnackbarButton.view addr model.mdl [] [ text "Snackbar" ]
] ]
, cell , cell
[ size Desktop 7, size Tablet 3, size Phone 12, align Top ] [ size Desktop 7, size Tablet 3, size Phone 12, align Top ]

View file

@ -15,27 +15,28 @@ for a live demo.
# Component model # Component model
The component model of the library is simply the Elm Architecture, e.g., The component model of the library is simply the Elm Architecture (TEA), i.e.,
each component has Model, Action, view, and update. A minimal example using each component has types `Model` and `Action`, and values `view` and `update`. A
this library in plain Elm Architecture can be found minimal example using this library in plain TEA can be found
[here](https://github.com/debois/elm-mdl/blob/master/examples/Component-EA.elm). [here](https://github.com/debois/elm-mdl/blob/master/examples/Component-TEA.elm).
Nesting large amounts of components in the Elm Architecture is somewhat Using more than a few component in plain TEA is unwieldy because of the large
unwieldy because of the large amount of boilerplate one has to write. This amount of boilerplate one has to write. This library provides the "component
library includes "component support", for getting rid of most of that support" for getting rid of most of that boilerplate. A minimal example using
boilerplate. A minimal example using component support is component support is
[here](http://github.com/debois/elm-mdl/blob/master/examples/Component.elm). [here](http://github.com/debois/elm-mdl/blob/master/examples/Component.elm).
It is important to note that component support lives __within__ the Elm It is important to note that component support lives __within__ TEA;
architecture; it is not an alternative architecture. it is not an alternative architecture.
# Getting started # Getting started
The easiest way to get started is to start with one of the minimal examples above. The easiest way to get started is to start with one of the minimal examples above.
We recommend going with the library's component support rather than working We recommend going with the library's
directly in plain Elm Architecture. [component support](http://github.com/debois/elm-mdl/blob/master/examples/Component.elm)
rather than working directly in plain Elm Architecture.
# This module # Component Support
This module contains only convenience functions for working with nested This module contains only convenience functions for working with nested
components in the Elm architecture. A minimal example using this library components in the Elm architecture. A minimal example using this library
@ -49,6 +50,7 @@ All examples in this subsection is from the
Here is how you use component support in general. First, boilerplate. Here is how you use component support in general. First, boilerplate.
1. Include `Material`: 1. Include `Material`:
`import Material` `import Material`
2. Add a model container Material components to your model: 2. Add a model container Material components to your model:
@ -80,7 +82,6 @@ Here is how you use component support in general. First, boilerplate.
in in
( { model | mdl = mdl' } , fx ) ( { model | mdl = mdl' } , fx )
Next, make the component instances you need. Do this in the View section of your Next, make the component instances you need. Do this in the View section of your
source file. Let's say you need a textfield for name entry, and you'd like to source file. Let's say you need a textfield for name entry, and you'd like to
be notifed whenever the field changes value through your own NameChanged action: be notifed whenever the field changes value through your own NameChanged action:
@ -106,8 +107,8 @@ be notifed whenever the field changes value through your own NameChanged action:
nameInput : Textfield.Instance Material.Model Action nameInput : Textfield.Instance Material.Model Action
nameInput = nameInput =
Textfield.instance 2 MDL Textfield.model Textfield.instance 2 MDL Textfield.model
[ Textfield.fwdInput NameChanged ] [ Textfield.fwdInput NameChanged
]
view addr model = view addr model =
... ...
@ -125,13 +126,13 @@ but now it's not boilerplate, its "business logic".)
Using this module will force all elm-mdl components to be built and included in Using this module will force all elm-mdl components to be built and included in
your application. If this is unacceptable, you can custom-build a version of this your application. If this is unacceptable, you can custom-build a version of this
module that uses only the components you need. To do so, you need to re-implement module that uses only the components you need. To do so, you need to re-implement
the present module, modifying the values `model` and `Model`. The module source the present module, modifying the values `model` and `Model` by commenting out the
can be found components you are not using. The module source can be found
[here](https://github.com/debois/elm-mdl/blob/master/src/Material.elm). [here](https://github.com/debois/elm-mdl/blob/master/src/Material.elm).
You do not need to re-build the entire elm-mdl library; simply copy the You do not need to re-build the entire elm-mdl library; simply copy the
source of this module, give it a new name, modify as itMatendicated above, then use source of this module, give it a new name, modify as it as indicated above,
your modified module rather than this one. then use your modified module rather than this one.
@docs Model, model, Action, update @docs Model, model, Action, update
-} -}
@ -145,37 +146,39 @@ import Material.Snackbar as Snackbar
import Material.Component as Component exposing (Indexed) import Material.Component as Component exposing (Indexed)
{-| Model encompassing all Material components. {-| Model encompassing all Material components. Since some components store
user actions in their model (notably Snackbar), the model is generic in the
type of such "observations".
-} -}
type alias Model a = type alias Model obs =
{ button : Indexed Button.Model { button : Indexed Button.Model
, textfield : Indexed Textfield.Model , textfield : Indexed Textfield.Model
, snackbar : Indexed (Snackbar.Model a) , snackbar : Maybe (Snackbar.Model obs)
} }
{-| Initial model. {-| Initial model.
-} -}
model : Model a model : Model obs
model = model =
{ button = Dict.empty { button = Dict.empty
, textfield = Dict.empty , textfield = Dict.empty
, snackbar = Dict.empty , snackbar = Nothing
} }
{-| Action encompassing actions of all Material components. {-| Action encompassing actions of all Material components.
-} -}
type alias Action action = type alias Action obs =
Component.Action (Model action) action Component.Action (Model obs) obs
{-| Update function for the above Action. {-| Update function for the above Action.
-} -}
update : update :
(Action action -> action) (Action obs -> obs)
-> Action action -> Action obs
-> Model action -> Model obs
-> (Model action, Effects action) -> (Model obs, Effects obs)
update = update =
Component.update Component.update

View file

@ -1,16 +1,15 @@
module Material.Component module Material.Component
( Embedding, Observer ( embed, embedIndexed, Embedding, Observer
, Indexed , Indexed
, Instance, instance , Instance, instance, instance1
, update , update
, Action , Action
) where ) where
{-| {-|
The Elm Architecture is conceptually very nice, but it forces us The Elm Architecture is conceptually very nice, but it forces us to write large
to write large amounts of boilerplate whenever we need to use a "component". amounts of boilerplate whenever we need to use a "component". We must:
We must:
1. Retain the state of the component in our Model 1. Retain the state of the component in our Model
2. Add the components actions to our Action 2. Add the components actions to our Action
@ -28,35 +27,41 @@ produce `instance` functions; if you are using elm-mdl (and are not interested i
optimising for compiled program size), you should ignore this module and look optimising for compiled program size), you should ignore this module and look
instead at `Material`. instead at `Material`.
# Component types
@docs Indexed, Embedding, Observer, Instance # Embeddings
@docs Indexed, Embedding, embed, embedIndexed
# Instance construction # Instance construction
@docs instance @docs Action, Instance, Observer, instance, instance1
# Instance consumption # Instance consumption
@docs update, Action @docs update
-} -}
import Effects exposing (Effects) import Effects exposing (Effects)
import Task
import Dict exposing (Dict) import Dict exposing (Dict)
import Material.Helpers exposing (map1, map2, map1st, map2nd, Update, Update') import Material.Helpers exposing (map1, map2, map1st, map2nd, Update, Update')
-- TYPES -- TYPES
{-| Standard EA view function type. {-| Standard EA view function type.
-} -}
type alias View model action a = type alias View model action a =
Signal.Address action -> model -> a Signal.Address action -> model -> a
-- EMBEDDING MODELS -- EMBEDDING MODELS
{-| Indexed families of things. {-| Indexed families of things.
-} -}
type alias Indexed a = type alias Indexed a =
@ -65,7 +70,7 @@ type alias Indexed a =
{-| An __embedding__ of an Elm Architecture component is a variant in which {-| An __embedding__ of an Elm Architecture component is a variant in which
view and update functions know how to extract and update their model view and update functions know how to extract and update their model
from a larger container model. from a larger master model.
-} -}
type alias Embedding model container action a = type alias Embedding model container action a =
{ view : View container action a { view : View container action a
@ -78,6 +83,14 @@ type alias Embedding model container action a =
{-| Embed a component. Third and fourth arguments are a getter (extract the {-| Embed a component. Third and fourth arguments are a getter (extract the
local model from the container) and a setter (update local model in the local model from the container) and a setter (update local model in the
container). container).
It is instructive to compare the types of the view and update function in
the input and output:
{- Input -} {- Output -}
View model action a View container action a
Update model action Update container action
-} -}
embed : embed :
View model action a -> -- Given a view function, View model action a -> -- Given a view function,
@ -98,11 +111,9 @@ embed view update get set =
} }
{-| We are interested in particular embeddings where components of the same type {-| We are interested in particular embeddings where components of the same
all have their state living inside a shared `Dict`; the individual component type all have their state living inside a shared `Dict`; the individual
has an id used for looking up its own state. Its the responsibility of the user component has a key used to look up its own state.
to make
sure that ids are unique.
-} -}
embedIndexed : embedIndexed :
View model action a -> -- Given a view function, View model action a -> -- Given a view function,
@ -128,37 +139,90 @@ embedIndexed view update get set model0 id =
-- LIFTING ACTIONS -- LIFTING ACTIONS
{-| Generic MDL Action.
{-| Similarly to how embeddings enable collecting models of different type
in a single model container, we need to collect actions in a single "master
action" type. Obviously, actions need to be eventually executed by running
the corresponding update function. To avoid this master action type explicitly
representing the Action/update pairs of elm-mdl components, we represent an
action of an individual component as a partially applied update function; that
is, a function `container -> container`. E.g., the `Click` action of Button is
conceptually represented as:
embeddedButton : Embedding Button.Model container action ...
embeddedButton =
embedIndexed
Button.view Button.update .button {\m x -> {m|button=x} Button.model 0
clickAction : container -> container
clickAction = embeddedButton.update Button.click
When all Material components are embedded in the same `container` model, we
then have a uniform update mechanism.
We lost the ability to inspect the action when we did this, though. To be
able to react to some actions of a component, we add to our `container ->
container` type for actions a potential __observation__ of type `obs`.
In practice, this observation type `obs` will be the Action of the TEA
component __hosting__ MDL components.
Altogether, accounting also for effects, we arrive at the following type.
-} -}
type Action model obs = type Action container obs =
A (model -> (model, Effects (Action model obs), Maybe obs)) A (container -> (container, Effects (Action container obs), Maybe obs))
{-| Type of observers, i.e., functions that take an actual action of the
underlying TEA component to an observation. E.g., Button has an Observer for
its `Click` action.
-}
type alias Observer action obs =
action -> Maybe obs
{-| Generic update function for Action. {-| Generic update function for Action.
-} -}
update : update :
(Action state action -> action) -> (Action container obs -> obs) ->
Update' state (Action state action) action Update' container (Action container obs) obs
update fwd (A f) state = update fwd (A f) container =
let let
(state', fx, obs) = (container', fx, obs) =
f state f container
|> map2 (Effects.map fwd) |> map2 (Effects.map fwd)
in in
case obs of case obs of
Nothing -> Nothing ->
(state', fx) (container', fx)
Just x -> Just x ->
(state', Effects.batch [ fx, Effects.tick (always x) ]) (container', Effects.batch [ fx, Effects.task (Task.succeed x) ])
-- INSTANCES -- INSTANCES
{- EA update function variant where running the function
{-| Type of component instances. A component instance contains a view,
get/set/map for the inner model, and a forwarder lifting component
actions to observations.
-}
type alias Instance model container action obs a =
{ view : View container obs a
, get : container -> model
, set : model -> container -> container
, map : (model -> model) -> container -> container
, fwd : action -> obs
}
{- TEA update function variant where running the function
produces not just a new model and an effect, but also an produces not just a new model and an effect, but also an
observation. observation.
-} -}
@ -166,34 +230,13 @@ type alias Step model action obs =
action -> model -> (model, Effects action, Maybe obs) action -> model -> (model, Effects action, Maybe obs)
{- Partially apply a step function to an action, producing a generic Action.
{-| Type of component instances. A component instance contains a view,
and get/set/map for, well, getting, setting, and mapping the component
model.
-}
type alias Instance submodel model subaction action a =
{ view : View model action a
, get : model -> submodel
, set : submodel -> model -> model
, map : (submodel -> submodel) -> model -> model
, fwd : subaction -> action
}
{- Partially apply a step function to an action,
producing a generic Action.
-} -}
pack : (Step model action obs) -> action -> Action model obs pack : (Step model action obs) -> action -> Action model obs
pack update action = pack update action =
A (update action >> map2 (Effects.map (pack update))) A (update action >> map2 (Effects.map (pack update)))
{-| Type of observers.
-}
type alias Observer action obs =
action -> Maybe obs
{- Convert an update function to a step function by applying a {- Convert an update function to a step function by applying a
function that converts the action input to the update function into function that converts the action input to the update function into
an observation. an observation.
@ -216,20 +259,22 @@ pick f xs =
x -> x x -> x
connect : List (Observer subaction action) -> Observer subaction action {- Promote a list of Observers to a single Observer by picking, for a given
action, the first one that succeeds.
-}
connect : List (Observer action obs) -> Observer action obs
connect observers subaction = connect observers subaction =
pick ((|>) subaction) observers pick ((|>) subaction) observers
{-| Given a lifting function, a list of observers and an embedding, construct an {-| Given a lifting function, a list of observers and an embedding, construct an
Instance. Notice that the Instance forgets the type parameter `subaction`. Instance.
-} -}
instance' : instance'
(Action model action -> action) -> : (Action container obs -> obs)
List (Observer subaction action) -> -> List (Observer action obs)
Embedding submodel model subaction a -> -> Embedding model container action a
Instance submodel model subaction action a -> Instance model container action obs a
instance' lift observers embedding = instance' lift observers embedding =
let let
fwd = fwd =
@ -249,19 +294,28 @@ instance' lift observers embedding =
} }
{-| It is helpful to see parameter names: {-| It is helpful to see parameter names:
instance view update get set id lift model0 observers = instance view update get set id lift model0 observers =
... ...
Convert a regular Elm Architecture component (view, update) to a component Convert a regular Elm Architecture component (`view`, `update`) to a component
which knows how to access its state in a generic container model (get, set), which knows how to access its model inside a generic container model (`get`,
and which dispatches generic Action updates, lifted to the consumers action `set`), and which dispatches generic `Action` updates, lifted to the consumers
type (lift). You can react to actions in custom way by providing observers action type `obs` (`lift`). You can react to actions in custom way by providing
(observers). You must also provide an initial model (model0) and an identifier observers (`observers`). You must also provide an initial model (`model0`) and an
for the instance (id). The identifier must be unique for all instances of the identifier for the instance (`id`). The identifier must be unique for all
same type stored in the same model (rule of thumb: if they are in the same instances of the same type stored in the same model (overapproximating rule of
file, they need distinct ids.) thumb: if they are in the same file, they need distinct ids.)
Its instructive to compare the types of the input and output views:
{- Input -} {- Output -}
View model action a View container obs a
That is, this function fully converts a view from its own `model` and `action`
to the master `container` model and `observation` action.
-} -}
instance instance
: View model action a : View model action a
@ -269,11 +323,29 @@ instance
-> (container -> Indexed model) -> (container -> Indexed model)
-> (Indexed model -> container -> container) -> (Indexed model -> container -> container)
-> Int -> Int
-> (Action container observation -> observation) -> (Action container obs -> obs)
-> model -> model
-> List (Observer action observation) -> List (Observer action obs)
-> Instance model container action observation a -> Instance model container action obs a
instance view update get set id lift model0 observers = instance view update get set id lift model0 observers =
embedIndexed view update get set model0 id embedIndexed view update get set model0 id
|> instance' lift observers |> instance' lift observers
{-| Variant of `instance` for components that are naturally singletons
(e.g., snackbar, layout).
-}
instance1
: View model action a
-> Update model action
-> (container -> Maybe model)
-> (Maybe model -> container -> container)
-> (Action container obs -> obs)
-> model
-> List (Observer action obs)
-> Instance model container action obs a
instance1 view update get set lift model0 observers =
embed view update (get >> Maybe.withDefault model0) (Just >> set)
|> instance' lift observers

View file

@ -281,7 +281,7 @@ view addr model =
{-| {-|
-} -}
type alias State s obs = type alias State s obs =
{ s | snackbar : Indexed (Model obs) } { s | snackbar : Maybe (Model obs) }
{-| {-|
@ -306,19 +306,16 @@ actionObserver action =
Nothing Nothing
{-| Component instance. {-| Component instance.
-} -}
instance : instance
Int : (Component.Action (State state obs) obs -> obs)
-> (Component.Action (State state obs) obs -> obs)
-> (Model obs) -> (Model obs)
-> Instance (State state obs) obs -> Instance (State state obs) obs
instance id lift model0 = instance lift model0 =
Component.instance Component.instance1
view update .snackbar (\x y -> {y | snackbar = x}) id lift model0 [ actionObserver ] view update .snackbar (\x y -> {y | snackbar = x}) lift model0 [ actionObserver ]
{-| {-|
TODO TODO
@ -334,4 +331,3 @@ add contents inst model =
update (Add contents) (inst.get model) update (Add contents) (inst.get model)
in in
(inst.set sb model, Effects.map inst.fwd fx) (inst.set sb model, Effects.map inst.fwd fx)