From 99f23f25582429e276f513f58677fe6676657ac3 Mon Sep 17 00:00:00 2001 From: Gregor Kleen Date: Mon, 25 May 2020 14:04:31 +0200 Subject: [PATCH] feat(course-communication): one recipient group per tutorial Fixes #428 --- messages/uniworx/de-de-formal.msg | 2 +- messages/uniworx/en-eu.msg | 2 +- src/Handler/Tutorial/Communication.hs | 27 ++++--- src/Handler/Utils/Communication.hs | 78 ++++++++----------- src/Handler/Utils/Form/MassInput.hs | 36 ++++++--- .../Utils/Form/MassInput/Liveliness.hs | 2 +- src/Utils/PathPiece.hs | 47 +++++++++++ .../communication/recipientLayout.hamlet | 16 +++- 8 files changed, 139 insertions(+), 71 deletions(-) diff --git a/messages/uniworx/de-de-formal.msg b/messages/uniworx/de-de-formal.msg index 6a258b1dc..33b1f8aca 100644 --- a/messages/uniworx/de-de-formal.msg +++ b/messages/uniworx/de-de-formal.msg @@ -1445,7 +1445,7 @@ RGCourseParticipants: Kursteilnehmer RGCourseLecturers: Kursverwalter RGCourseCorrectors: Korrektoren RGCourseTutors: Tutoren -RGTutorialParticipants: Tutorium-Teilnehmer +RGTutorialParticipants tutn@TutorialName: Tutorium-Teilnehmer (#{tutn}) MultiSelectFieldTip: Mehrfach-Auswahl ist möglich (Umschalt bzw. Strg) MultiEmailFieldTip: Es sind mehrere, Komma-separierte, E-Mail-Addressen möglich diff --git a/messages/uniworx/en-eu.msg b/messages/uniworx/en-eu.msg index f4d2e078c..604bff30a 100644 --- a/messages/uniworx/en-eu.msg +++ b/messages/uniworx/en-eu.msg @@ -1445,7 +1445,7 @@ RGCourseParticipants: Course participants RGCourseLecturers: Course administrators RGCourseCorrectors: Course correctors RGCourseTutors: Course tutors -RGTutorialParticipants: Tutorial participants +RGTutorialParticipants tutn: Tutorial participants (#{tutn}) MultiSelectFieldTip: Multiple selections are possible (Shift or Ctrl) MultiEmailFieldTip: Multiple emails addresses may be specified (comma-separated) diff --git a/src/Handler/Tutorial/Communication.hs b/src/Handler/Tutorial/Communication.hs index 126eef925..c09447ffb 100644 --- a/src/Handler/Tutorial/Communication.hs +++ b/src/Handler/Tutorial/Communication.hs @@ -16,21 +16,28 @@ import qualified Data.Map as Map getTCommR, postTCommR :: TermId -> SchoolId -> CourseShorthand -> TutorialName -> Handler Html getTCommR = postTCommR postTCommR tid ssh csh tutn = do - (cid, tutid) <- runDB $ fetchCourseIdTutorialId tid ssh csh tutn + ((cid, tutid), usertuts) <- runDB $ do + tutData@(cid, _) <- fetchCourseIdTutorialId tid ssh csh tutn + tuts <- selectList [TutorialCourse ==. cid] [] + usertuts <- forMaybeM tuts $ \(Entity tutid Tutorial{..}) -> do + cID <- encrypt tutid + guardM . hasReadAccessTo $ CTutorialR tid ssh csh tutorialName TUsersR + return ( RGTutorialParticipants cID + , E.from $ \(user `E.InnerJoin` participant) -> do + E.on $ user E.^. UserId E.==. participant E.^. TutorialParticipantUser + E.where_ $ participant E.^. TutorialParticipantTutorial E.==. E.val tutid + return user + ) + return (tutData, usertuts) + commR CommunicationRoute { crHeading = SomeMessage . prependCourseTitle tid ssh csh $ SomeMessage MsgCommTutorialHeading , crUltDest = SomeRoute $ CTutorialR tid ssh csh tutn TCommR , crJobs = crJobsCourseCommunication cid , crTestJobs = crTestJobsCourseCommunication cid - , crRecipients = Map.fromList - [ ( RGTutorialParticipants - , E.from $ \(user `E.InnerJoin` participant) -> do - E.on $ user E.^. UserId E.==. participant E.^. TutorialParticipantUser - E.where_ $ participant E.^. TutorialParticipantTutorial E.==. E.val tutid - return user - ) - , ( RGCourseLecturers + , crRecipients = Map.fromList $ + [ ( RGCourseLecturers , E.from $ \(user `E.InnerJoin` lecturer) -> do E.on $ user E.^. UserId E.==. lecturer E.^. LecturerUser E.where_ $ lecturer E.^. LecturerCourse E.==. E.val cid @@ -52,7 +59,7 @@ postTCommR tid ssh csh tutn = do E.&&. tutor E.^. TutorUser E.==. user E.^. UserId return user ) - ] + ] ++ usertuts , crRecipientAuth = Just $ \uid -> do isTutorialUser <- E.selectExists . E.from $ \tutorialUser -> E.where_ $ tutorialUser E.^. TutorialParticipantUser E.==. E.val uid diff --git a/src/Handler/Utils/Communication.hs b/src/Handler/Utils/Communication.hs index da6ae9c72..ae8884874 100644 --- a/src/Handler/Utils/Communication.hs +++ b/src/Handler/Utils/Communication.hs @@ -23,17 +23,14 @@ import qualified Data.Conduit.Combinators as C data RecipientGroup = RGCourseParticipants | RGCourseLecturers | RGCourseCorrectors | RGCourseTutors - | RGTutorialParticipants - deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable) + | RGTutorialParticipants CryptoUUIDTutorial + deriving (Eq, Ord, Read, Show, Generic, Typeable) -instance Universe RecipientGroup -instance Finite RecipientGroup -nullaryPathPiece ''RecipientGroup $ camelToPathPiece' 1 -embedRenderMessage ''UniWorX ''RecipientGroup id +instance LowerBounded RecipientGroup where + minBound' = RGCourseParticipants -deriveJSON defaultOptions - { constructorTagModifier = camelToPathPiece' 1 - } ''RecipientGroup +derivePathPiece ''RecipientGroup (camelToPathPiece' 1) "--" +pathPieceJSON ''RecipientGroup data RecipientCategory @@ -41,19 +38,8 @@ data RecipientCategory | RecipientCustom deriving (Eq, Ord, Read, Show, Generic, Typeable) -deriveFinite ''RecipientCategory -finiteEnum ''RecipientCategory - -deriveJSON defaultOptions - { constructorTagModifier = camelToPathPiece' 1 - , unwrapUnaryRecords = True - , sumEncoding = UntaggedValue - } ''RecipientCategory - -instance ToJSONKey RecipientCategory where - toJSONKey = toJSONKeyText toPathPiece -instance FromJSONKey RecipientCategory where - fromJSONKey = FromJSONKeyTextParser $ maybe (fail "Could not pars RecipientCategory") return . fromPathPiece +instance LowerBounded RecipientCategory where + minBound' = RecipientGroup minBound' instance PathPiece RecipientCategory where toPathPiece RecipientCustom = "custom" @@ -62,13 +48,8 @@ instance PathPiece RecipientCategory where fromPathPiece "custom" = Just RecipientCustom fromPathPiece t = RecipientGroup <$> fromPathPiece t -instance RenderMessage UniWorX RecipientCategory where - renderMessage foundation ls = \case - RecipientCustom -> renderMessage' MsgRecipientCustom - RecipientGroup g -> renderMessage' g - where - renderMessage' :: forall msg. RenderMessage UniWorX msg => msg -> Text - renderMessage' = renderMessage foundation ls +pathPieceJSON ''RecipientCategory +pathPieceJSONKey ''RecipientCategory data CommunicationButton = BtnCommunicationSend @@ -147,13 +128,13 @@ commR CommunicationRoute{..} = do = entityVal . unsafeHead . filter ((== lId) . entityKey) $ concat (Map.elems suggestedRecipients) ++ chosenRecipients let chosenRecipients' = Map.fromList $ - [ ( (EnumPosition $ RecipientGroup g, pos) + [ ( (BoundedPosition $ RecipientGroup g, pos) , (Right recp, recp `elem` map entityKey chosenRecipients) ) | (g, recps) <- Map.toList suggestedRecipients , (pos, recp) <- zip [0..] $ map entityKey recps ] ++ - [ ( (EnumPosition RecipientCustom, pos) + [ ( (BoundedPosition RecipientCustom, pos) , (Right recp, True) ) | (pos, recp) <- zip [0..] . Set.toList $ Set.fromList (map entityKey chosenRecipients) \\ Set.fromList (concatMap (map entityKey) $ Map.elems suggestedRecipients) @@ -163,10 +144,10 @@ commR CommunicationRoute{..} = do let recipientAForm :: AForm Handler (Set (Either UserEmail UserId)) recipientAForm = postProcess <$> massInputA MassInput{..} (fslI MsgCommRecipients & setTooltip MsgCommRecipientsTip) True (Just chosenRecipients') where - miAdd (EnumPosition RecipientCustom, 0) 1 nudge submitView = Just $ \csrf -> do + miAdd (BoundedPosition RecipientCustom, 0) 1 nudge submitView = Just $ \csrf -> do (addRes, addView) <- mpreq (multiUserField True Nothing) (fslpI MsgEMail (mr MsgEMail) & setTooltip MsgMultiEmailFieldTip & addName (nudge "email")) Nothing let - addRes' = addRes <&> \nEmails ((Map.elems &&& maybe 0 (succ . snd . fst) . Map.lookupMax) . Map.filterWithKey (\(EnumPosition c, _) _ -> c == RecipientCustom) -> (oEmails, kStart)) -> FormSuccess . Map.fromList . zip (map (EnumPosition RecipientCustom, ) [kStart..]) . Set.toList $ nEmails `Set.difference` Set.fromList oEmails + addRes' = addRes <&> \nEmails ((Map.elems &&& maybe 0 (succ . snd . fst) . Map.lookupMax) . Map.filterWithKey (\(BoundedPosition c, _) _ -> c == RecipientCustom) -> (oEmails, kStart)) -> FormSuccess . Map.fromList . zip (map (BoundedPosition RecipientCustom, ) [kStart..]) . Set.toList $ nEmails `Set.difference` Set.fromList oEmails return (addRes', $(widgetFile "widgets/communication/recipientAdd")) miAdd _ _ _ _ = Nothing miCell _ (Left (CI.original -> email)) initRes nudge csrf = do @@ -179,31 +160,36 @@ commR CommunicationRoute{..} = do | otherwise -> mpreq checkBoxField ("" & addName (nudge "tick")) $ initRes <|> Just True return (tickRes, $(widgetFile "widgets/communication/recipientName")) - miAllowAdd (EnumPosition RecipientCustom, 0) 1 _ = True + miAllowAdd (BoundedPosition RecipientCustom, 0) 1 _ = True miAllowAdd _ _ _ = False - miAddEmpty _ 0 _ = Set.singleton (EnumPosition RecipientCustom, 0) + miAddEmpty _ 0 _ = Set.singleton (BoundedPosition RecipientCustom, 0) miAddEmpty _ _ _ = Set.empty miButtonAction :: forall p . PathPiece p => p -> Maybe (SomeRoute UniWorX) miButtonAction anchor = SomeRoute . (:#: anchor) <$> mbCurrentRoute - miLayout :: MapLiveliness (EnumLiveliness RecipientCategory) ListLength - -> Map (EnumPosition RecipientCategory, ListPosition) (_, FormResult Bool) - -> Map (EnumPosition RecipientCategory, ListPosition) Widget - -> Map (EnumPosition RecipientCategory, ListPosition) (FieldView UniWorX) - -> Map (Natural, (EnumPosition RecipientCategory, ListPosition)) Widget + miLayout :: MapLiveliness (BoundedLiveliness RecipientCategory) ListLength + -> Map (BoundedPosition RecipientCategory, ListPosition) (_, FormResult Bool) + -> Map (BoundedPosition RecipientCategory, ListPosition) Widget + -> Map (BoundedPosition RecipientCategory, ListPosition) (FieldView UniWorX) + -> Map (Natural, (BoundedPosition RecipientCategory, ListPosition)) Widget -> Widget miLayout liveliness cState cellWdgts _delButtons addWdgts = do checkedIdentBase <- newIdent - let checkedCategories = Set.mapMonotonic (unEnumPosition . fst) . Set.filter (\k' -> Map.foldrWithKey (\k (_, checkState) -> (||) $ k == k' && checkState /= FormSuccess False && (checkState /= FormMissing || maybe True snd (chosenRecipients' !? k))) False cState) $ Map.keysSet cState + let checkedCategories = Set.mapMonotonic (unBoundedPosition . fst) . Set.filter (\k' -> Map.foldrWithKey (\k (_, checkState) -> (||) $ k == k' && checkState /= FormSuccess False && (checkState /= FormMissing || maybe True snd (chosenRecipients' !? k))) False cState) $ Map.keysSet cState checkedIdent c = checkedIdentBase <> "-" <> toPathPiece c - hasContent c = not (null $ categoryIndices c) || Map.member (1, (EnumPosition c, 0)) addWdgts - categoryIndices c = Set.filter ((== c) . unEnumPosition . fst) $ review liveCoords liveliness + hasContent c = not (null $ categoryIndices c) || Map.member (1, (BoundedPosition c, 0)) addWdgts + categoryIndices c = Set.filter ((== c) . unBoundedPosition . fst) $ review liveCoords liveliness + rgTutorialParticipantsCaption :: CryptoUUIDTutorial -> Widget + rgTutorialParticipantsCaption cID = do + tutId <- decrypt cID + Tutorial{..} <- liftHandler . runDBRead $ get404 tutId + i18n $ MsgRGTutorialParticipants tutorialName $(widgetFile "widgets/communication/recipientLayout") - miDelete :: Map (EnumPosition RecipientCategory, ListPosition) (Either UserEmail UserId) -> (EnumPosition RecipientCategory, ListPosition) -> MaybeT (MForm Handler) (Map (EnumPosition RecipientCategory, ListPosition) (EnumPosition RecipientCategory, ListPosition)) - -- miDelete liveliness@(MapLiveliness lMap) (EnumPosition RecipientCustom, delPos) = mappend (Map.fromSet id . Set.filter (\(EnumPosition c, _) -> c /= RecipientCustom) $ review liveCoords liveliness) . fmap (EnumPosition RecipientCustom, ) . Map.mapKeysMonotonic (EnumPosition RecipientCustom, ) <$> miDeleteList (lMap ! EnumPosition RecipientCustom) delPos + miDelete :: Map (BoundedPosition RecipientCategory, ListPosition) (Either UserEmail UserId) -> (BoundedPosition RecipientCategory, ListPosition) -> MaybeT (MForm Handler) (Map (BoundedPosition RecipientCategory, ListPosition) (BoundedPosition RecipientCategory, ListPosition)) + -- miDelete liveliness@(MapLiveliness lMap) (BoundedPosition RecipientCustom, delPos) = mappend (Map.fromSet id . Set.filter (\(BoundedPosition c, _) -> c /= RecipientCustom) $ review liveCoords liveliness) . fmap (BoundedPosition RecipientCustom, ) . Map.mapKeysMonotonic (BoundedPosition RecipientCustom, ) <$> miDeleteList (lMap ! BoundedPosition RecipientCustom) delPos miDelete _ _ = mzero miIdent :: Text miIdent = "recipients" - postProcess :: Map (EnumPosition RecipientCategory, ListPosition) (Either UserEmail UserId, Bool) -> Set (Either UserEmail UserId) + postProcess :: Map (BoundedPosition RecipientCategory, ListPosition) (Either UserEmail UserId, Bool) -> Set (Either UserEmail UserId) postProcess = Set.fromList . map fst . filter snd . Map.elems recipientsListMsg <- messageI Info MsgCommRecipientsList diff --git a/src/Handler/Utils/Form/MassInput.hs b/src/Handler/Utils/Form/MassInput.hs index 21f7488e7..fd1a79895 100644 --- a/src/Handler/Utils/Form/MassInput.hs +++ b/src/Handler/Utils/Form/MassInput.hs @@ -13,6 +13,7 @@ module Handler.Utils.Form.MassInput , ListLength(..), ListPosition(..), miDeleteList , EnumLiveliness(..), EnumPosition(..) , MapLiveliness(..) + , LowerBounded(..), BoundedLiveliness(..), BoundedPosition(..) ) where import Import @@ -81,17 +82,10 @@ instance Liveliness ListLength where newtype EnumLiveliness enum = EnumLiveliness { unEnumLiveliness :: IntSet } deriving (Eq, Ord, Generic, Typeable, Read, Show) + deriving newtype (Lattice, BoundedJoinSemiLattice) makeWrapped ''EnumLiveliness -instance Lattice (EnumLiveliness enum) where - (EnumLiveliness a) \/ (EnumLiveliness b) = EnumLiveliness $ a `IntSet.union` b - (EnumLiveliness a) /\ (EnumLiveliness b) = EnumLiveliness $ a `IntSet.intersection` b -instance BoundedJoinSemiLattice (EnumLiveliness enum) where - bottom = EnumLiveliness IntSet.empty -instance (Enum enum, Bounded enum) => BoundedMeetSemiLattice (EnumLiveliness enum) where - top = EnumLiveliness . IntSet.fromList $ map (fromEnum :: enum -> Int) [minBound..maxBound] - newtype EnumPosition enum = EnumPosition { unEnumPosition :: enum } deriving newtype (Enum, Bounded, PathPiece, ToJSON, FromJSON, ToJSONKey, FromJSONKey) @@ -99,11 +93,11 @@ newtype EnumPosition enum = EnumPosition { unEnumPosition :: enum } makeWrapped ''EnumPosition -instance (Enum enum, Bounded enum, PathPiece enum, ToJSON enum, FromJSON enum, ToJSONKey enum, FromJSONKey enum, Eq enum, Ord enum) => IsBoxCoord (EnumPosition enum) where +instance (Bounded enum, PathPiece enum, ToJSON enum, FromJSON enum, ToJSONKey enum, FromJSONKey enum, Ord enum) => IsBoxCoord (EnumPosition enum) where boxDimensions = [BoxDimension _Wrapped] boxOrigin = minBound -instance (Enum enum, Bounded enum, PathPiece enum, ToJSON enum, FromJSON enum, ToJSONKey enum, FromJSONKey enum, Eq enum, Ord enum) => Liveliness (EnumLiveliness enum) where +instance (Enum enum, Bounded enum, PathPiece enum, ToJSON enum, FromJSON enum, ToJSONKey enum, FromJSONKey enum, Ord enum) => Liveliness (EnumLiveliness enum) where type BoxCoord (EnumLiveliness enum) = EnumPosition enum liveCoords = iso fromSet toSet where @@ -113,6 +107,28 @@ instance (Enum enum, Bounded enum, PathPiece enum, ToJSON enum, FromJSON enum, T fromSet = EnumLiveliness . IntSet.fromList . map fromEnum . Set.toList +class Ord coord => LowerBounded coord where + minBound' :: coord + +newtype BoundedLiveliness coord = BoundedLiveliness { unBoundedLiveliness :: Set coord } + deriving (Eq, Ord, Generic, Typeable, Read, Show) + deriving newtype (Lattice, BoundedJoinSemiLattice, BoundedMeetSemiLattice) +makeWrapped ''BoundedLiveliness + +newtype BoundedPosition coord = BoundedPosition { unBoundedPosition :: coord } + deriving newtype (Enum, Bounded, PathPiece, ToJSON, FromJSON, ToJSONKey, FromJSONKey, LowerBounded) + deriving (Eq, Ord, Generic, Typeable, Read, Show) +makeWrapped ''BoundedPosition + +instance (LowerBounded coord, PathPiece coord, ToJSON coord, FromJSON coord, ToJSONKey coord, FromJSONKey coord, Ord coord) => IsBoxCoord (BoundedPosition coord) where + boxDimensions = [BoxDimension _Wrapped] + boxOrigin = minBound' + +instance (LowerBounded coord, PathPiece coord, ToJSON coord, FromJSON coord, ToJSONKey coord, FromJSONKey coord, Ord coord) => Liveliness (BoundedLiveliness coord) where + type BoxCoord (BoundedLiveliness coord) = BoundedPosition coord + liveCoords = iso (Set.mapMonotonic $ view _Wrapped) (Set.mapMonotonic $ view _Unwrapped) . _Unwrapped + + newtype MapLiveliness l1 l2 = MapLiveliness { unMapLiveliness :: Map (BoxCoord l1) l2 } deriving (Generic, Typeable) diff --git a/src/Handler/Utils/Form/MassInput/Liveliness.hs b/src/Handler/Utils/Form/MassInput/Liveliness.hs index 14709ec9b..5e3e4cc75 100644 --- a/src/Handler/Utils/Form/MassInput/Liveliness.hs +++ b/src/Handler/Utils/Form/MassInput/Liveliness.hs @@ -20,7 +20,7 @@ import qualified Data.Set as Set import Data.List (genericLength, genericIndex) -data BoxDimension x = forall n. (Enum n, Eq n) => BoxDimension (Lens' x n) +data BoxDimension x = forall n. Eq n => BoxDimension (Lens' x n) class (ToJSON x, FromJSON x, ToJSONKey x, FromJSONKey x, PathPiece x, Eq x, Ord x) => IsBoxCoord x where boxDimensions :: [BoxDimension x] diff --git a/src/Utils/PathPiece.hs b/src/Utils/PathPiece.hs index 8d17bfc13..8c429964f 100644 --- a/src/Utils/PathPiece.hs +++ b/src/Utils/PathPiece.hs @@ -1,6 +1,7 @@ module Utils.PathPiece ( nullaryToPathPiece', nullaryToPathPiece , nullaryPathPiece', nullaryPathPiece, finitePathPiece + , derivePathPiece , splitCamel , camelToPathPiece, camelToPathPiece' , nameToPathPiece, nameToPathPiece' @@ -13,6 +14,7 @@ import ClassyPrelude.Yesod import Language.Haskell.TH import qualified Language.Haskell.TH.Syntax as TH (Lift(..)) +import Language.Haskell.TH.Datatype import Data.Universe import qualified Data.Text as Text @@ -85,6 +87,51 @@ finitePathPiece finiteType verbs = do , funD 'fromPathPiece [ clause [] (normalB $ return finExp) [] ] ] + +derivePathPiece :: Name -> (Text -> Text) -> Text -> DecsQ +derivePathPiece adt mangle joinPP = do + let mangle' = TH.lift . mangle . pack . nameBase + DatatypeInfo{..} <- reifyDatatype adt + mapName <- newName $ "pathPieceConstructorMap" <> nameBase adt + let + toClause ConstructorInfo{..} = do + vars <- mapM (const $ newName "x") constructorFields + clause [conP constructorName $ map varP vars] (normalB [e|Text.intercalate joinPP $ $(mangle' constructorName) : $(listE $ map (\v -> [e|toPathPiece $(varE v)|]) vars)|]) [] + fromClause = do + constrName <- newName "c" + argsName <- newName "args" + clause [viewP [e|Text.splitOn joinPP|] $ infixP (varP constrName) '(:) (varP argsName)] + (normalB [e|HashMap.lookup $(varE constrName) $(varE mapName) >>= ($ $(varE argsName))|]) + [] + finDecs = + [ pragInlD mapName NoInline FunLike AllPhases + , sigD mapName [t|HashMap Text ([Text] -> Maybe $(conT adt))|] + , funD mapName + [ clause [] (normalB finClause) [] ] + ] + where finClause = ([e|HashMap.fromList|] `appE`) . listE $ map listItem datatypeCons + listItem ConstructorInfo{..} = do + vars <- mapM (const $ newName "x") constructorFields + tupE [ mangle' constructorName + , lamCaseE + [ match (listP $ map varP vars) + (normalB $ case vars of + [] -> [e|Just $(conE constructorName)|] + v : vs -> foldl' (\acc v' -> [e|$(acc) <*> fromPathPiece $(varE v')|]) [e|$(conE constructorName) <$> fromPathPiece $(varE v)|] vs + ) + [] + , match wildP (normalB [e|Nothing|]) [] + ] + ] + sequence . (finDecs ++ ) . pure $ + instanceD (cxt []) [t|PathPiece $(conT adt)|] + [ funD 'toPathPiece + (map toClause datatypeCons) + , funD 'fromPathPiece + [ fromClause + , clause [wildP] (normalB [e|Nothing|]) [] + ] + ] splitCamel :: Textual t => t -> [t] diff --git a/templates/widgets/communication/recipientLayout.hamlet b/templates/widgets/communication/recipientLayout.hamlet index 5f47cb7b7..e7e278f13 100644 --- a/templates/widgets/communication/recipientLayout.hamlet +++ b/templates/widgets/communication/recipientLayout.hamlet @@ -5,7 +5,19 @@ $if not (null activeCategories)