diff --git a/messages/uniworx/de.msg b/messages/uniworx/de.msg index 8d51a6547..6558b7e75 100644 --- a/messages/uniworx/de.msg +++ b/messages/uniworx/de.msg @@ -89,6 +89,7 @@ CourseRegisterToTip: Anmeldung darf auch unbegrenzt offen bleiben CourseDeregisterUntilTip: Abmeldung darf auch unbegrenzt erlaubt bleiben CourseFilterSearch: Volltext-Suche CourseFilterRegistered: Registriert +CourseFilterNone: Egal CourseDeleteQuestion: Wollen Sie den unten aufgeführten Kurs wirklich löschen? CourseDeleted: Kurs gelöscht CourseUserNote: Notiz diff --git a/models/system-messages b/models/system-messages index 0547718ae..0ceec9223 100644 --- a/models/system-messages +++ b/models/system-messages @@ -2,7 +2,7 @@ SystemMessage from UTCTime Maybe to UTCTime Maybe authenticatedOnly Bool - severity MessageClass + severity MessageStatus defaultLanguage Lang content Html summary Html Maybe diff --git a/shell.nix b/shell.nix index 931e7ade0..e6178f7b0 100644 --- a/shell.nix +++ b/shell.nix @@ -1,11 +1,8 @@ -{ nixpkgs ? import {}, compiler ? null }: +{ nixpkgs ? import , compiler ? null }: let - inherit (nixpkgs) pkgs; - - haskellPackages = if isNull compiler - then pkgs.haskellPackages - else pkgs.haskell.packages."${compiler}"; + inherit (nixpkgs {}) pkgs; + haskellPackages = if isNull compiler then pkgs.haskellPackages else pkgs.haskell.packages."${compiler}"; drv = haskellPackages.callPackage ./uniworx.nix {}; @@ -26,21 +23,29 @@ let shellHook = '' export PROMPT_INFO="${oldAttrs.name}" - pgDir=$(mktemp -d) - pgSockDir=$(mktemp -d) - pgLogFile=$(mktemp) - initdb --no-locale -D ''${pgDir} - pg_ctl start -D ''${pgDir} -l ''${pgLogFile} -w -o "-k ''${pgSockDir} -c listen_addresses=''' -c hba_file='${postgresHba}' -c unix_socket_permissions=0700" - export PGHOST=''${pgSockDir} PGLOG=''${pgLogFile} - psql -f ${postgresSchema} postgres - printf "Postgres logfile is %s\nPostgres socket directory is %s\n" ''${pgLogFile} ''${pgSockDir} + if [[ -z "$PGHOST" ]]; then + set -xe - cleanup() { - pg_ctl stop -D ''${pgDir} - rm -rvf ''${pgDir} ''${pgSockDir} ''${pgLogFile} - } + pgDir=$(mktemp -d) + pgSockDir=$(mktemp -d) + pgLogFile=$(mktemp) + initdb --no-locale -D ''${pgDir} + pg_ctl start -D ''${pgDir} -l ''${pgLogFile} -w -o "-k ''${pgSockDir} -c listen_addresses=''' -c hba_file='${postgresHba}' -c unix_socket_permissions=0700" + export PGHOST=''${pgSockDir} PGLOG=''${pgLogFile} + psql -f ${postgresSchema} postgres + printf "Postgres logfile is %s\nPostgres socket directory is %s\n" ''${pgLogFile} ''${pgSockDir} - trap cleanup EXIT + cleanup() { + set +e -x + pg_ctl stop -D ''${pgDir} + rm -rvf ''${pgDir} ''${pgSockDir} ''${pgLogFile} + set +x + } + + trap cleanup EXIT + + set +xe + fi ${oldAttrs.shellHook} ''; diff --git a/src/Foundation.hs b/src/Foundation.hs index e688b03bb..6e91611f3 100644 --- a/src/Foundation.hs +++ b/src/Foundation.hs @@ -220,7 +220,7 @@ instance RenderMessage UniWorX MsgLanguage where instance RenderMessage UniWorX (UnsupportedAuthPredicate (Route UniWorX)) where renderMessage f ls (UnsupportedAuthPredicate tag route) = renderMessage f ls $ MsgUnsupportedAuthPredicate tag (show route) -embedRenderMessage ''UniWorX ''MessageClass ("Message" <>) +embedRenderMessage ''UniWorX ''MessageStatus ("Message" <>) embedRenderMessage ''UniWorX ''NotificationTrigger $ ("NotificationTrigger" <>) . concat . drop 1 . splitCamel embedRenderMessage ''UniWorX ''StudyFieldType id embedRenderMessage ''UniWorX ''SheetFileType id @@ -1021,6 +1021,7 @@ siteLayout' headingOverride widget = do addScript $ StaticR js_utils_asidenav_js addScript $ StaticR js_utils_asyncForm_js addScript $ StaticR js_utils_asyncTable_js + addScript $ StaticR js_utils_asyncTableFilter_js addScript $ StaticR js_utils_checkAll_js addScript $ StaticR js_utils_httpClient_js addScript $ StaticR js_utils_form_js @@ -1032,9 +1033,13 @@ siteLayout' headingOverride widget = do addStylesheet $ StaticR css_utils_alerts_scss addStylesheet $ StaticR css_utils_asidenav_scss addStylesheet $ StaticR css_utils_asyncForm_scss + addStylesheet $ StaticR css_utils_asyncTable_scss + addStylesheet $ StaticR css_utils_asyncTableFilter_scss + addStylesheet $ StaticR css_utils_checkbox_scss addStylesheet $ StaticR css_utils_form_scss addStylesheet $ StaticR css_utils_inputs_scss addStylesheet $ StaticR css_utils_modal_scss + addStylesheet $ StaticR css_utils_radio_scss addStylesheet $ StaticR css_utils_showHide_scss addStylesheet $ StaticR css_utils_tabber_scss addStylesheet $ StaticR css_utils_tooltip_scss diff --git a/src/Handler/Admin.hs b/src/Handler/Admin.hs index 501cc97b9..946310640 100644 --- a/src/Handler/Admin.hs +++ b/src/Handler/Admin.hs @@ -20,6 +20,8 @@ import Database.Persist.Sql (fromSqlKey) -- import qualified Data.UUID.Cryptographic as UUID +import Control.Monad.Trans.Writer (mapWriterT) + -- BEGIN - Buttons needed only here data ButtonCreate = CreateMath | CreateInf -- Dummy for Example deriving (Enum, Eq, Ord, Bounded, Read, Show, Generic, Typeable) @@ -84,15 +86,12 @@ postAdminTestR = do _other -> addMessage Warning "KEIN Knopf erkannt" ((emailResult, emailWidget), emailEnctype) <- runFormPost . identifyForm "email" $ renderAForm FormStandard emailTestForm - case emailResult of - (FormSuccess (email, ls)) -> do - jId <- runDB $ do - jId <- queueJob $ JobSendTestEmail email ls - addMessage Success [shamlet|Email-test gestartet (Job ##{tshow (fromSqlKey jId)})|] - return jId - writeJobCtl $ JobCtlPerform jId - FormMissing -> return () - (FormFailure errs) -> forM_ errs $ addMessage Error . toHtml + formResultModal emailResult AdminTestR $ \(email, ls) -> do + jId <- mapWriterT runDB $ do + jId <- queueJob $ JobSendTestEmail email ls + tell . pure $ Message Success [shamlet|Email-test gestartet (Job ##{tshow (fromSqlKey jId)})|] + return jId + writeJobCtl $ JobCtlPerform jId let emailWidget' = [whamlet|
diff --git a/src/Handler/Corrections.hs b/src/Handler/Corrections.hs index af5eb3e23..47f6f8a74 100644 --- a/src/Handler/Corrections.hs +++ b/src/Handler/Corrections.hs @@ -85,22 +85,22 @@ submissionModeIs sMode ((_course `E.InnerJoin` sheet `E.InnerJoin` _submission) -- Columns -colTerm :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colTerm :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colTerm = sortable (Just "term") (i18nCell MsgTerm) $ \DBRow{ dbrOutput=(_, _, course, _, _) } -> -- cell [whamlet| _{untermKey $ course ^. _3}|] -- lange, internationale Semester textCell $ termToText $ unTermKey $ course ^. _3 -- kurze Semsterkürzel -colSchool :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colSchool :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colSchool = sortable (Just "school") (i18nCell MsgCourseSchool) $ \DBRow{ dbrOutput=(_, _, course, _, _) } -> anchorCell (TermSchoolCourseListR (course ^. _3) (course ^. _4)) [whamlet|#{unSchoolKey (course ^. _4)}|] -colCourse :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colCourse :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colCourse = sortable (Just "course") (i18nCell MsgCourse) $ \DBRow{ dbrOutput=(_, _, (_,csh,tid,sid), _, _) } -> courseCellCL (tid,sid,csh) -colSheet :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colSheet :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colSheet = sortable (Just "sheet") (i18nCell MsgSheet) $ \DBRow{ dbrOutput=(_, sheet, course, _, _) } -> let tid = course ^. _3 @@ -109,16 +109,16 @@ colSheet = sortable (Just "sheet") (i18nCell MsgSheet) shn = sheetName $ entityVal sheet in anchorCell (CSheetR tid ssh csh shn SShowR) [whamlet|#{display shn}|] -colSheetType :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colSheetType :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colSheetType = sortable (toNothing "sheetType") (i18nCell MsgSheetType) $ \DBRow{ dbrOutput=(_, sheet, _, _, _) } -> i18nCell . sheetType $ entityVal sheet -colCorrector :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colCorrector :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colCorrector = sortable (Just "corrector") (i18nCell MsgCorrector) $ \case DBRow{ dbrOutput = (_, _, _, Nothing , _) } -> cell mempty DBRow{ dbrOutput = (_, _, _, Just (Entity _ User{..}), _) } -> userCell userDisplayName userSurname -colSubmissionLink :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colSubmissionLink :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colSubmissionLink = sortable Nothing (i18nCell MsgSubmission) $ \DBRow{ dbrOutput=(submission, sheet, course, _, _) } -> let csh = course ^. _2 @@ -134,7 +134,7 @@ colSubmissionLink = sortable Nothing (i18nCell MsgSubmission) colSelect :: forall act h. (Monoid act, Headedness h) => Colonnade h CorrectionTableData (DBCell _ (FormResult (act, DBFormResult CryptoFileNameSubmission Bool CorrectionTableData), SheetTypeSummary)) colSelect = dbSelect (_1 . applying _2) id $ \DBRow{ dbrOutput=(Entity subId _, _, _, _, _) } -> encrypt subId -colSubmittors :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colSubmittors :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colSubmittors = sortable (Just "submittors") (i18nCell MsgSubmissionUsers) $ \DBRow{ dbrOutput=(_, _, course, _, users) } -> let csh = course ^. _2 tid = course ^. _3 @@ -146,7 +146,7 @@ colSubmittors = sortable (Just "submittors") (i18nCell MsgSubmissionUsers) $ \DB Just p -> [whamlet|^{nameWidget userDisplayName userSurname} (#{review _PseudonymText p})|] in protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] -colSMatrikel :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colSMatrikel :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colSMatrikel = sortable Nothing (i18nCell MsgMatrikelNr) $ \DBRow{ dbrOutput=(_, _, _, _, users) } -> let protoCell = listCell (Map.toList users) $ \(userId, (User{..}, _)) -> anchorCellM (AdminUserR <$> encrypt userId) (maybe mempty toWidget userMatrikelnummer) in protoCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] @@ -170,26 +170,26 @@ colRating = sortable (Just "rating") (i18nCell MsgRating) $ \DBRow{ dbrOutput=(E scribe (_2 :: Lens' (a, SheetTypeSummary) SheetTypeSummary) summary ] -colAssigned :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colAssigned :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colAssigned = sortable (Just "assignedtime") (i18nCell MsgAssignedTime) $ \DBRow{ dbrOutput=(Entity _subId Submission{..}, _sheet, _course, _, _) } -> maybe mempty dateTimeCell submissionRatingAssigned -colRated :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colRated :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colRated = sortable (Just "ratingtime") (i18nCell MsgRatingTime) $ \DBRow{ dbrOutput=(Entity _subId Submission{..}, _sheet, _course, _, _) } -> maybe mempty dateTimeCell submissionRatingTime -colPseudonyms :: IsDBTable m a => Colonnade _ CorrectionTableData (DBCell m a) +colPseudonyms :: IsDBTable m a => Colonnade Sortable CorrectionTableData (DBCell m a) colPseudonyms = sortable Nothing (i18nCell MsgPseudonyms) $ \DBRow{ dbrOutput=(_, _, _, _, users) } -> let lCell = listCell (catMaybes $ snd . snd <$> Map.toList users) $ \pseudo -> cell [whamlet|#{review _PseudonymText pseudo}|] in lCell & cellAttrs <>~ [("class", "list--inline list--comma-separated")] -colRatedField :: Colonnade _ CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (Bool, a, b) CorrectionTableData))) +colRatedField :: Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (Bool, a, b) CorrectionTableData))) colRatedField = sortable Nothing (i18nCell MsgRatingDone) $ formCell id (\DBRow{ dbrOutput=(Entity subId _, _, _, _, _) } -> return subId) (\DBRow{ dbrOutput=(Entity _ (submissionRatingDone -> done), _, _, _, _) } mkUnique -> over (_1.mapped) (_1 .~) . over _2 fvInput <$> mreq checkBoxField (fsUniq mkUnique "rated") (Just done)) -colPointsField :: Colonnade _ CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (a, Maybe Points, b) CorrectionTableData))) +colPointsField :: Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (a, Maybe Points, b) CorrectionTableData))) colPointsField = sortable (Just "rating") (i18nCell MsgColumnRatingPoints) $ formCell id (\DBRow{ dbrOutput=(Entity subId _, _, _, _, _) } -> return subId) (\DBRow{ dbrOutput=(Entity _ Submission{..}, Entity _ Sheet{..}, _, _, _) } mkUnique -> case sheetType of @@ -197,7 +197,7 @@ colPointsField = sortable (Just "rating") (i18nCell MsgColumnRatingPoints) $ for _other -> over (_1.mapped) (_2 .~) . over _2 fvInput <$> mopt pointsField (fsUniq mkUnique "points") (Just submissionRatingPoints) ) -colCommentField :: Colonnade _ CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (a, b, Maybe Text) CorrectionTableData))) +colCommentField :: Colonnade Sortable CorrectionTableData (DBCell _ (FormResult (DBFormResult SubmissionId (a, b, Maybe Text) CorrectionTableData))) colCommentField = sortable Nothing (i18nCell MsgRatingComment) $ formCell id (\DBRow{ dbrOutput=(Entity subId _, _, _, _, _) } -> return subId) (\DBRow{ dbrOutput=(Entity _ Submission{..}, _, _, _, _) } mkUnique -> over (_1.mapped) ((_3 .~) . assertM (not . null) . fmap (Text.strip . unTextarea)) . over _2 fvInput <$> mopt textareaField (fsUniq mkUnique "comment") (Just $ Textarea <$> submissionRatingComment)) @@ -237,6 +237,9 @@ makeCorrectionsTable whereClause dbtColonnade dbtFilterUI psValidator dbtProj' d [ ( "term" , SortColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _) -> course E.^. CourseTerm ) + , ( "school" + , SortColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _) -> course E.^. CourseSchool + ) , ( "course" , SortColumn $ \((course `E.InnerJoin` _ `E.InnerJoin` _) `E.LeftOuterJoin` _) -> course E.^. CourseShorthand ) diff --git a/src/Handler/Course.hs b/src/Handler/Course.hs index 3305be5a4..3c303850d 100644 --- a/src/Handler/Course.hs +++ b/src/Handler/Course.hs @@ -33,7 +33,7 @@ import qualified Database.Esqueleto as E -- NOTE: Outdated way to use dbTable; see ProfileDataR Handler for a more recent method. type CourseTableData = DBRow (Entity Course, Int64, Bool, Entity School) -colCourse :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colCourse :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colCourse = sortable (Just "course") (i18nCell MsgCourse) $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> anchorCell (CourseR courseTerm courseSchool courseShorthand CShowR) @@ -51,12 +51,12 @@ colDescription = sortable Nothing mempty Nothing -> mempty (Just descr) -> cell $ modal (toWidget $ hasComment True) (Right $ toWidget descr) -colCShort :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colCShort :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colCShort = sortable (Just "cshort") (i18nCell MsgCourseShort) $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> anchorCell (CourseR courseTerm courseSchool courseShorthand CShowR) [whamlet|#{display courseShorthand}|] --- colCShortDescr :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +-- colCShortDescr :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) -- colCShortDescr = sortable (Just "cshort") (i18nCell MsgCourseShort) -- $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> mappend -- ( anchorCell (CourseR courseTerm courseSchool courseShorthand CShowR) [whamlet|#{display courseShorthand}|] ) @@ -70,39 +70,39 @@ colCShort = sortable (Just "cshort") (i18nCell MsgCourseShort) -- |] -- ) -colTerm :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colTerm :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colTerm = sortable (Just "term") (i18nCell MsgTerm) $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> anchorCell (TermCourseListR courseTerm) [whamlet|#{display courseTerm}|] -colSchool :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colSchool :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colSchool = sortable (Just "school") (i18nCell MsgCourseSchool) $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, Entity _ School{..}) } -> anchorCell (TermSchoolCourseListR courseTerm courseSchool) [whamlet|#{display schoolName}|] -colSchoolShort :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colSchoolShort :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colSchoolShort = sortable (Just "schoolshort") (i18nCell MsgCourseSchoolShort) $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, Entity _ School{..}) } -> anchorCell (TermSchoolCourseListR courseTerm courseSchool) [whamlet|#{display schoolShorthand}|] -colRegFrom :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colRegFrom :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colRegFrom = sortable (Just "register-from") (i18nCell MsgRegisterFrom) $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> maybe mempty dateTimeCell courseRegisterFrom -- cell $ traverse (formatTime SelFormatDateTime) courseRegisterFrom >>= maybe mempty toWidget -colRegTo :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colRegTo :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colRegTo = sortable (Just "register-to") (i18nCell MsgRegisterTo) $ \DBRow{ dbrOutput=(Entity _ Course{..}, _, _, _) } -> maybe mempty dateTimeCell courseRegisterTo -colMembers :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colMembers :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colMembers = sortable (Just "members") (i18nCell MsgCourseMembers) $ \DBRow{ dbrOutput=(Entity _ Course{..}, currentParticipants, _, _) } -> i18nCell $ case courseCapacity of Nothing -> MsgCourseMembersCount currentParticipants Just limit -> MsgCourseMembersCountLimited currentParticipants limit -colRegistered :: IsDBTable m a => Colonnade _ CourseTableData (DBCell m a) +colRegistered :: IsDBTable m a => Colonnade Sortable CourseTableData (DBCell m a) colRegistered = sortable (Just "registered") (i18nCell MsgRegistered) $ \DBRow{ dbrOutput=(_, _, registered, _) } -> tickmarkCell registered diff --git a/src/Handler/Term.hs b/src/Handler/Term.hs index d6accb27c..784486f91 100644 --- a/src/Handler/Term.hs +++ b/src/Handler/Term.hs @@ -145,7 +145,7 @@ getTermShowR = do , dbtIdent = "terms" :: Text } defaultLayout $ do - setTitle "Freigeschaltete Semester" + setTitleI MsgTermsHeading $(widgetFile "terms") getTermEditR :: Handler Html diff --git a/src/Handler/Utils/Form.hs b/src/Handler/Utils/Form.hs index 6571849fb..f974c5a61 100644 --- a/src/Handler/Utils/Form.hs +++ b/src/Handler/Utils/Form.hs @@ -522,6 +522,30 @@ secretJsonField = Field{..} |] fieldEnctype = UrlEncoded +boolField :: ( MonadHandler m + , HandlerSite m ~ UniWorX + ) + => Field m Bool +boolField = Field + { fieldParse = \e _ -> return $ boolParser e + , fieldView = \theId name attrs val isReq -> $(widgetFile "widgets/fields/bool") + , fieldEnctype = UrlEncoded + } + where + boolParser [] = Right Nothing + boolParser (x:_) = case x of + "" -> Right Nothing + "none" -> Right Nothing + "yes" -> Right $ Just True + "on" -> Right $ Just True + "no" -> Right $ Just False + "true" -> Right $ Just True + "false" -> Right $ Just False + t -> Left $ SomeMessage $ MsgInvalidBool t + showVal = either $ const False + + + funcForm :: forall k v m. ( Finite k, Ord k @@ -671,5 +695,5 @@ formResultModal res finalDest handler = maybeT_ $ do if | isModal -> sendResponse $ toJSON messages | otherwise -> do - forM_ messages $ \Message{..} -> addMessage messageClass messageContent + forM_ messages $ \Message{..} -> addMessage messageStatus messageContent redirect finalDest diff --git a/src/Import/NoFoundation.hs b/src/Import/NoFoundation.hs index 0c6b55264..f044ca557 100644 --- a/src/Import/NoFoundation.hs +++ b/src/Import/NoFoundation.hs @@ -3,7 +3,7 @@ module Import.NoFoundation , MForm ) where -import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static) +import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static, boolField) import Model as Import import Model.Types.JSON as Import import Model.Migration as Import diff --git a/src/Model.hs b/src/Model.hs index 92df5772e..3fabff444 100644 --- a/src/Model.hs +++ b/src/Model.hs @@ -20,8 +20,8 @@ import Data.CaseInsensitive (CI) import Data.CaseInsensitive.Instances () import Text.Blaze (ToMarkup, toMarkup, Markup) +import Utils.Message (MessageStatus) -import Utils.Message (MessageClass) import Settings.Cluster (ClusterSettingsKey) import Data.Binary (Binary) diff --git a/src/Settings.hs b/src/Settings.hs index 81f98eb45..f717ee378 100644 --- a/src/Settings.hs +++ b/src/Settings.hs @@ -39,7 +39,7 @@ import qualified Data.Text.Encoding as Text import qualified Ldap.Client as Ldap -import Utils hiding (MessageClass(..)) +import Utils hiding (MessageStatus(..)) import Control.Lens import Data.Maybe (fromJust) diff --git a/src/Utils/Message.hs b/src/Utils/Message.hs index 7cf7f653f..69ce9e45e 100644 --- a/src/Utils/Message.hs +++ b/src/Utils/Message.hs @@ -1,6 +1,6 @@ module Utils.Message - ( MessageClass(..) - , UnknownMessageClass(..) + ( MessageStatus(..) + , UnknownMessageStatus(..) , addMessage, addMessageI, addMessageIHamlet, addMessageFile, addMessageWidget , Message(..) , messageI, messageIHamlet, messageFile, messageWidget @@ -25,64 +25,64 @@ import Text.Blaze.Html.Renderer.Text (renderHtml) import Text.HTML.SanitizeXSS (sanitizeBalance) -data MessageClass = Error | Warning | Info | Success +data MessageStatus = Error | Warning | Info | Success deriving (Eq, Ord, Enum, Bounded, Show, Read, Lift) -instance Universe MessageClass -instance Finite MessageClass +instance Universe MessageStatus +instance Finite MessageStatus deriveJSON defaultOptions { constructorTagModifier = camelToPathPiece - } ''MessageClass + } ''MessageStatus -nullaryPathPiece ''MessageClass camelToPathPiece -derivePersistField "MessageClass" +nullaryPathPiece ''MessageStatus camelToPathPiece +derivePersistField "MessageStatus" -newtype UnknownMessageClass = UnknownMessageClass Text +newtype UnknownMessageStatus = UnknownMessageStatus Text deriving (Eq, Ord, Read, Show, Generic, Typeable) -instance Exception UnknownMessageClass +instance Exception UnknownMessageStatus data Message = Message - { messageClass :: MessageClass + { messageStatus :: MessageStatus , messageContent :: Html } instance Eq Message where - a == b = ((==) `on` messageClass) a b && ((==) `on` renderHtml . messageContent) a b + a == b = ((==) `on` messageStatus) a b && ((==) `on` renderHtml . messageContent) a b instance Ord Message where - a `compare` b = (compare `on` messageClass) a b `mappend` (compare `on` renderHtml . messageContent) a b + a `compare` b = (compare `on` messageStatus) a b `mappend` (compare `on` renderHtml . messageContent) a b instance ToJSON Message where toJSON Message{..} = object - [ "class" .= messageClass + [ "status" .= messageStatus , "content" .= renderHtml messageContent ] instance FromJSON Message where parseJSON = withObject "Message" $ \o -> do - messageClass <- o .: "class" + messageStatus <- o .: "status" messageContent <- preEscapedText . sanitizeBalance <$> o .: "content" return Message{..} -addMessage :: MonadHandler m => MessageClass -> Html -> m () +addMessage :: MonadHandler m => MessageStatus -> Html -> m () addMessage mc = ClassyPrelude.Yesod.addMessage (toPathPiece mc) -addMessageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageClass -> msg -> m () +addMessageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageStatus -> msg -> m () addMessageI mc = ClassyPrelude.Yesod.addMessageI (toPathPiece mc) -messageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageClass -> msg -> m Message -messageI messageClass msg = do +messageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageStatus -> msg -> m Message +messageI messageStatus msg = do messageContent <- toHtml . ($ msg) <$> getMessageRender return Message{..} addMessageIHamlet :: ( MonadHandler m , RenderMessage (HandlerSite m) msg , HandlerSite m ~ site - ) => MessageClass -> HtmlUrlI18n msg (Route site) -> m () + ) => MessageStatus -> HtmlUrlI18n msg (Route site) -> m () addMessageIHamlet mc iHamlet = do mr <- getMessageRender ClassyPrelude.Yesod.addMessage (toPathPiece mc) =<< withUrlRenderer (iHamlet $ toHtml . mr) @@ -90,22 +90,22 @@ addMessageIHamlet mc iHamlet = do messageIHamlet :: ( MonadHandler m , RenderMessage (HandlerSite m) msg , HandlerSite m ~ site - ) => MessageClass -> HtmlUrlI18n msg (Route site) -> m Message + ) => MessageStatus -> HtmlUrlI18n msg (Route site) -> m Message messageIHamlet mc iHamlet = do mr <- getMessageRender Message mc <$> withUrlRenderer (iHamlet $ toHtml . mr) -addMessageFile :: MessageClass -> FilePath -> ExpQ +addMessageFile :: MessageStatus -> FilePath -> ExpQ addMessageFile mc tPath = [e|addMessageIHamlet mc $(ihamletFile tPath)|] -messageFile :: MessageClass -> FilePath -> ExpQ +messageFile :: MessageStatus -> FilePath -> ExpQ messageFile mc tPath = [e|messageIHamlet mc $(ihamletFile tPath)|] addMessageWidget :: forall m site. ( MonadHandler m , HandlerSite m ~ site , Yesod site - ) => MessageClass -> WidgetT site IO () -> m () + ) => MessageStatus -> WidgetT site IO () -> m () -- ^ _Note_: `addMessageWidget` ignores `pageTitle` and `pageHead` addMessageWidget mc wgt = do PageContent{pageBody} <- liftHandlerT $ widgetToPageContent wgt @@ -115,7 +115,7 @@ messageWidget :: forall m site. ( MonadHandler m , HandlerSite m ~ site , Yesod site - ) => MessageClass -> WidgetT site IO () -> m Message + ) => MessageStatus -> WidgetT site IO () -> m Message messageWidget mc wgt = do PageContent{pageBody} <- liftHandlerT $ widgetToPageContent wgt messageIHamlet mc (const pageBody :: HtmlUrlI18n (SomeMessage site) (Route site)) diff --git a/stack.nix b/stack.nix index 7a8a0dcbf..e986ba349 100644 --- a/stack.nix +++ b/stack.nix @@ -1,8 +1,14 @@ -{ ghc, nixpkgs ? (import {}) }: +{ ghc, nixpkgs ? import }: let - inherit (nixpkgs) haskell pkgs; - haskellPackages = if ghc.version == pkgs.haskellPackages.ghc.version then pkgs.haskellPackages else pkgs.haskell.packages."ghc${builtins.replaceStrings ["."] [""] ghc.version}"; + snapshot = "lts-10.5"; + stackage = import (fetchTarball { + url = "https://stackage.serokell.io/drczwlyf6mi0ilh3kgv01wxwjfgvq14b-stackage/default.nix.tar.gz"; + sha256 = "1bwlbxx6np0jfl6z9gkmmcq22crm0pa07a8zrwhz5gkal64y6jpz"; + }); + inherit (nixpkgs { overlays = [ stackage."${snapshot}" ]; }) haskell pkgs; + + haskellPackages = pkgs.haskell.packages."${snapshot}"; in haskell.lib.buildStackProject { inherit ghc; name = "stackenv"; diff --git a/stack.yaml b/stack.yaml index c4f2d4dba..94be126d8 100644 --- a/stack.yaml +++ b/stack.yaml @@ -17,6 +17,10 @@ packages: git: https://github.com/pngwjpgh/encoding.git commit: 67bb87ceff53f0178c988dd4e15eeb2daee92b84 extra-dep: true + - location: + git: https://github.com/pngwjpgh/memcached-binary.git + commit: b5461747e7be226d3b67daebc3c9aefe8a4490ad + extra-dep: true extra-deps: - colonnade-1.2.0 @@ -45,8 +49,4 @@ extra-deps: - quickcheck-classes-0.4.14 - semirings-0.2.1.1 - - memcached-binary-0.2.0 - -allow-newer: true - resolver: lts-10.5 diff --git a/static/css/utils/asidenav.scss b/static/css/utils/asidenav.scss index 51fe73163..1ac580d58 100644 --- a/static/css/utils/asidenav.scss +++ b/static/css/utils/asidenav.scss @@ -57,7 +57,7 @@ padding-left: 10px; &.js-show-hide__toggle::before { - top: 25px; + z-index: 1; } } } @@ -103,7 +103,6 @@ &::before { left: auto; right: 20px; - top: 30px; color: var(--color-font); } } @@ -314,6 +313,10 @@ word-break: break-all; background-color: var(--color-dark); color: var(--color-lightwhite); + + &:hover { + background-color: var(--color-dark); + } } .asidenav__link-shorthand { diff --git a/static/css/utils/asyncForm.scss b/static/css/utils/asyncForm.scss index cd4fe3159..a0f9956dd 100644 --- a/static/css/utils/asyncForm.scss +++ b/static/css/utils/asyncForm.scss @@ -1,9 +1,78 @@ .async-form-response { margin: 20px 0; + position: relative; + width: 100%; + font-size: 18px; + text-align: center; + padding-top: 60px; +} + +.async-form-response::before, +.async-form-response::after { + position: absolute; + top: 0px; + left: 50%; + display: block; +} + +.async-form-response--success::before { + content: ''; + width: 17px; + height: 28px; + border: solid #069e04; + border-width: 0 5px 5px 0; + transform: translateX(-50%) rotate(45deg); +} + +.async-form-response--info::before { + content: ''; + width: 5px; + height: 30px; + top: 10px; + background-color: #777; + transform: translateX(-50%); +} +.async-form-response--info::after { + content: ''; + width: 5px; + height: 5px; + background-color: #777; + transform: translateX(-50%); +} + +.async-form-response--warning::before { + content: ''; + width: 5px; + height: 30px; + background-color: rgb(255, 187, 0); + transform: translateX(-50%); +} +.async-form-response--warning::after { + content: ''; + width: 5px; + height: 5px; + top: 35px; + background-color: rgb(255, 187, 0); + transform: translateX(-50%); +} + +.async-form-response--error::before { + content: ''; + width: 5px; + height: 40px; + background-color: #940d0d; + transform: translateX(-50%) rotate(-45deg); +} +.async-form-response--error::after { + content: ''; + width: 5px; + height: 40px; + background-color: #940d0d; + transform: translateX(-50%) rotate(45deg); } .async-form-loading { opacity: 0.1; - transition: opacity 800ms ease-in-out; + transition: opacity 800ms ease-out; pointer-events: none; } diff --git a/static/css/utils/asyncTable.scss b/static/css/utils/asyncTable.scss new file mode 100644 index 000000000..9766daa84 --- /dev/null +++ b/static/css/utils/asyncTable.scss @@ -0,0 +1,5 @@ +.async-table--loading { + opacity: 0.7; + pointer-events: none; + transition: opacity 400ms ease-out; +} diff --git a/static/css/utils/asyncTableFilter.scss b/static/css/utils/asyncTableFilter.scss new file mode 100644 index 000000000..794240f3f --- /dev/null +++ b/static/css/utils/asyncTableFilter.scss @@ -0,0 +1,5 @@ +.async-table-filter--loading { + opacity: 0.7; + pointer-events: none; + transition: opacity 400ms ease-out; +} diff --git a/static/css/utils/checkbox.scss b/static/css/utils/checkbox.scss new file mode 100644 index 000000000..4cab1568e --- /dev/null +++ b/static/css/utils/checkbox.scss @@ -0,0 +1,74 @@ + +/* CUSTOM CHECKBOXES */ +/* Completely replaces legacy checkbox */ +.checkbox { + position: relative; + display: inline-block; + + [type="checkbox"] { + position: fixed; + top: -1px; + left: -1px; + width: 1px; + height: 1px; + overflow: hidden; + } + + label { + display: block; + height: 24px; + width: 24px; + background-color: #f3f3f3; + box-shadow: inset 0 1px 2px 1px rgba(50, 50, 50, 0.05); + border: 2px solid var(--color-primary); + border-radius: 4px; + color: white; + cursor: pointer; + } + + label::before, + label::after { + position: absolute; + display: block; + top: 12px; + left: 8px; + height: 2px; + width: 8px; + background-color: var(--color-font); + } + + :checked + label { + background-color: var(--color-primary); + } + + [type="checkbox"]:focus + label { + border-color: #3273dc; + box-shadow: 0 0 0 0.125em rgba(50,115,220,.25); + outline: 0; + } + + :checked + label::before, + :checked + label::after { + content: ''; + } + + :checked + label::before { + background-color: white; + transform: rotate(45deg); + left: 4px; + } + + :checked + label::after { + background-color: white; + transform: rotate(-45deg); + top: 11px; + width: 13px; + } + + [disabled] + label { + pointer-events: none; + border: none; + opacity: 0.6; + filter: grayscale(1); + } +} diff --git a/static/css/utils/inputs.scss b/static/css/utils/inputs.scss index f30155892..6bf5286e3 100644 --- a/static/css/utils/inputs.scss +++ b/static/css/utils/inputs.scss @@ -95,7 +95,6 @@ select { width: 100%; max-width: 600px; - -webkit-appearance: none; align-items: center; border: 1px solid transparent; border-radius: 4px; @@ -162,6 +161,11 @@ textarea { } /* OPTIONS */ + +select { + -webkit-appearance: menulist; +} + select, option { font-size: 1rem; @@ -171,7 +175,8 @@ option { border-radius: 2px; outline: 0; color: #363636; - min-width: 200px; + min-width: 250px; + width: auto; background-color: #f3f3f3; box-shadow: inset 0 1px 2px 1px rgba(50,50,50,.05); } @@ -183,130 +188,6 @@ option { } } -/* CUSTOM LEGACY CHECKBOX AND RADIO BOXES */ -input[type="checkbox"] { - position: relative; - height: 20px; - width: 20px; - -webkit-appearance: none; - appearance: none; - cursor: pointer; -} -input[type="checkbox"]::before { - content: ''; - position: absolute; - width: 20px; - height: 20px; - background-color: var(--color-lighter); - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; -} -input[type="checkbox"]:checked::before { - background-color: var(--color-light); -} -input[type="checkbox"]:checked::after { - content: '✓'; - position: absolute; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - color: white; - font-size: 20px; -} - -/* CUSTOM CHECKBOXES AND RADIO BOXES */ -/* Completely replaces legacy checkbox and radiobox */ -.checkbox, -.radio { - position: relative; - display: inline-block; - - [type="checkbox"], - [type="radio"] { - position: fixed; - top: -1px; - left: -1px; - width: 1px; - height: 1px; - overflow: hidden; - } - - label { - display: block; - height: 24px; - width: 24px; - background-color: #f3f3f3; - box-shadow: inset 0 1px 2px 1px rgba(50, 50, 50, 0.05); - border: 2px solid var(--color-primary); - border-radius: 4px; - color: white; - cursor: pointer; - } - - label::before, - label::after { - position: absolute; - display: block; - top: 12px; - left: 8px; - height: 2px; - width: 8px; - background-color: var(--color-font); - } - - :checked + label { - background-color: var(--color-primary); - } - - [type="checkbox"]:focus + label, - [type="radio"]:focus + label { - border-color: #3273dc; - box-shadow: 0 0 0 0.125em rgba(50,115,220,.25); - outline: 0; - } - - :checked + label::before, - :checked + label::after { - content: ''; - } - - :checked + label::before { - background-color: white; - transform: rotate(45deg); - left: 4px; - } - - :checked + label::after { - background-color: white; - transform: rotate(-45deg); - top: 11px; - width: 13px; - } - - [disabled] + label { - pointer-events: none; - border: none; - opacity: 0.6; - filter: grayscale(1); - } -} - -.radio::before { - content: ''; - position: absolute; - top: 2px; - left: 2px; - right: 2px; - bottom: 2px; - border-radius: 4px; - border: 2px solid white; - z-index: -1; -} - /* CUSTOM FILE INPUT */ .file-input__label { cursor: pointer; diff --git a/static/css/utils/modal.scss b/static/css/utils/modal.scss index 5cac989a3..2f5d0e168 100644 --- a/static/css/utils/modal.scss +++ b/static/css/utils/modal.scss @@ -3,7 +3,7 @@ left: 50%; top: 50%; transform: translate(-50%, -50%) scale(0.8, 0.8); - display: block; + display: flex; background-color: rgba(255, 255, 255, 1); min-width: 60vw; min-height: 100px; @@ -26,10 +26,6 @@ z-index: 200; transform: translate(-50%, -50%) scale(1, 1); } - - .modal__content { - margin: 20px 0; - } } @media (max-width: 1024px) { @@ -96,3 +92,8 @@ color: white; } } + +.modal__content { + margin: 20px 0; + width: 100%; +} diff --git a/static/css/utils/radio.scss b/static/css/utils/radio.scss new file mode 100644 index 000000000..a832de842 --- /dev/null +++ b/static/css/utils/radio.scss @@ -0,0 +1,76 @@ +/* CUSTOM RADIO BOXES */ +/* Completely replaces native radiobox */ + +.radio-group { + display: flex; +} + +.radio-group__option { + min-width: 30px; +} + +.radio { + position: relative; + display: inline-block; + + [type="radio"] { + position: fixed; + top: -1px; + left: -1px; + width: 1px; + height: 1px; + overflow: hidden; + } + + label { + display: block; + height: 34px; + min-width: 42px; + line-height: 34px; + text-align: center; + padding: 0 13px; + background-color: #f3f3f3; + box-shadow: inset 2px 1px 2px 1px rgba(50, 50, 50, 0.05); + color: var(--color-font); + cursor: pointer; + } + + :checked + label { + background-color: var(--color-primary); + color: var(--color-lightwhite); + box-shadow: inset -2px -1px 2px 1px rgba(255, 255, 255, 0.15); + } + + :focus + label { + border-color: #3273dc; + box-shadow: 0 0 0.125em 0 rgba(50,115,220,0.8); + outline: 0; + } + + [disabled] + label { + pointer-events: none; + border: none; + opacity: 0.6; + filter: grayscale(1); + } +} + +.radio:first-child { + label { + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + } +} + +.radio:last-child { + label { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + } +} + +@media (max-width: 768px) { + .radio + .radio { + margin-left: 10px; + } +} diff --git a/static/css/utils/showHide.scss b/static/css/utils/showHide.scss index 64dfe367c..ab82286b8 100644 --- a/static/css/utils/showHide.scss +++ b/static/css/utils/showHide.scss @@ -1,22 +1,30 @@ +$show-hide-toggle-size: 6px; + .js-show-hide__toggle { + position: relative; cursor: pointer; + padding: 3px 7px; + + &:hover { + background-color: var(--color-grey-lighter); + cursor: pointer; + } } .js-show-hide__toggle::before { content: ''; position: absolute; - width: 0; - height: 0; - left: -28px; - top: 6px; + width: $show-hide-toggle-size; + height: $show-hide-toggle-size; + left: -15px; + top: 12px - $show-hide-toggle-size / 2; color: var(--color-primary); - border-right: 8px solid transparent; - border-top: 8px solid transparent; - border-left: 8px solid transparent; - border-bottom: 8px solid currentColor; + border-right: 2px solid currentColor; + border-top: 2px solid currentColor; transition: transform .2s ease; - transform-origin: 8px 12px; + transform-origin: ($show-hide-toggle-size / 2); + transform: translateY($show-hide-toggle-size) rotate(-45deg); } .js-show-hide__target { @@ -26,7 +34,7 @@ .js-show-hide--collapsed { .js-show-hide__toggle::before { - transform: rotate(180deg); + transform: translateY($show-hide-toggle-size / 3) rotate(135deg); } .js-show-hide__target { diff --git a/static/js/utils/alerts.js b/static/js/utils/alerts.js index d52a47376..b854495a0 100644 --- a/static/js/utils/alerts.js +++ b/static/js/utils/alerts.js @@ -87,5 +87,10 @@ initToggler(); alertElements.forEach(initAlert); + + return { + scope: alertsEl, + destroy: function() {}, + }; }; })(); diff --git a/static/js/utils/asidenav.js b/static/js/utils/asidenav.js index 154232109..bb95f6455 100644 --- a/static/js/utils/asidenav.js +++ b/static/js/utils/asidenav.js @@ -55,5 +55,10 @@ initFavoritesButton(); initAsidenavSubmenus(); + + return { + scope: asideEl, + destroy: function() {}, + }; }; })(); diff --git a/static/js/utils/asyncForm.js b/static/js/utils/asyncForm.js index 7917012a9..aa57ed2a0 100644 --- a/static/js/utils/asyncForm.js +++ b/static/js/utils/asyncForm.js @@ -6,9 +6,12 @@ var ASYNC_FORM_RESPONSE_CLASS = 'async-form-response'; var ASYNC_FORM_LOADING_CLASS = 'async-form-loading'; var ASYNC_FORM_MIN_DELAY = 600; + var DEFAULT_FAILURE_MESSAGE = 'The response we received from the server did not match what we expected. Please let us know this happened via the help widget in the top navigation.'; window.utils.asyncForm = function(formElement, options) { + options = options || {}; + var lastRequestTimestamp = 0; function setup() { @@ -16,19 +19,27 @@ } function processResponse(response) { - var responseElement = document.createElement('div'); - responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS); - responseElement.innerHTML = response.content; + var responseElement = makeResponseElement(response.content, response.status); var parentElement = formElement.parentElement; // make sure there is a delay between click and response var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now()); + setTimeout(function() { parentElement.insertBefore(responseElement, formElement); formElement.remove(); }, delay); } + function makeResponseElement(content, status) { + var responseElement = document.createElement('div'); + status = status || 'info'; + responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS); + responseElement.classList.add(ASYNC_FORM_RESPONSE_CLASS + '--' + status); + responseElement.innerHTML = content; + return responseElement; + } + function submitHandler(event) { event.preventDefault(); @@ -47,14 +58,29 @@ window.utils.httpClient.post(url, headers, body) .then(function(response) { - return response.json(); + if (response.headers.get("content-type").indexOf("application/json") !== -1) {// checking response header + return response.json(); + } else { + throw new TypeError('Unexpected Content-Type. Expected Content-Type: "application/json". Requested URL:' + url + '"'); + } }).then(function(response) { - processResponse(response[0]) + processResponse(response[0]); }).catch(function(error) { - console.error('could not fetch or process response from ' + url, { error }); + var failureMessage = DEFAULT_FAILURE_MESSAGE; + if (options.i18n && options.i18n.asyncFormFailure) { + failureMessage = options.i18n.asyncFormFailure; + } + processResponse({ content: failureMessage }); + + formElement.classList.remove(ASYNC_FORM_LOADING_CLASS); }); } setup(); + + return { + scope: formElement, + destroy: function() {}, + }; }; })(); diff --git a/static/js/utils/asyncTable.js b/static/js/utils/asyncTable.js index a7f46a134..ea6458633 100644 --- a/static/js/utils/asyncTable.js +++ b/static/js/utils/asyncTable.js @@ -5,6 +5,10 @@ var HEADER_HEIGHT = 80; var RESET_OPTIONS = [ 'scrollTo' ]; + var TABLE_FILTER_FORM_CLASS = 'table-filter-form'; + var ASYNC_TABLE_CONTENT_CHANGED_CLASS = 'async-table--changed'; + var ASYNC_TABLE_LOADING_CLASS = 'async-table--loading'; + var JS_INITIALIZED_CLASS = 'js-async-table-initialized'; window.utils.asyncTable = function(wrapper, options) { @@ -17,6 +21,8 @@ var pagesizeForm; var scrollTable; + var utilInstances = []; + function init() { var table = wrapper.querySelector('#' + tableIdent); @@ -42,17 +48,30 @@ // pagesize form pagesizeForm = wrapper.querySelector('#' + tableIdent + '-pagesize-form'); + // check all + utilInstances.push(window.utils.setup('checkAll', wrapper)); + + // showhide + utilInstances.push(window.utils.setup('showHide', wrapper)); + + // filter + var filterForm = wrapper.querySelector('.' + TABLE_FILTER_FORM_CLASS); + if (filterForm) { + options.updateTableFrom = updateTableFrom; + utilInstances.push(window.utils.setup('asyncTableFilter', filterForm, options)); + } + // take options into account - if (options && options.scrollTo) { + if (options.scrollTo) { window.scrollTo(options.scrollTo); } - if (options && options.horizPos && scrollTable) { + if (options.horizPos && scrollTable) { scrollTable.scrollLeft = options.horizPos; } setupListeners(); - wrapper.classList.add('js-initialized'); + wrapper.classList.add(JS_INITIALIZED_CLASS); } function setupListeners() { @@ -117,32 +136,22 @@ } function changePagesizeHandler(event) { - var currentTableUrl = options.currentUrl || window.location.href; - var url = getUrlWithUpdatedPagesize(currentTableUrl, event.target.value); - url = new URL(getUrlWithResetPagenumber(url)); + var pagesizeParamKey = tableIdent + '-pagesize'; + var pageParamKey = tableIdent + '-page'; + var url = new URL(options.currentUrl || window.location.href); + url.searchParams.set(pagesizeParamKey, event.target.value); + url.searchParams.set(pageParamKey, 0); updateTableFrom(url); } - function getUrlWithUpdatedPagesize(url, pagesize) { - if (url.indexOf('pagesize') >= 0) { - return url.replace(/pagesize=(\d+|all)/, 'pagesize=' + pagesize); - } else if (url.indexOf('?') >= 0) { - return url += '&' + tableIdent + '-pagesize=' + pagesize; - } - - return url += '?' + tableIdent + '-pagesize=' + pagesize; - } - - function getUrlWithResetPagenumber(url) { - return url.replace(/-page=\d+/, '-page=0'); - } - // fetches new sorted table from url with params and replaces contents of current table - function updateTableFrom(url, tableOptions) { + function updateTableFrom(url, tableOptions, callback) { if (!window.utils.httpClient) { throw new Error('httpClient not found!'); } + wrapper.classList.add(ASYNC_TABLE_LOADING_CLASS); + tableOptions = tableOptions || {}; var headers = { 'Accept': 'text/html', @@ -157,6 +166,11 @@ tableOptions.currentUrl = url.href; removeListeners(); updateWrapperContents(data, tableOptions); + if (callback && typeof callback === 'function') { + callback(wrapper); + } + + wrapper.classList.remove(ASYNC_TABLE_LOADING_CLASS); }).catch(function(err) { console.error(err); }); @@ -165,11 +179,11 @@ function updateWrapperContents(newHtml, tableOptions) { tableOptions = tableOptions || {}; wrapper.innerHTML = newHtml; - wrapper.classList.remove("js-initialized"); + wrapper.classList.remove(JS_INITIALIZED_CLASS); + wrapper.classList.add(ASYNC_TABLE_CONTENT_CHANGED_CLASS); + + destroyUtils(); - // setup the wrapper and its components to behave async again - window.utils.teardown('asyncTable'); - window.utils.teardown('form'); // merge global options and table specific options var resetOptions = {}; Object.keys(options) @@ -195,13 +209,24 @@ window.utils.setup('asyncTable', wrapper, combinedOptions); Array.from(wrapper.querySelectorAll('form')).forEach(function(form) { - window.utils.setup('form', form); + utilInstances.push(window.utils.setup('form', form)); }); Array.from(wrapper.querySelectorAll('.modal')).forEach(function(modal) { - window.utils.setup('modal', modal); + utilInstances.push(window.utils.setup('modal', modal)); + }); + } + + function destroyUtils() { + utilInstances.forEach(function(utilInstance) { + utilInstance.destroy(); }); } init(); + + return { + scope: wrapper, + destroy: destroyUtils, + }; }; })(); diff --git a/static/js/utils/asyncTableFilter.js b/static/js/utils/asyncTableFilter.js new file mode 100644 index 000000000..98d9cda75 --- /dev/null +++ b/static/js/utils/asyncTableFilter.js @@ -0,0 +1,166 @@ +(function () { + 'use strict'; + + window.utils = window.utils || {}; + + var ASYNC_TABLE_FILTER_LOADING_CLASS = 'async-table-filter--loading'; + var JS_INITIALIZED_CLASS = 'js-async-table-filter-initialized'; + var INPUT_DEBOUNCE = 600; + + // debounce function, taken from Underscore.js + function debounce(func, wait, immediate) { + var timeout; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; + }; + + window.utils.asyncTableFilter = function(formElement, options) { + if (!options || !options.updateTableFrom) { + return false; + } + + if (formElement.matches('.' + JS_INITIALIZED_CLASS)) { + return false; + } + options = options || {}; + var tableIdent = options.dbtIdent; + + var formId = formElement.querySelector('[name="_formid"]').value; + var inputs = { + search: [], + input: [], + change: [], + select: [], + } + + function setup() { + gatherInputs(); + addEventListeners(); + } + + function gatherInputs() { + Array.from(formElement.querySelectorAll('input[type="search"]')).forEach(function(input) { + inputs.search.push(input); + }); + + Array.from(formElement.querySelectorAll('input[type="text"]')).forEach(function(input) { + inputs.input.push(input); + }); + + Array.from(formElement.querySelectorAll('input:not([type="text"]):not([type="search"])')).forEach(function(input) { + inputs.change.push(input); + }); + + Array.from(formElement.querySelectorAll('select')).forEach(function(input) { + inputs.select.push(input); + }); + } + + function addEventListeners() { + inputs.search.forEach(function(input) { + var debouncedInput = debounce(function() { + if (input.value.length === 0 || input.value.length > 2) { + updateTable(); + } + }, INPUT_DEBOUNCE); + input.addEventListener('input', debouncedInput); + }); + + inputs.input.forEach(function(input) { + var debouncedInput = debounce(function() { + if (input.value.length === 0 || input.value.length > 2) { + updateTable(); + } + }, INPUT_DEBOUNCE); + input.addEventListener('input', debouncedInput); + }); + + inputs.change.forEach(function(input) { + input.addEventListener('change', function() { + updateTable(); + }); + }); + + inputs.select.forEach(function(input) { + input.addEventListener('change', function() { + updateTable(); + }); + }); + + formElement.addEventListener('submit', function(event) { + event.preventDefault(); + updateTable(); + }); + } + + function updateTable() { + var url = serializeFormToURL(); + var callback = null; + + formElement.classList.add(ASYNC_TABLE_FILTER_LOADING_CLASS); + + var focusedSearch = inputs.search.reduce(function(acc, input) { + return acc || (input.matches(':focus') && input); + }, null); + // focus search input + if (focusedSearch) { + var selectionStart = focusedSearch.selectionStart; + callback = function(wrapper) { + var search = wrapper.querySelector('input[type="search"]'); + if (search) { + search.focus(); + search.selectionStart = selectionStart; + } + }; + } + options.updateTableFrom(url, options, callback); + } + + function serializeFormToURL() { + var url = new URL(options.currentUrl || window.location.href); + url.searchParams.set('_formid', formId); + url.searchParams.set('_hasdata', 'true'); + url.searchParams.set(tableIdent + '-page', '0'); + + inputs.search.forEach(function(input) { + url.searchParams.set(input.name, input.value); + }); + + inputs.input.forEach(function(input) { + url.searchParams.set(input.name, input.value); + }); + + inputs.change.forEach(function(input) { + if (input.checked) { + url.searchParams.set(input.name, input.value); + } + }); + + inputs.select.forEach(function(select) { + var options = Array.from(select.querySelectorAll('option')); + var selected = options.find(function(option) { return option.selected }); + if (selected) { + url.searchParams.set(select.name, selected.value); + } + }); + + return url; + } + + setup(); + + return { + scope: formElement, + destroy: function() {}, + }; + } +})(); diff --git a/static/js/utils/checkAll.js b/static/js/utils/checkAll.js index 5decca2de..b37a89454 100644 --- a/static/js/utils/checkAll.js +++ b/static/js/utils/checkAll.js @@ -3,6 +3,7 @@ window.utils = window.utils || {}; + var ASYNC_TABLE_CONTENT_CHANGED_CLASS = 'async-table--changed'; var JS_INITIALIZED_CLASS = 'js-check-all-initialized'; var CHECKBOX_SELECTOR = '[type="checkbox"]'; @@ -12,7 +13,7 @@ window.utils.checkAll = function(wrapper, options) { - if (!wrapper || wrapper.classList.contains(JS_INITIALIZED_CLASS)) { + if ((!wrapper || wrapper.classList.contains(JS_INITIALIZED_CLASS)) && !wrapper.classList.contains(ASYNC_TABLE_CONTENT_CHANGED_CLASS)) { return false; } options = options || {}; @@ -21,6 +22,8 @@ var checkboxColumn = []; var checkAllCheckbox = null; + var utilInstances = []; + function init() { columns = gatherColumns(wrapper); @@ -79,7 +82,7 @@ checkAllCheckbox.setAttribute('id', getCheckboxId()); th.innerHTML = ''; th.insertBefore(checkAllCheckbox, null); - window.utils.setup('checkboxRadio', checkAllCheckbox); + utilInstances.push(window.utils.setup('checkbox', checkAllCheckbox)); checkAllCheckbox.addEventListener('input', onCheckAllCheckboxInput); setupCheckboxListeners(); @@ -112,6 +115,17 @@ }); } + function destroy() { + utilInstances.forEach(function(util) { + util.destroy(); + }); + } + init(); + + return { + scope: wrapper, + destroy: destroy, + }; }; })(); diff --git a/static/js/utils/form.js b/static/js/utils/form.js index 1e0db4c20..8dc8642a2 100644 --- a/static/js/utils/form.js +++ b/static/js/utils/form.js @@ -25,36 +25,48 @@ return false; } + var utilInstances = []; + // reactive buttons - var submitBtn = form.querySelector(SUBMIT_BUTTON_SELECTOR); - if (submitBtn) { - window.utils.setup('reactiveButton', form, { button: submitBtn }); - } + utilInstances.push(window.utils.setup('reactiveButton', form)); // conditonal fieldsets var fieldSets = Array.from(form.querySelectorAll('fieldset[data-conditional-id][data-conditional-value]')); - window.utils.setup('interactiveFieldset', form, { fieldSets }); + utilInstances.push(window.utils.setup('interactiveFieldset', form, { fieldSets })); // hide autoSubmit submit button - window.utils.setup('autoSubmit', form, options); + utilInstances.push(window.utils.setup('autoSubmit', form, options)); // async form if (AJAX_SUBMIT_FLAG in form.dataset) { - window.utils.setup('asyncForm', form, options); + utilInstances.push(window.utils.setup('asyncForm', form, options)); } + // inputs + utilInstances.push(window.utils.setup('inputs', form, options)); + form.classList.add(JS_INITIALIZED); + + function destroyUtils() { + utilInstances.forEach(function(utilInstance) { + utilInstance.destroy(); + }); + } + + return { + scope: form, + destroy: destroyUtils, + }; }; // registers input-listener for each element in (array) and // enables