classy-miso-0.0.0.2: src/Miso/Classy.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE InstanceSigs #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilyDependencies #-}
{-# LANGUAGE TypeInType #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE UnicodeSyntax #-}
#if !MIN_VERSION_base(4,10,0)
{-# OPTIONS_GHC -fno-warn-deprecations #-}
#endif
module Miso.Classy
( Component(..)
, update'
, WrappedComponent
, wrapComponent
, mapWrappedComponent
, addSubcomponent
, initSubcomponent
, WrappedAction(..)
, RouteParser
, ClassyEffect
, WrappedEffect
, ClassyTransition
, effectToTransition
, ClassySub
, ClassySink
, WrappedView
, WrappedSub
, WrappedSink
, wrapAction
, wrapSub
, routeToURI
, toRoutePath
, getCurrentURI
, parseCurrentURI
, RoutePath
, actionToSub
, actionToWrappedSub
, wrappedActionToSub
, viewSub
, viewSubs
, viewSubBy
, viewSubsBy
, emptyView
, emptySub
, emptyWrappedSub
, fireEvent
, addEventHandler
, module Miso
, module Network.URI
, module RFC.Miso.String
, Default(..)
) where
import Control.Lens hiding ( view )
import Control.Monad.Trans.State.Strict ( put )
import Control.Monad.Trans.Writer.Strict ( tell )
import Data.Default
import Data.Map.Strict ( Map )
import qualified Data.Map.Strict as Map
import Data.Typeable
import Miso hiding ( App (..), getCurrentURI )
import qualified Miso.Html as Html
import Network.URI ( URI (..), parseURI )
import qualified Network.URL as URL
import RFC.Miso.String
import RFC.Prelude hiding ( init )
-- Need the ability to compare proxies for equivalence
#if MIN_VERSION_base(4,10,0)
import Type.Reflection ( eqTypeRep )
eqProxy :: Proxy a -> Proxy b -> Maybe (a :~: b)
eqProxy a b = eqTypeRep (typeRep a) (typeRep b)
#else
-- | Hand-rolled and dangerous implementation of 'eqTypeRep'. BOO!
eqProxy :: (Typeable a, Typeable b) => Proxy a -> Proxy b -> Maybe (a :~: b)
eqProxy a b
| typeRep a == typeRep b = Just undefined
| otherwise = Nothing
#endif
{-# ANN module ("HLint: ignore Use fewer imports"::String) #-}
-- | A 'View' where we wrap up the action type as a 'WrappedAction'.
type WrappedView = View WrappedAction
-- | An 'Effect' where we wrap up the action type as a 'WrappedAction'.
type WrappedEffect model = Effect WrappedAction model
-- | A 'Sink' where we consume the action type as a 'WrappedAction'.
type WrappedSink = WrappedAction -> IO ()
-- | A 'Sub' where we wrap up the action type as a 'WrappedSub'.
type WrappedSub = WrappedSink -> IO ()
-- | Handy alias specifying the 'Effect' in terms of type families.
type ClassyEffect model = Effect (Action model) model
-- | Handy alias specifying the 'Transition' in terms of type families.
type ClassyTransition model = Transition (Action model) model
-- | Handy alias specifying the 'Sink' in terms of type families.
type ClassySink model = Action model -> IO ()
-- | Handy alias specifying the 'Sub' in terms of type families.
type ClassySub model = ClassySink model -> IO ()
-- | Wraps the concept of th e
class (Eq model, Typeable model, Eq (Action model), Typeable (Action model), Default(EventHandler model)) => Component model where
{-# MINIMAL init, view, eventHandlers, (update|transition) #-}
-- | The actions for this component, such as those returned in an 'Effect'
type Action model = action | action -> model
-- | Defines the initial arguments that the component expects to receive.
type InitArgs model = initargs | initargs -> model
-- | Defines the kinds of events that the parent can handle.
type EventHandler model
init :: InitArgs model → IO model -- ^ Initialize the model
view :: model -> View (Action model) -- ^ Render the model into a VDOM-friendly structure
eventHandlers :: ALens' model [EventHandler model] -- ^ Holds onto all the event handlers.
subcomponents :: ALens' model [WrappedComponent] -- ^ Holds onto all the subcomponents
subcomponents = lens (const []) (const)
{-# INLINE subcomponents #-}
routeParser :: model -> RouteParser -- ^ Provides a parser that defines the route for this model
routeParser _ _ = fail "Model is not a route"
{-# INLINE routeParser #-}
-- | Implement this if you want to use the classic way to update.
update :: Action model -> model -> WrappedEffect model
update = fromTransition . transition
{-# INLINE update #-}
-- | Implement this if you want to use the 'Transition'-based way to update.
transition :: Action model -> Transition WrappedAction model ()
transition = toTransition . update
{-# INLINE transition #-}
-- | Whether or not this action is really for this model. By default, we accept all actions. But, if you've
-- got multiple components of the same type, you may need to be more picky.
acceptAction :: Action model -> model -> Bool
acceptAction _ _ = True
{-# INLINE acceptAction #-}
-- | Converts an 'Effect' into a 'Transition'.
effectToTransition :: Effect action model -> Transition action model ()
effectToTransition (Effect model actions) = do
lift $ tell actions
put model
{-# INLINE effectToTransition #-}
fireEvent :: (Component model) => model -> (EventHandler model -> WrappedSub) -> WrappedEffect model
fireEvent model event = Effect model $ event <$> events
where
events = model^.(cloneLens eventHandlers)
{-# INLINE fireEvent #-}
-- | Utility method to add an 'EventHandler' instance to a 'Component'.
addEventHandler :: (Component model) => model -> EventHandler model -> model
addEventHandler model handler = model & (cloneLens eventHandlers) %~ ((:) handler)
{-# INLINE addEventHandler #-}
addSubcomponent :: (Component parent, Component child) => parent -> child -> parent
addSubcomponent parent child =
parent & (cloneLens subcomponents) %~ ((:) $ wrapComponent child)
{-# INLINE addSubcomponent #-}
-- | Handy method for the common case when you can pass your args down to your subcomponents.
initSubcomponent :: (Component model, Component sub) =>
InitArgs model -> model -> (InitArgs model -> IO (InitArgs sub)) -> EventHandler sub -> IO model
initSubcomponent parentArgs parent argPicker handler = addSub . addHandler <$> (subArgs >>= init)
where
addHandler sub = addEventHandler sub handler
subArgs = argPicker parentArgs
initSubs = parent^.(cloneLens subcomponents)
addSub newSub = parent & (cloneLens subcomponents) .~ (wrapComponent newSub:initSubs)
{-# INLINE initSubcomponent #-}
-- | For the component itself, and then recursively for all the subcomponents, this attempts to cast
-- the action (unwrapped from 'WrappedAction') to the relevant type for that component.
-- If that works, we calls 'update' on that component.
update' :: (Component model) => WrappedAction -> model -> WrappedEffect model
update' wrapped@(WrappedAction action) initModel =
Effect finalModel allEffects
where
(finalModel, allEffects) = (foldedModel, foldedEffects)
Effect foldedModel foldedEffects =
foldr foldImpl initEffect (initModel^.(cloneLens subcomponents))
foldImpl (WrappedComponent (_,subcomp)) (Effect roundModel roundEffects) =
let result = update' wrapped subcomp in -- TODO Do this recursion as a scheduled IO
let Effect wrappedModel wrappedEffects = result &
bimap
id
(\subModel -> roundModel & (cloneLens subcomponents) %~ ((:) (WrappedComponent (Proxy,subModel))))
in
Effect wrappedModel (wrappedEffects <> roundEffects)
initEffect =
case cast action of
Nothing -> noEff initModel
Just myAction ->
if acceptAction myAction initModel then
update myAction initModel
else
noEff initModel
{-# INLINABLE update' #-}
-- | The function for processing routes from route paths. It will consume the current
-- 'RoutePath', and (if it successfully parsed), it returns the component that handles the route.
type RouteParser = forall m. MonadFail m => RoutePath -> m WrappedComponent
-- | A wrapper around components that hides the existential quantification.
data WrappedComponent = forall child. Component child ⇒ WrappedComponent (Proxy child, child)
instance Eq WrappedComponent where
(==) (WrappedComponent left) (WrappedComponent right) =
maybe False (left ==) (cast right)
{-# INLINE (==) #-}
-- | Smart constructor for 'WrappedComponent'.
wrapComponent :: Component child => child -> WrappedComponent
wrapComponent child = WrappedComponent (Proxy, child)
{-# INLINE wrapComponent #-}
-- | Allows you to effectively extract the component, but note that the type of the
-- wrapped component can't escape its scope.
mapWrappedComponent :: (forall model. (Component model, Typeable model) => model -> a) -> WrappedComponent -> a
mapWrappedComponent f (WrappedComponent(_,it)) = f it
{-# INLINE mapWrappedComponent #-}
-- | A wrapper around actions, such as those returned in an 'Effect', which
-- hides the existential quantification.
data WrappedAction = forall child. Component child => WrappedAction (Action child)
instance Eq WrappedAction where
(==) (WrappedAction left) (WrappedAction right) =
maybe False (right ==) (cast left)
{-# INLINE (==) #-}
-- | Utility function for wrapping up an action into a 'WrappedAction'
wrapAction :: Component model => Action model -> WrappedAction
wrapAction = WrappedAction
{-# INLINE wrapAction #-}
-- | Utility method for converting an action into a 'Sub'
actionToSub :: Action model -> Sub (Action model)
actionToSub action sink = sink action
{-# INLINE actionToSub #-}
-- | Utility method for converting a 'WrappedAction' into a 'WrappedSub'
wrappedActionToSub :: WrappedAction -> WrappedSub
wrappedActionToSub action sink = sink action
{-# INLINE wrappedActionToSub #-}
-- | Utility function for wrapping up a 'Sub' into a 'WrappedSub'
wrapSub :: Component model => Sub (Action model) -> WrappedSub
wrapSub sub sink = sub (sink . wrapAction)
{-# INLINE wrapSub #-}
-- | Ties together 'actionToSub' and 'wrapSub' for you,
-- so you can go all the way from an action to a 'WrappedSub'.
actionToWrappedSub :: Component model => Action model -> WrappedSub
actionToWrappedSub = wrapSub . actionToSub
{-# INLINE actionToWrappedSub #-}
-- | A single segment of a route (a piece between slashes in the URI fragment)
type RouteSegment = StrictText
-- | The value of the query section of a route
type RouteQuery = Map StrictText [StrictText]
-- | The full path of the route. The third URL is the "base URI",
-- and does not necessarily contain the 'RouteSegment' and the
-- 'RouteQuery' pieces.
type RoutePath = ([RouteSegment], RouteQuery, URI)
-- | Generates a URI from the route path by merging the 'RouteQuery' into the 'uriQuery'
-- and the '[RouteSegment]' into the 'uriFragment'.
routeToURI :: RoutePath -> URI
routeToURI (segs, qry, uri) =
uri
{ uriQuery = '?':query
, uriFragment = '#':frag
}
where
encodeString :: ConvertibleStrings a String => Bool -> a -> String -- Second argument is: "Is this a query string?"
encodeString spaceToPlus = URL.encString spaceToPlus URL.ok_param . cs
query :: String
query = intercalate "&" . pairsToTerms $ Map.toList qry
pairsToTerms [] = []
pairsToTerms ((key,[]):rest) = (encodeString True key) : pairsToTerms rest
pairsToTerms ((key,([val])):rest) = (encodeString True key <> "=" <> (encodeString True val)) : pairsToTerms rest
pairsToTerms ((key,vals):rest) = (encodeString True key <> "=" <> intercalate "," (encodeString True <$> vals)) : pairsToTerms rest
frag :: String
frag = intercalate "/" (segToPath <$> segs)
segToPath :: RouteSegment -> String
segToPath = encodeString False
{-# INLINABLE routeToURI #-}
-- | Parses a 'URI' into a 'RoutePath'
toRoutePath :: URI -> RoutePath
toRoutePath uri@URI{uriFragment,uriQuery} =
( pathToSegments . parseHash $ uriFragment
, parseQuery uriQuery
, uri
)
where
decodeString :: String -> StrictText
decodeString str = cs . fromMaybe str . URL.decString True $ str
pathToSegments :: String -> [RouteSegment]
pathToSegments "" = []
pathToSegments ('/':rest) = pathToSegments rest
pathToSegments path = (decodeString start) : pathToSegments rest
where
(start,rest) = splitOnSlash path
splitOnSlash "" = ("","")
splitOnSlash ('/':content) = splitOnSlash content
splitOnSlash content =
case span ('/' /=) content of
([],[]) -> ("","")
([], _:theRest) -> splitOnSlash theRest
(theStart, []) -> (theStart, "")
(theStart, _:theRest) -> (theStart, theRest)
parseHash ('#':rest) = parseHash rest
parseHash ('!':rest) = parseHash rest
parseHash ('/':rest) = parseHash rest
parseHash hash = hash
parseQuery ('?':rest) = parseQuery rest
parseQuery query =
case URL.importParams query of
Nothing ->
Map.empty
Just pairs ->
fmap (second listify) pairs &
Map.fromListWith (++) &
Map.map sort &
Map.map (fmap cs) &
Map.mapKeys cs
listify :: String -> [String]
listify "" = []
listify (',':val) = listify val
listify val = start : listify rest
where
(start,rest) = splitOnComma val
splitOnComma "" = ("","")
splitOnComma (',':content) = splitOnComma content
splitOnComma (' ':content) = splitOnComma content
splitOnComma content =
case span (',' /=) content of
([], []) -> ("", "")
([], _:theRest) -> splitOnComma theRest
(theStart, []) -> (theStart, "")
(theStart, _:theRest) -> (theStart, theRest)
{-# INLINABLE toRoutePath #-}
-- | Retrieves the current location of the window from "window.location.href".
foreign import javascript safe "$r = (window && window.location && window.location.href) || '';"
getWindowLocationHref :: IO MisoString
-- | Gets the current URI.
getCurrentURI :: (MonadIO m, MonadFail m) => m URI
getCurrentURI = do
href <- fromMisoString <$> liftIO getWindowLocationHref
case parseURI href of
Nothing -> fail $ "Co uld not parse URI from window.location: " <> href
Just uri -> return uri
{-# INLINE getCurrentURI #-}
-- | Utility method to parse the current URI.
parseCurrentURI :: (MonadIO m) => m RoutePath
parseCurrentURI = toRoutePath <$> liftIO getCurrentURI
{-# INLINE parseCurrentURI #-}
-- | View a particular subcomponent by specifying a test. For the first (leftmost) subcomponent
-- that returns 'True', we will return their view. Failing that, we return an
-- empty (but defined) view.
viewSubBy :: (Component model) => model -> (WrappedComponent -> Bool) -> WrappedView
viewSubBy parent subTest = fromMaybe emptyView . safeHead $ viewSubsBy parent subTest
{-# INLINE viewSubBy #-}
-- | View a particular subcomponent by specifying its type. For the first (leftmost) subcomponent
-- whose type is equal to the given type, we will return their view. Failing that, we
-- return an empty (but defined) view.
viewSub :: (Component model, Component sub) => model -> Proxy sub -> WrappedView
viewSub parent subType = viewSubBy parent (wrappedComponentPxyEq subType)
{-# INLINE viewSub #-}
-- | View subcomponents by a given test. For every subcomponent that returns 'True', we will
-- include their 'WrappedView' in the resulting list.
viewSubsBy :: (Component model) => model -> (WrappedComponent -> Bool) -> [WrappedView]
viewSubsBy parent test =
toWrappedView <$> filter test targets
where
targets = (wrapComponent parent : parent^.(cloneLens subcomponents))
toWrappedView :: WrappedComponent -> WrappedView
toWrappedView (WrappedComponent(_,sub)) = WrappedAction <$> view sub
{-# INLINE viewSubsBy #-}
-- | View subcomponents by specifying their type. For every subcomponent of whose type
-- is equal to the given type, we will include their 'WrappedView' in the resulting list.
viewSubs :: (Component model, Component sub) => model -> Proxy sub -> [WrappedView]
viewSubs parent subType = viewSubsBy parent (wrappedComponentPxyEq subType)
{-# INLINE viewSubs #-}
-- | Test that a 'WrappedComponent' is equal to a given type.
wrappedComponentPxyEq :: (Component target) => Proxy target -> WrappedComponent -> Bool
wrappedComponentPxyEq targetPxy (WrappedComponent(pxy,_)) = isJust $ eqProxy targetPxy pxy
{-# INLINE wrappedComponentPxyEq #-}
-- | A definition of an empty view, which has no impact on how the screen is rendered.
emptyView :: View anything
emptyView = Html.text ""
{-# INLINE emptyView #-}
-- | An empty (nop) 'Sub'
emptySub :: Sub (Action a)
emptySub = const $ return ()
{-# INLINE emptySub #-}
-- | A wrapped version of 'emptySub'
emptyWrappedSub :: WrappedSub
emptyWrappedSub = const $ return ()
{-# INLINE emptyWrappedSub #-}