Creating an app skeleton using Halogen 0.5 and Ocelot components

halogen
#1

I made a stab at this in order to scratch an itch of my own but maybe it will interest someone else - i’m currently trying to work out how to fix a couple of compile errors arising from the changes going from Halogen 0.4 -> 0.5, so i can’t say if it will work.

Collaborators welcome!

edit: forgot the repo https://github.com/afcondon/halocelot.git

1 Like
#2

I’m concluding / suspending this effort now - having gotten thru the simple stuff here there’s a bunch of changes which i think would require me to learn more about Halogen 0.4 in order to understand how to migrate the code. There certainly are good resources for this (see links below) but they’re not quite enough for me to intelligently migrate the parts that have changed in the (formerly) Parent components.

I think someone who’d built an app with Halogen 0.4 could probably make those changes pretty easily, its a question of resolving the following sort of stuff:

$ spago build  2>&1 | grep "Unknown type" | sort | uniq                                       master ✭ ✱
Unknown type H.ComponentDSL
Unknown type H.HTML
Unknown type H.ParentDSL
Unknown type H.ParentHTML
Unknown type H.SubscribeStatus
Unknown type ProxyS
Unknown type Select.ComponentHTML
Unknown type Select.Message

Useful links for anyone who’s interested in using my WIP as a base:


#3

Just checking, but is your goal here to upgrade purescript-ocelot from Halogen v4 to Halogen v5?

#4

yes, plus moving it to spago, just to have a “hello world”-ish skeleton that would let people (including me) tip a toe in a halogen 5 app with minimal setup

(i didn’t fork ocelot because i really only want a skeleton - but i would have forked and offered a PR if i’d got it working)

#5

I’m currently figuring out Yesod/Servant. In a few weeks, I’ll likely be getting back to Halogen where I’d likely help you in this regard.

1 Like
#6

@afc After opening this issue, I was told about a port that is currently Halogen v5 compatible.

I’d like to upgrade that fork, so that it’s compatible with the latest release of Halogen Select.

1 Like
#7

that sounds like a great idea (wish i’d known that fork existed! i thought i went looking for downstream forks at the time, maybe it was private then)

#8

I think I’ll fork the main purescript-ocelot repo, update it to use my general halogen template file and then refer to that fork to see how they implemented some things. When I surveyed that fork, it made things compatible with Halogen v5, but wasn’t using “native” Halogen IIRC. See my fork for my current progress.

I’m also wondering whether it’d be worth it to make it more modular by using row polymorphism and Variant, VariantF, and Record. For example, see the template/example file for halogen-modular. However, I’m not sure whether this would significantly affect performance, so that it wouldn’t be worth it.

#9

i’m inclined to say keep it simple - not just because i’m a bear of very little brain myself, but also i think that now that it seems (to me) to have shaken out that Halogen is the main game in town for UI / webapp development in Purescript it would probably be more accessible to more people if you take the simpler option.

i appreciate that all both your learn halogen repo and Thomas Honeyman-Scott’s “real world halogen” are good intros for beginners. There’s still a lot of value in an app skeleton that looks polished out-of-the-box as the ocelot ones do.

#10

“Keep it simple” is always a good rule of thumb, but I wonder how simple “simple” really is. When it comes to renderless components, there seems to be two ways to do it:

  1. provide a base component that gets wrapped by another (halogen-select as it currently is)
  2. provide “parts” of a component that an end-developer “embeds” in their component at various spots (halogen-modular’s template file)

The first is easier to use/understand (I think?) but comes at the cost of certain restrictions. For example, I don’t think that one could utilize two renderless components within the same component. (Now, I’m not sure when one would want to do this, but that’s not my point.) If one did have such a need, then the second option is the simplest way it could be done AFAIK.

By taking the second approach, there’s a few other things we could do that would be harder/impossible to do in the first:

  • message forwarding: given component A renders component B, which renders component C, when C raises a message, B forwards it to A rather than unwrapping C’s message and packing it up as a B message.
  • event handling reusage for example, calling preventDefault with the same action rather than defining 4 different actions that all ultimately call preventDefault
  • library feature opt-in: if a library does X, Y, and Z correctly, but you only need it to do X and Y / you need to change how it does Z to something slightly differently.

