Elm Architecture in PureScript III

10 Oct 2015

Dynamic Lists of Counters

On thelast post, we implemented a pair of counters. Now, we’ll generalize that out to a dynamic list of counters, and later, give them all remove buttons. In the process, we’ll learn how to combine components, stack them, peek on them, and otherwise deal with them appropriately.

The code for this is available in this repository
.

Let’s get started! We want a list of counters, a button to add a counter, and a button to remove a counter. Let’s define our state and inputs:

type StateP =
  { counterArray :: Array Int
  , nextID :: Int
  }

initialState :: StateP
initialState =
  { counterArray: []
  , nextID: 0
  }

data Input a
  = AddCounter a
  | RemoveCounter a

Another quick detour to define our parent-level state and query types:

type State g =
  InstalledState StateP Counter.State Input Counter.Input g CounterSlot

type Query =
  Coproduct Input (ChildF CounterSlot Counter.Input)

And, our UI function:

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = parentComponent render eval
  where
    render state = 
      H.div_ 
        [ H.h1_ [ H.text "Counters" ]
        , H.ul_ $ map (i -> mslot (CounterSlot i) Counter.ui (Counter.init 0)) state.counterArray
        , H.button [ E.onClick $ E.input_ AddCounter ]
                   [ H.text "Add Counter" ]
        , H.button [ E.onClick $ E.input_ RemoveCounter ]
                   [ H.text "Remove Counter" ]
        ]

    eval :: EvalParent Input StateP Counter.State Input Counter.Input g CounterSlot
    eval (AddCounter next) = do
      modify addCounter
      pure next
    eval (RemoveCounter next) = do
      modify removeCounter
      pure next

mslot :: forall s f g p i. p -> Component s f g -> s -> HTML (SlotConstructor s f g p) i
mslot slot comp state = H.slot slot _ -> { component: comp, initialState: state }

Basically the same thing we’ve been working with already! Instead of keeping a CounterSlot 0
and CounterSlot 1
around, we’ve got an array of integers. When we want to render them, we map over them with the slot type constructor and the H.slot
to give them a place to go. Halogen figures out all of the event routing for us.

Removing a Counter

Alright, it’s time to give counters their own remove button. Rather than touch the counter at all, we’re simply going to wrap the existing counter component in a new component. The sole responsibility of this component will be handling the removal of counters.

There’s a bit of boiler plate around the State and Query, but after that, the result is pretty tiny!

-- src/Example/CounterRem.purs
data Input a = Remove a

type State g =
  InstalledState Unit Counter.State Input Counter.Input g CounterSlot
type Query =
  Coproduct Input (ChildF CounterSlot Counter.Input)

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = parentComponent render eval
  where
    render _ =
        H.div_ 
          [ mslot (CounterSlot 0) Counter.ui (Counter.init 0)
          , H.button [ E.onClick $ E.input_ Remove ]
                     [ H.text "Remove" ]
          ]
    eval :: EvalParent Input Unit Counter.State Input Counter.Input g CounterSlot
    eval (Remove a) = pure a

Since we’re not maintaining any state, we’ll just use the Unit
type to signify that. Our eval
function is going to punt the behavior to the parent component.

Now… Halogen does some impressive
type trickery. Coproducts, free monads, query algebrae… it can be pretty intimidating. There’s a decent amount of associated boilerplate as well. We’re about to get into some of that.

Let’s look at InstalledState
in the Halogen documentation
:

type InstalledState s s' f f' g p = 
  { parent   :: s
  , children :: Map p (Tuple (Component s' f' g) s')
  , memo     :: Map p (HTML Void (Coproduct f (ChildF p f') Unit)) 
  }

It’s a record with a parent state, a map from child slots to child states, and a map from child slots to memoized HTML.

But what is all of this coproduct
stuff again? A Coproduct
is defined like this:

newtype Coproduct f g a = Coproduct (Either (f a) (g a))

It’s a way of saying “I have a value of type a inside of a functor. That functor is either f or g.” We know we can specialize f
in the InstalledComponent
to our Input
query algebra. And ChildF p f'
is a given child’s identifier and the child’s query algebra. Halogen is using the coproduct structure to keep track of the children’s query algebra inputs.

Revisiting our type synonyms again, we have:

type State g =
  InstalledState Unit Counter.State Input Counter.Input g CounterSlot

The true state of this component isn’t just Unit
– it’s the result of installing the Counter.State
into this component. We’re giving that a name we can reference, and allowing the caller to provide the functor.

type Query =
  Coproduct Input (ChildF CounterSlot Counter.Input)

Finally, our QueryMiddle
just fills in the types for the combined query algebra.

Alright! Awesome! We’ve augmented a component with a Remove
button. Let’s embed that into a list. We’ll actually get to reuse almost everything from example three!

-- src/Example/Four.purs
data Input a = AddCounter a

type State g =
  InstalledState StateP (Counter.State g) Input Counter.Query g CounterSlot

type Query =
  Coproduct Input (ChildF CounterSlot Counter.Query)

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = parentComponent' render eval peek
  where

Ah! We’re peeking! I can tell because of the peek
function. And also the '
on the end of parentComponent'
. The '
indicates peeking.

Peeking is the way to inspect child components in purescript-halogen. So when a child component of a peeking parent is done with an action, then the parent gets a chance to see the action and act accordingly.

    render state =
      H.div_ 
        [ H.h1_ [ H.text "Counters" ]
        , H.ul_ (map (mapSlot CounterSlot Counter.ui (installedState unit)) state.counterArray)
        , H.button [ E.onClick $ E.input_ AddCounter ]
                   [ H.text "Add Counter" ]
        ]

    eval :: EvalParent _ _ _ _ _ g CounterSlot
    eval (AddCounter next) = do
      modify addCounter
      pure next

mapSlot slot comp state index = mslot (slot index) comp state

Rendering and evalling work exactly as you’d expect. Let’s look at peeking!

    peek :: Peek (ChildF CounterSlot Counter.Query) StateP (Counter.State g) Input Counter.Query g CounterSlot
    peek (ChildF counterSlot (Coproduct queryAction)) =
      case queryAction of
        Left (Counter.Remove _) ->
          modify (removeCounter counterSlot)
        _ ->
          pure unit

So this is kind of a more complex peek
than you’d normally start with. My bad. Generally, the peek
function has a definition that’d look like:

peek (ChildF childSlot action) =
  case action of
       DoThing next -> -- ...

But we’re working with the installed/child components who manage their state using the coproduct machinery, and as of now, we have to manually unwrap the coproduct and pattern match on the Either
value inside. When we match on the Left
value, we get to see the immediate child’s actions. If we were to match on the Right
value, then we’d get to inspect children’s of children’s actions.

In any case, we peek
on the child component, and if it just did a Remove
action, then we modify our own state. Otherwise, we ignore it.

-- src/Main.purs
main = ... do
  app <- runEx4
  appendToBody app.node

runEx4 = runUI Ex4.ui (installedState (Ex3.initialState))

And now we’ve got our dynamic list of removable embedded counters going.

Next up, we’ll be looking at AJAX, effects, and other fun stuff.

UPDATE: Modularize me, cap’n!

Ok, so I wasn’t happy with how unmodular the above example was. We had to redefine a whole component just to add a remove button. If I wanted another component that had a remove button, I’d have to redo all that work! No thanks. Instead, I made a higher order component out of it.

There’s no meaning for distinguishing between children, because it only has one. There’s no state involved either, so we’ll use Unit for both of them. The only query is Remove. So let’s put that all together!

-- src/Example/RemGeneric.purs
data QueryP a = Remove a

type State s f g =
  InstalledState Unit s QueryP f g Unit

type Query f =
  Coproduct QueryP (ChildF Unit f)

addRemove :: forall g s f. (Plus g)
          => Component s f g
          -> s
          -> Component (State s f g) (Query f) g
addRemove comp state = parentComponent render eval
  where
    render _ =
        H.div_ 
          [ H.slot unit _ -> { component: comp, initialState: state } 
          , H.button [ E.onClick $ E.input_ Remove ]
                     [ H.text "Remove" ]
          ]
    eval :: EvalParent QueryP Unit s QueryP f g Unit
    eval (Remove a) = pure a

Easy! We’ve got a few extra type variables to represent where the child state and query will go. Fairly standard type synonym definitions for use in client components. The only kinda tricky part is rendering: we accept a component and initial state as parameters.

Cool! Let’s see what the definition for the counter looks like with the remove button added:

-- src/Example/CounterRemPrime.purs
type State g = Rem.State Counter.State Counter.Input g
type Query = Rem.Query Counter.Input

ui :: forall g. (Plus g)
   => Component (State g) Query g
ui = Rem.addRemove Counter.ui (Counter.init 0)

More type synonyms! And a fairly nice one liner function to wrap the counter.

The code for the list itself is essentially unchanged. We do have to import the RemGeneric
as well as the CounterRemPrime
module to be able to use the RemGeneric.Input
type, but the type declarations hardly change at all.

All in all, this level of componentiziation is fairly easy! Defining the type synonyms is a bit of a pain, but you’ll likely be writing a lot fewer of them when you have more involved components.

Other posts in the series:

稿源:To Overcome (源链) | 关于 | 阅读提示

本站遵循[CC BY-NC-SA 4.0]。如您有版权、意见投诉等问题,请通过eMail联系我们处理。
酷辣虫 » 综合编程 » Elm Architecture in PureScript III

喜欢 (0)or分享给?

专业 x 专注 x 聚合 x 分享 CC BY-NC-SA 4.0

使用声明 | 英豪名录