Still, these ideas might be motivated more by “what kinds of abstractions could we build?” / premature optimization rather than “what abstractions are necessary to solve business problems?”

#11

I have the message forwarding. I’ve made a SideBar (the B) component that proxies messages between A and C upward and downward, but I’m wrapping C answers as a B message (as B also has its own messages to send, but A knows of B, so not an issue).

#12

Actually those do sound like abstractions that are worth having / solve business problems, so perhaps it’s just a question of lessening the burden of absorbing them so that they don’t present such a high learning curve that the user rebounds from it.

#13

Yeah, message forwarding is already possible, but there’s still that extra unwrap/wrap step when converting C’s message into B’s message (which might just box C’s message). This approach is completely fine and might be worth it in the long-term (see last paragraph in this comment for more context).

Still, the idea I had was to use something like Variant to wrap messages. Then, C’s message might be
Variant ( cParentMessage :: CMainMessage, cForwardMessage :: CForwardMessage)
as in CMainMessage is for the immediate parent above and CForwardMessage is meant for whichever ancestor needs it. B’s message might be
Variant (bMessage :: BMessage, cForwardMessage :: CForwardMessage)

When C emits the CForwardMessage, B would just re-raise it via the below code

-- Component B
render state = 
  ...  
  H.slot _slot slotIndex componentC input forward
  ...

handleAction =
  caseV -- i.e. Data.Variant.case_
    # handleForward
    # on _bMessage handleBMessage

and in a library like halogen-modular defines the forward function as

-- injV = Data.Variant.inj
-- `forward` would work for any value
forward 
  :: forall a otherRows
   . a -> Maybe (Variant (hmForwarder :: a | otherRows))
forward = Just <<< injV _hmForwarder

handleForward 
  :: forall a rows
   . H.HalogenM state action slots (Variant (hmForwarder :: a | otherMsgRows) m Unit 
handleForward = on _hmForwarder H.raise

This complexity doesn’t seem worth it if the parent component is forwarding a single child component to the grandparent component (e.g. your example). However, if the parent is forwarding multiple children’s different messages to a grandparent, then this abstraction would be useful. Otherwise, I’m not sure whether the added complexity is worth the abstraction’s benefits (i.e. which approach will I be able to understand better in 6 months: the normal wrap/unwrap approach or the Variant-based abstraction?). I’m also not sure whether performance suffers when using Variant and by how much if it does.

#14

Yeah, that’s my thought process as well. If a user is already using/wanting to use Halogen, then they need to understand a number of “complex” concepts already:

  • the Monad type class hierarchy
  • how to model pure/impure functions via Monads
  • PureScript syntax in general
  • the Halogen framework

None of the above concepts are hard to use once understood. Rather, the issue is being able to explain them in ways that people can grasp.

On initial viewing, the Halogen Modular template/example file above would definitely scare people away. However, after understanding how it works, I think you’d find it tremendously helpful.

So, here’s a brief overview:

Row polymorphism means we can make a function work on only one label of a Variant/Record without needing to worry about what else that Variant could be or that Record could have. For example

type V = Variant (first :: String | otherPossibleValuesOrMaybeNoneAtAll)
type R = Record (first :: String | otherLabelsOrMaybeNoneAtAll)

getName :: forall otherRows. { name :: String | otherRows } -> String
getName record = record.name

-- These compile
-- getName { name: "foo", age: 40 }
-- getName { age: 40, name: "foo"}
-- getName { age: 40 }

-- Similarly for Variant

By using type aliases and Type.Row.RowApply/type (+), we can specify the rows used in the Variant/Record easily

import Type.Row (type (+))
type MainActionRows r = (component :: MainAction | r)
type LibraryActionRows r = (libraryName :: LibraryAction | r)
type Action = 
  Variant 
    ( | MainActionRows 
      + LibraryActionRows 
      + ()
    )

With that understanding, each main type in a Halogen component (e.g. state, action, query, message) can either be a normal “data” type (it’s not extendable/modular) or a Variant/Record type (it is extendable/modular).

State: a state type can be one of two things: either a data-like value (e.g. data State = ...) or a record (e.g. type State = { ... }). If using a renderless component that needs to store and update its own state without affecting the end-user’s state (e.g. Halogen Select), then that component might do two things: first, require you to specify what the values are for its labels in the state record; and two, require you to specify some “library input” values in the initialState :: input -> state function that it later modifies (e.g. Halogen Select specifies search :: Maybe String as an input to that library, which it changes to search :: String in the actual state type used via fromMaybe "" searchValue).

In this situation, the record version is the one that is easier to use because the library can use Record.Builder to add or modify additional labels to the state. In the above file, that’s what the { | PipelineRows } mean. If you change input into { | PipelineRows }, then the library provides its own function that further modifies that record until it is { | YourStateRows + LibraryStateRows + () }. If one was using multiple renderless components, each library could specify its own pipeline function (e.g. Builder { | PipelineRows} { | StateRows }), which are composed together.

Action: an Action can either be a “data” type or a Variant. If it’s a Variant, then each renderless component library can specify and handle its own actions. All you need to do is add their action rows to your Variant-based action type. Then, your handleAction looks like this:

handleAction =
  caseV
    # handleLibrary1Actions
    # handleLibrary2Actions
    # on _mainComponent handleMainAction
    -- ^ i.e. your actions, not other libraries' actions

Query: A query an likewise be a data type or a VariantF. If it’s a VariantF, then each renderless component library can specify and handle its own queries. All you need to do is add their query rows to your VariantF-based query type. Then, your handleQuery looks like this:

handleQuery =
  caseVF -- i.e. Data.Functor.VariantF.case_
    # handleLibrary1Query
    # handleLibrary2Query
    # on _mainComponent handleMainQuery
    -- ^ i.e. your queries, not other libraries' queries

Message: I’ve already covered this in a prior comment above.

So, when defining your component, you will usually use the “data” types. However, once you need to use Library X, which adds its own constraints, you can then opt-in to the Variant/Record types as needed. So, if one component needs to forward child messages but doesn’t do anything else, it can opt-in to the Variant-based Message type but leave the other Halogen types as “data” types. Or, if a component needs something like Halogen Select, it might opt-in to the Record-based State type and Variant-based Action type because (in a possible future release) that’s what Halogen Select requires in order to be used upstream. Or, if you use the same queries multiple times throughout your code (e.g. query forwarding), you can specify them once in a VariantF-based query type and then reuse that query throughout your code.

Again, this is simply a different way to do things. While we could forward messages and queries using Variant/VariantF, some other mechanism might be a better solution in some situations and a worse solution in other situations.

#15

After trying out the above message-forwarding idea using Variant, I’ve discovered that it doesn’t scale. In other words, if one wanted to use this approach to be able to easily forward a child component’s message to some ancestor component 5+ levels above it, this idea wouldn’t scale. I don’t think it would be any better/worse than the current way it’s done (i.e. parent has action that wraps the message and then unwraps message in the handleAction function, which re-raises the message as the parent’s message).

For more context, see Lessons Learned.

The query-forwarding idea doesn’t scale for similar reasons.

#16

I do not see it as polluting the grandparents code as I expect that grandparent knows of both the parent and the child, so must handle them. Although, I’d like receive to live in HalogenM, like handleAction.

As for multi-level messaging, I’d expect it to use bus and subscribe. I’m rather new to Halogen, so maybe that isn’t the right way.

#17

I do not see it as polluting the grandparents code as I expect that grandparent knows of both the parent and the child, so must handle them. Although, I’d like receive to live in HalogenM , like handleAction .

Yeah… this is probably an overly harsh critique on my part.

As for multi-level messaging, I’d expect it to use bus and subscribe. I’m rather new to Halogen , so maybe that isn’t the right way.

Yeah, that’s the “other mechanism” to which I was referring that might be a better solution than my idea. Since my idea doesn’t scale, I think that might be more appropriate.

#18

the https://citizennet.github.io/purescript-ocelot/ is much slower than react applications (e.g. https://material-ui.com/)

e.g. on mobile browser when I click on checkbox it updates not quick enough

I hope it will improve with halogen 5