merge master

This commit is contained in:
Steffen Jost 2019-04-16 11:14:52 +02:00
commit 23029abec6
86 changed files with 1850 additions and 1401 deletions

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
exec -- stack build --fast --flag uniworx:library-only --flag uniworx:dev exec -- stack build --fast --flag uniworx:-library-only --flag uniworx:dev
echo Build task completed. echo Build task completed.

2
db.sh
View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Options: see /test/Database.hs (Main) # Options: see /test/Database.hs (Main)
stack build --fast --flag uniworx:library-only --flag uniworx:dev stack build --fast --flag uniworx:-library-only --flag uniworx:dev
stack exec uniworxdb -- $@ stack exec uniworxdb -- $@

3
hlint.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
exec -- ./test.sh uniworx:test:hlint

4
messages/frontend/de.msg Normal file
View File

@ -0,0 +1,4 @@
FilesSelected: Dateien ausgewählt
SelectFile: Datei auswählen
SelectFiles: Datei(en) auswählen
AsyncFormFailure: Da ist etwas schief gelaufen, das tut uns Leid. Falls das erneut passiert schicke uns gerne eine kurze Beschreibung dieses Ereignisses über das Hilfe-Widget rechts oben. Vielen Dank für deine Hilfe!

View File

@ -159,21 +159,21 @@ SheetInvisibleUntil date@Text: Dieses Übungsblatt ist für Teilnehmer momentan
SheetName: Name SheetName: Name
SheetDescription: Hinweise für Teilnehmer SheetDescription: Hinweise für Teilnehmer
SheetGroup: Gruppenabgabe SheetGroup: Gruppenabgabe
SheetVisibleFrom: Sichtbar ab SheetVisibleFrom: Sichtbar für Teilnehmer ab
SheetVisibleFromTip: Ohne Datum wird das Blatt nie sichtbar, z.B. weil es noch nicht fertig ist SheetVisibleFromTip: Ohne Datum nie sichtbar und keine Abgabe möglich; nur für unfertige Blätter leer lassen, deren Fristen/Bewertung sich noch ändern kann
SheetActiveFrom: Aktiv ab SheetActiveFrom: Beginn Abgabezeitraum
SheetActiveFromTip: Abgabe und Download der Aufgabenstellung ist erst ab diesem Datum möglich SheetActiveFromTip: Download der Aufgabenstellung erst ab diesem Datum möglich
SheetActiveTo: Abgabefrist SheetActiveTo: Ende Abgabezeitraum
SheetHintFromTip: Ohne Datum nie für Teilnehmer sichtbar, Korrektoren können diese Dateien immer herunterladen SheetHintFromTip: Ohne Datum nie für Teilnehmer sichtbar, Korrektoren können diese Dateien immer herunterladen
SheetSolutionFromTip: Ohne Datum nie für Teilnehmer sichtbar, Korrektoren können diese Dateien immer herunterladen SheetSolutionFromTip: Ohne Datum nie für Teilnehmer sichtbar, Korrektoren können diese Dateien immer herunterladen
SheetMarkingTip: Hinweise zur Korrektur, sichtbar nur für Korrektoren SheetMarkingTip: Hinweise zur Korrektur, sichtbar nur für Korrektoren
SheetPseudonym: Persönliches Abgabe-Pseudonym SheetPseudonym: Persönliches Abgabe-Pseudonym
SheetGeneratePseudonym: Generieren SheetGeneratePseudonym: Generieren
SheetErrVisibility: Sichtbarkeit muss vor Beginn der Abgabefrist liegen SheetErrVisibility: "Beginn Abgabezeitraum" muss nach "Sichbar für Teilnehmer ab" liegen
SheetErrDeadlineEarly: Ende der Abgabefrist muss nach deren Beginn liegen SheetErrDeadlineEarly: "Ende Abgabezeitraum" muss nach "Beginn Abzeitraum" liegen
SheetErrHintEarly: Hinweise dürfen erst nach Beginn der Abgabefrist herausgegeben werden SheetErrHintEarly: Hinweise dürfen erst nach Beginn des Abgabezeitraums herausgegeben werden
SheetErrSolutionEarly: Die Lösung sollte erst nach Ende der Abgabefrist herausgegeben werden SheetErrSolutionEarly: Lösungen dürfen erst nach Ende der Abgabezeitraums herausgegeben werden
Deadline: Abgabe Deadline: Abgabe
@ -244,7 +244,7 @@ AddCorrector: Zusätzlicher Korrektor
CorrectorExists email@UserEmail: #{email} ist bereits als Korrektor eingetragen CorrectorExists email@UserEmail: #{email} ist bereits als Korrektor eingetragen
SheetCorrectorsTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Korrektoren für #{display tid}-#{display ssh}-#{csh} #{sheetName} SheetCorrectorsTitle tid@TermId ssh@SchoolId csh@CourseShorthand sheetName@SheetName: Korrektoren für #{display tid}-#{display ssh}-#{csh} #{sheetName}
CountTutProp: Tutorien zählen gegen Proportion CountTutProp: Tutorien zählen gegen Proportion
AutoAssignCorrs: Korrekturen am Ende der Abgabefrist automatisch zuteilen AutoAssignCorrs: Korrekturen nach Ablauf des Abgabezeitraums automatisch zuteilen
Corrector: Korrektor Corrector: Korrektor
Correctors: Korrektoren Correctors: Korrektoren
CorState: Status CorState: Status
@ -254,7 +254,7 @@ CorByProportionOnly proportion@Rational: #{display proportion} Anteile
CorByProportionIncludingTutorial proportion@Rational: #{display proportion} Anteile - Tutorium CorByProportionIncludingTutorial proportion@Rational: #{display proportion} Anteile - Tutorium
CorByProportionExcludingTutorial proportion@Rational: #{display proportion} Anteile + Tutorium CorByProportionExcludingTutorial proportion@Rational: #{display proportion} Anteile + Tutorium
RowCount count@Int64: #{display count} #{pluralDE count "Eintrag" "Einträge"} insgesamt RowCount count@Int64: #{display count} #{pluralDE count "Eintrag" "Einträge"} nach Filter
DeleteRow: Zeile entfernen DeleteRow: Zeile entfernen
ProportionNegative: Anteile dürfen nicht negativ sein ProportionNegative: Anteile dürfen nicht negativ sein
CorrectorUpdated: Korrektor erfolgreich aktualisiert CorrectorUpdated: Korrektor erfolgreich aktualisiert
@ -294,7 +294,10 @@ Settings: Individuelle Benutzereinstellungen
SettingsUpdate: Einstellungen wurden gespeichert. SettingsUpdate: Einstellungen wurden gespeichert.
Never: Nie Never: Nie
PreviouslyUploadedInfo: Bereits hochgeladene Dateien:
PreviouslyUploadedDeletionInfo: (Nicht ausgewählte Dateien werden gelöscht)
MultiFileUploadInfo: (Mehrere Dateien mit Shift oder Strg auswählen) MultiFileUploadInfo: (Mehrere Dateien mit Shift oder Strg auswählen)
AddMoreFiles: Weitere Dateien hinzufügen:
NrColumn: Nr NrColumn: Nr
SelectColumn: Auswahl SelectColumn: Auswahl
@ -496,7 +499,7 @@ MailSubmissionsUnassignedIntro n@Int courseName@Text termDesc@Text sheetName@She
MailSubjectSheetSoonInactive csh@CourseShorthand sheetName@SheetName: #{sheetName} in #{csh} kann nur noch kurze Zeit abgegeben werden MailSubjectSheetSoonInactive csh@CourseShorthand sheetName@SheetName: #{sheetName} in #{csh} kann nur noch kurze Zeit abgegeben werden
MailSheetSoonInactiveIntro courseName@Text termDesc@Text sheetName@SheetName: Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) endet in Kürze. MailSheetSoonInactiveIntro courseName@Text termDesc@Text sheetName@SheetName: Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) endet in Kürze.
MailSubjectSheetInactive csh@CourseShorthand sheetName@SheetName: Abgabfrist für #{sheetName} in #{csh} abgelaufen MailSubjectSheetInactive csh@CourseShorthand sheetName@SheetName: Abgabezeitraum für #{sheetName} in #{csh} abgelaufen
MailSheetInactiveIntro courseName@Text termDesc@Text sheetName@SheetName n@Int num@Int64: Die Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) beendet. Es gab #{noneOneMoreDE n "Keine Abgaben" "Nur eine Abgabe von " (display n <> " Abgaben von ")}#{noneOneMoreDE num "" "einem Teilnehmer" (display num <> " Teilnehmern")}. MailSheetInactiveIntro courseName@Text termDesc@Text sheetName@SheetName n@Int num@Int64: Die Abgabefirst für #{sheetName} im Kurs #{courseName} (#{termDesc}) beendet. Es gab #{noneOneMoreDE n "Keine Abgaben" "Nur eine Abgabe von " (display n <> " Abgaben von ")}#{noneOneMoreDE num "" "einem Teilnehmer" (display num <> " Teilnehmern")}.
MailSubjectCorrectionsAssigned csh@CourseShorthand sheetName@SheetName: Ihnen wurden Korrekturen zu #{sheetName} in #{csh} zugeteilt MailSubjectCorrectionsAssigned csh@CourseShorthand sheetName@SheetName: Ihnen wurden Korrekturen zu #{sheetName} in #{csh} zugeteilt
@ -510,6 +513,7 @@ MailLecturerRights n@Int: Als Dozent dürfen Sie Veranstaltungen innerhalb #{plu
MailEditNotifications: Benachrichtigungen ein-/ausschalten MailEditNotifications: Benachrichtigungen ein-/ausschalten
MailSubjectSupport: Supportanfrage MailSubjectSupport: Supportanfrage
MailSubjectSupportCustom customSubject@Text: [Support] #{customSubject}
SheetGrading: Bewertung SheetGrading: Bewertung
SheetGradingPoints maxPoints@Points: #{tshow maxPoints} Punkte SheetGradingPoints maxPoints@Points: #{tshow maxPoints} Punkte
@ -553,7 +557,7 @@ NotificationTriggerSubmissionRatedGraded: Meine Abgabe in einem gewerteten Übun
NotificationTriggerSubmissionRated: Meine Abgabe wurde korrigiert NotificationTriggerSubmissionRated: Meine Abgabe wurde korrigiert
NotificationTriggerSheetActive: Ich kann ein neues Übungsblatt herunterladen NotificationTriggerSheetActive: Ich kann ein neues Übungsblatt herunterladen
NotificationTriggerSheetSoonInactive: Ich kann ein Übungsblatt bald nicht mehr abgeben NotificationTriggerSheetSoonInactive: Ich kann ein Übungsblatt bald nicht mehr abgeben
NotificationTriggerSheetInactive: Abgabefrist eines meiner Übungsblätter ist abgelaufen NotificationTriggerSheetInactive: Abgabezeitraum eines meiner Übungsblätter ist abgelaufen
NotificationTriggerCorrectionsAssigned: Mir wurden Abgaben zur Korrektur zugeteilt NotificationTriggerCorrectionsAssigned: Mir wurden Abgaben zur Korrektur zugeteilt
NotificationTriggerCorrectionsNotDistributed: Abgaben eines meiner Übungsblätter konnten keinem Korrektur zugeteilt werden NotificationTriggerCorrectionsNotDistributed: Abgaben eines meiner Übungsblätter konnten keinem Korrektur zugeteilt werden
NotificationTriggerUserRightsUpdate: Meine Berechtigungen wurden geändert NotificationTriggerUserRightsUpdate: Meine Berechtigungen wurden geändert
@ -584,6 +588,7 @@ HelpAnswer: Antworten an
HelpUser: Meinen Benutzeraccount HelpUser: Meinen Benutzeraccount
HelpAnonymous: Keine Antwort (Anonym) HelpAnonymous: Keine Antwort (Anonym)
HelpEmail: E-Mail HelpEmail: E-Mail
HelpSubject: Betreff
HelpRequest: Supportanfrage / Verbesserungsvorschlag HelpRequest: Supportanfrage / Verbesserungsvorschlag
HelpProblemPage: Problematische Seite HelpProblemPage: Problematische Seite
HelpIntroduction: Wenn Ihnen die Benutzung dieser Webseite Schwierigkeiten bereitet oder Sie einen verbesserbaren Umstand entdecken bitten wir Sie uns das zu melden, auch wenn Sie Ihr Problem bereits selbst lösen konnten. Wir passen die Seite ständig an und versuchen sie auch für zukünftige Benutzer so einsichtig wie möglich zu halten. HelpIntroduction: Wenn Ihnen die Benutzung dieser Webseite Schwierigkeiten bereitet oder Sie einen verbesserbaren Umstand entdecken bitten wir Sie uns das zu melden, auch wenn Sie Ihr Problem bereits selbst lösen konnten. Wir passen die Seite ständig an und versuchen sie auch für zukünftige Benutzer so einsichtig wie möglich zu halten.
@ -739,4 +744,4 @@ AddRecipientCustom: Weitere Empfänger
RGCourseParticipants: Kursteilnehmer RGCourseParticipants: Kursteilnehmer
RGCourseLecturers: Kursverwalter RGCourseLecturers: Kursverwalter
RGCourseCorrectors: Korrektoren RGCourseCorrectors: Korrektoren

View File

@ -218,6 +218,9 @@ executables:
dependencies: dependencies:
- uniworx - uniworx
other-modules: [] other-modules: []
when:
- condition: flag(library-only)
buildable: false
# Test suite # Test suite
tests: tests:

View File

@ -66,7 +66,7 @@ campusForm :: ( RenderMessage site FormMessage
, Button site ButtonSubmit , Button site ButtonSubmit
) => AForm (HandlerT site IO) CampusLogin ) => AForm (HandlerT site IO) CampusLogin
campusForm = CampusLogin campusForm = CampusLogin
<$> areq ciField (fslpI MsgCampusIdent "user.name@campus.lmu.de" & setTooltip MsgCampusIdentNote) Nothing <$> areq ciField (fslpI MsgCampusIdent "user.name@campus.lmu.de" & setTooltip MsgCampusIdentNote & addAttr "autofocus" "") Nothing
<*> areq passwordField (fslI MsgCampusPassword) Nothing <*> areq passwordField (fslI MsgCampusPassword) Nothing
campusLogin :: forall site. campusLogin :: forall site.

View File

@ -5,8 +5,9 @@ module Database.Esqueleto.Utils
, isInfixOf, hasInfix , isInfixOf, hasInfix
, any, all , any, all
, SqlIn(..) , SqlIn(..)
, mkExactFilter, mkContainsFilter , mkExactFilter, mkExactFilterWith
, anyFilter , mkContainsFilter
, anyFilter, allFilter
) where ) where
import ClassyPrelude.Yesod hiding (isInfixOf, any, all) import ClassyPrelude.Yesod hiding (isInfixOf, any, all)
@ -74,13 +75,22 @@ _queryFeaturesDegree = $(sqlIJproj 3 2)
-- Given a lens-like function, make filter for exact matches in a collection -- Given a lens-like function, make filter for exact matches in a collection
-- (Generalizing from Set to Foldable ok here, but gives ambigouus types elsewhere) -- (Generalizing from Set to Foldable ok here, but gives ambigouus types elsewhere)
mkExactFilter :: (PersistField a) mkExactFilter :: (PersistField a)
=> (t -> E.SqlExpr (E.Value a)) -- ^ getter from query to searched element => (t -> E.SqlExpr (E.Value a)) -- ^ getter from query to searched element
-> t -- ^ query row -> t -- ^ query row
-> Set.Set a -- ^ needle collection -> Set.Set a -- ^ needle collection
-> E.SqlExpr (E.Value Bool) -> E.SqlExpr (E.Value Bool)
mkExactFilter lenslike row criterias mkExactFilter = mkExactFilterWith id
-- | like @mkExactFiler@ but allows for conversion; convenient in conjunction with @anyFilter@ and @allFilter@
mkExactFilterWith :: (PersistField b)
=> (a -> b) -- ^ type conversion
-> (t -> E.SqlExpr (E.Value b)) -- ^ getter from query to searched element
-> t -- ^ query row
-> Set.Set a -- ^ needle collection
-> E.SqlExpr (E.Value Bool)
mkExactFilterWith cast lenslike row criterias
| Set.null criterias = true | Set.null criterias = true
| otherwise = lenslike row `E.in_` E.valList (Set.toList criterias) | otherwise = lenslike row `E.in_` E.valList (cast <$> Set.toList criterias)
-- | generic filter creation for dbTable -- | generic filter creation for dbTable
-- Given a lens-like function, make filter searching for needles in String-like elements -- Given a lens-like function, make filter searching for needles in String-like elements
@ -94,9 +104,22 @@ mkContainsFilter lenslike row criterias
| Set.null criterias = true | Set.null criterias = true
| otherwise = any (hasInfix $ lenslike row) criterias | otherwise = any (hasInfix $ lenslike row) criterias
-- | Combine several filters, using logical or
anyFilter :: (Foldable f) => f (t -> Set.Set Text-> E.SqlExpr (E.Value Bool)) anyFilter :: (Foldable f)
-> t -> Set.Set Text-> E.SqlExpr (E.Value Bool) => f (t -> Set.Set Text-> E.SqlExpr (E.Value Bool))
-> t
-> Set.Set Text
-> E.SqlExpr (E.Value Bool)
anyFilter fltrs needle criterias = F.foldr aux false fltrs anyFilter fltrs needle criterias = F.foldr aux false fltrs
where where
aux fltr acc = fltr needle criterias E.||. acc aux fltr acc = fltr needle criterias E.||. acc
-- | Combine several filters, using logical and
allFilter :: (Foldable f)
=> f (t -> Set.Set Text-> E.SqlExpr (E.Value Bool))
-> t
-> Set.Set Text
-> E.SqlExpr (E.Value Bool)
allFilter fltrs needle criterias = F.foldr aux true fltrs
where
aux fltr acc = fltr needle criterias E.&&. acc

View File

@ -187,6 +187,7 @@ mkMessageVariant "UniWorX" "Campus" "messages/campus" "de"
mkMessageVariant "UniWorX" "Dummy" "messages/dummy" "de" mkMessageVariant "UniWorX" "Dummy" "messages/dummy" "de"
mkMessageVariant "UniWorX" "PWHash" "messages/pw-hash" "de" mkMessageVariant "UniWorX" "PWHash" "messages/pw-hash" "de"
mkMessageVariant "UniWorX" "Button" "messages/button" "de" mkMessageVariant "UniWorX" "Button" "messages/button" "de"
mkMessageVariant "UniWorX" "Frontend" "messages/frontend" "de"
-- This instance is required to use forms. You can modify renderMessage to -- This instance is required to use forms. You can modify renderMessage to
-- achieve customized and internationalized form validation messages. -- achieve customized and internationalized form validation messages.
@ -1053,6 +1054,11 @@ siteLayout' headingOverride widget = do
hasSecondaryPageActions = any (is _PageActionSecondary) $ toListOf (traverse . _1 . _menuItemType) menuTypes hasSecondaryPageActions = any (is _PageActionSecondary) $ toListOf (traverse . _1 . _menuItemType) menuTypes
hasPrimaryPageActions = any (is _PageActionPrime) $ toListOf (traverse . _1 . _menuItemType) menuTypes hasPrimaryPageActions = any (is _PageActionPrime) $ toListOf (traverse . _1 . _menuItemType) menuTypes
MsgRenderer mr <- getMsgRenderer
let
-- See Utils.Frontend.I18n and files in messages/frontend for message definitions
frontendI18n = toJSON (mr :: FrontendMessage -> Text)
pc <- widgetToPageContent $ do pc <- widgetToPageContent $ do
-- 3rd party -- 3rd party
addScript $ StaticR js_vendor_flatpickr_js addScript $ StaticR js_vendor_flatpickr_js
@ -1064,20 +1070,21 @@ siteLayout' headingOverride widget = do
-- polyfills -- polyfills
addScript $ StaticR js_polyfills_fetchPolyfill_js addScript $ StaticR js_polyfills_fetchPolyfill_js
addScript $ StaticR js_polyfills_urlPolyfill_js addScript $ StaticR js_polyfills_urlPolyfill_js
-- JavaScript services
addScript $ StaticR js_services_utilRegistry_js
addScript $ StaticR js_services_httpClient_js
addScript $ StaticR js_services_i18n_js
-- JavaScript utils -- JavaScript utils
addScript $ StaticR js_utils_alerts_js addScript $ StaticR js_utils_alerts_js
addScript $ StaticR js_utils_asidenav_js addScript $ StaticR js_utils_asidenav_js
addScript $ StaticR js_utils_asyncForm_js addScript $ StaticR js_utils_asyncForm_js
addScript $ StaticR js_utils_asyncTable_js addScript $ StaticR js_utils_asyncTable_js
addScript $ StaticR js_utils_asyncTableFilter_js
addScript $ StaticR js_utils_checkAll_js addScript $ StaticR js_utils_checkAll_js
addScript $ StaticR js_utils_httpClient_js
addScript $ StaticR js_utils_form_js addScript $ StaticR js_utils_form_js
addScript $ StaticR js_utils_inputs_js addScript $ StaticR js_utils_inputs_js
addScript $ StaticR js_utils_modal_js addScript $ StaticR js_utils_modal_js
addScript $ StaticR js_utils_setup_js
addScript $ StaticR js_utils_showHide_js addScript $ StaticR js_utils_showHide_js
addScript $ StaticR js_utils_tabber_js -- addScript $ StaticR js_utils_tabber_js
addStylesheet $ StaticR css_utils_alerts_scss addStylesheet $ StaticR css_utils_alerts_scss
addStylesheet $ StaticR css_utils_asidenav_scss addStylesheet $ StaticR css_utils_asidenav_scss
addStylesheet $ StaticR css_utils_asyncForm_scss addStylesheet $ StaticR css_utils_asyncForm_scss
@ -2221,12 +2228,9 @@ instance YesodMail UniWorX where
mailT ctx mail = defMailT ctx $ do mailT ctx mail = defMailT ctx $ do
void setMailObjectId void setMailObjectId
setDateCurrent setDateCurrent
replaceMailHeader "Auto-Submitted" $ Just "auto-generated" replaceMailHeader "Sender" . Just . addressEmail =<< getsYesod (appMailFrom . appSettings)
ret <- mail mail <* setMailSmtpData
setMailSmtpData
return ret
instance (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadCrypto m where instance (MonadThrow m, MonadHandler m, HandlerSite m ~ UniWorX) => MonadCrypto m where

View File

@ -121,7 +121,7 @@ postAdminTestR = do
let emailWidget' = wrapForm emailWidget def let emailWidget' = wrapForm emailWidget def
{ formAction = Just . SomeRoute $ AdminTestR { formAction = Just . SomeRoute $ AdminTestR
, formEncoding = emailEnctype , formEncoding = emailEnctype
, formAttrs = [("data-ajax-submit", "")] , formAttrs = [("uw-async-form", "")]
} }

View File

@ -161,6 +161,7 @@ colRating = sortable (Just "rating") (i18nCell MsgRating) $ \DBRow{ dbrOutput=(E
mkRoute = do mkRoute = do
cid <- encrypt subId cid <- encrypt subId
return $ CSubmissionR tid ssh csh sheetName cid CorrectionR return $ CSubmissionR tid ssh csh sheetName cid CorrectionR
mTuple mA mB = (,) <$> mA <*> mB -- Hamlet does not support enough haskell-syntax for this
in mconcat in mconcat
[ anchorCellM mkRoute $(widgetFile "widgets/rating/rating") [ anchorCellM mkRoute $(widgetFile "widgets/rating/rating")
, writerCell $ do , writerCell $ do

View File

@ -862,15 +862,28 @@ makeCourseUserTable cid colChoices psValidator = do
, fltrUserEmail queryUser , fltrUserEmail queryUser
, fltrUserMatriclenr queryUser , fltrUserMatriclenr queryUser
, fltrUserNameEmail queryUser , fltrUserNameEmail queryUser
-- , ("course-user-degree", error "TODO") -- TODO , ("field-name" , FilterColumn $ E.mkContainsFilter $ queryFeaturesField >>> (E.?. StudyTermsName))
-- , ("field" , FilterColumn $ queryFeaturesField error "TODO") -- TODO , ("field-short" , FilterColumn $ E.mkContainsFilter $ queryFeaturesField >>> (E.?. StudyTermsShorthand))
, ("semesternr", FilterColumn $ E.mkExactFilter $ queryFeaturesStudy >>> (E.?. StudyFeaturesSemester)) , ("field-key" , FilterColumn $ E.mkExactFilter $ queryFeaturesField >>> (E.?. StudyTermsKey))
, ("field" , FilterColumn $ E.anyFilter
[ E.mkContainsFilter $ queryFeaturesField >>> (E.?. StudyTermsName)
, E.mkContainsFilter $ queryFeaturesField >>> (E.?. StudyTermsShorthand)
, E.mkExactFilterWith readMay $ queryFeaturesField >>> (E.?. StudyTermsKey)
] )
, ("degree" , FilterColumn $ E.anyFilter
[ E.mkContainsFilter $ queryFeaturesDegree >>> (E.?. StudyDegreeName)
, E.mkContainsFilter $ queryFeaturesDegree >>> (E.?. StudyDegreeShorthand)
, E.mkExactFilterWith readMay $ queryFeaturesDegree >>> (E.?. StudyDegreeKey)
] )
, ("semesternr" , FilterColumn $ E.mkExactFilter $ queryFeaturesStudy >>> (E.?. StudyFeaturesSemester))
-- , ("course-registration", error "TODO") -- TODO -- , ("course-registration", error "TODO") -- TODO
-- , ("course-user-note", error "TODO") -- TODO -- , ("course-user-note", error "TODO") -- TODO
] ]
dbtFilterUI mPrev = mconcat dbtFilterUI mPrev = mconcat
[ fltrUserNameEmailUI mPrev [ fltrUserNameEmailUI mPrev
, fltrUserMatriclenrUI mPrev , fltrUserMatriclenrUI mPrev
, prismAForm (singletonFilter "degree") mPrev $ aopt (searchField False) (fslI MsgStudyFeatureDegree)
, prismAForm (singletonFilter "field") mPrev $ aopt (searchField False) (fslI MsgCourseStudyFeature)
] ]
dbtParams = DBParamsForm dbtParams = DBParamsForm
{ dbParamsFormMethod = POST { dbParamsFormMethod = POST

View File

@ -16,16 +16,18 @@ nullaryPathPiece ''HelpIdentOptions (camelToPathPiece' 1)
embedRenderMessage ''UniWorX ''HelpIdentOptions (("Help" <>) . dropPrefix "HI") embedRenderMessage ''UniWorX ''HelpIdentOptions (("Help" <>) . dropPrefix "HI")
data HelpForm = HelpForm data HelpForm = HelpForm
{ hfReferer:: Maybe (Route UniWorX) { hfReferer :: Maybe (Route UniWorX)
, hfUserId :: Either (Maybe Address) UserId , hfUserId :: Either (Maybe Address) UserId
, hfRequest:: Text , hfSubject :: Maybe Text
, hfRequest :: Text
} }
helpForm :: Maybe (Route UniWorX) -> Maybe UserId -> AForm _ HelpForm helpForm :: (forall msg. RenderMessage UniWorX msg => msg -> Text) -> Maybe (Route UniWorX) -> Maybe UserId -> AForm _ HelpForm
helpForm mReferer mUid = HelpForm helpForm mr mReferer mUid = HelpForm
<$> aopt routeField (fslI MsgHelpProblemPage & inputReadonly) (Just <$> mReferer) <$> aopt routeField (fslI MsgHelpProblemPage & inputReadonly) (Just <$> mReferer)
<*> multiActionA (fslI MsgHelpAnswer) identActions (HIUser <$ mUid) <*> multiActionA (fslI MsgHelpAnswer) identActions (HIUser <$ mUid)
<*> (unTextarea <$> areq textareaField (fslI MsgHelpRequest) Nothing) <*> aopt textField (fslpI MsgHelpSubject $ mr MsgHelpSubject) Nothing
<*> (unTextarea <$> areq textareaField (fslpI MsgHelpRequest $ mr MsgHelpRequest) Nothing)
where where
identActions :: Map _ (AForm _ (Either (Maybe Address) UserId)) identActions :: Map _ (AForm _ (Either (Maybe Address) UserId))
identActions = Map.fromList $ case mUid of identActions = Map.fromList $ case mUid of
@ -33,7 +35,7 @@ helpForm mReferer mUid = HelpForm
Nothing -> defaultActions Nothing -> defaultActions
defaultActions = defaultActions =
[ (HIEmail, Left . Just <$> (Address <$> aopt textField (fslI MsgName) Nothing <*> apreq emailField (fslI MsgEMail) Nothing)) [ (HIEmail, Left . Just <$> (Address <$> aopt textField (fslpI MsgName $ mr MsgName) Nothing <*> apreq emailField (fslpI MsgEMail $ mr MsgEMail) Nothing))
, (HIAnonymous, pure $ Left Nothing) , (HIAnonymous, pure $ Left Nothing)
] ]
@ -43,19 +45,16 @@ postHelpR = do
mUid <- maybeAuthId mUid <- maybeAuthId
mReferer <- flip formResultMaybe return <=< runInputGetResult $ iopt routeField (toPathPiece GetReferer) mReferer <- flip formResultMaybe return <=< runInputGetResult $ iopt routeField (toPathPiece GetReferer)
isModal <- hasCustomHeader HeaderIsModal isModal <- hasCustomHeader HeaderIsModal
MsgRenderer mr <- getMsgRenderer
((res,formWidget),formEnctype) <- runFormPost $ renderAForm FormStandard $ helpForm mReferer mUid ((res,formWidget),formEnctype) <- runFormPost $ renderAForm FormStandard $ helpForm mr mReferer mUid
let form = wrapForm formWidget def
{ formAction = Just $ SomeRoute HelpR
, formEncoding = formEnctype
, formAttrs = [ ("data-ajax-submit", "") | isModal ]
}
formResultModal res HelpR $ \HelpForm{..} -> do formResultModal res HelpR $ \HelpForm{..} -> do
now <- liftIO getCurrentTime now <- liftIO getCurrentTime
hfReferer' <- traverse toTextUrl hfReferer hfReferer' <- traverse toTextUrl hfReferer
queueJob' JobHelpRequest queueJob' JobHelpRequest
{ jSender = hfUserId { jSender = hfUserId
, jHelpSubject = hfSubject
, jHelpRequest = hfRequest , jHelpRequest = hfRequest
, jRequestTime = now , jRequestTime = now
, jReferer = hfReferer' , jReferer = hfReferer'
@ -64,4 +63,8 @@ postHelpR = do
defaultLayout $ do defaultLayout $ do
setTitleI MsgHelpTitle setTitleI MsgHelpTitle
$(widgetFile "help") wrapForm $(widgetFile "help") def
{ formAction = Just $ SomeRoute HelpR
, formEncoding = formEnctype
, formAttrs = [ ("data-ajax-submit", "") | isModal ]
}

View File

@ -170,4 +170,4 @@ homeUpcomingSheets uid = do
, dbtParams = def , dbtParams = def
, dbtIdent = "upcoming-sheets" :: Text , dbtIdent = "upcoming-sheets" :: Text
} }
$(widgetFile "home/upcomingSheets") $(widgetFile "home/upcomingSheets")

View File

@ -116,10 +116,11 @@ postProfileR = do
siteLayout [whamlet|_{MsgProfileFor} ^{nameWidget userDisplayName userSurname}|] $ do siteLayout [whamlet|_{MsgProfileFor} ^{nameWidget userDisplayName userSurname}|] $ do
setTitle . toHtml $ "Profil " <> userIdent setTitle . toHtml $ "Profil " <> userIdent
wrapForm formWidget def let settingsForm = wrapForm formWidget def
{ formAction = Just $ SomeRoute ProfileR { formAction = Just $ SomeRoute ProfileR
, formEncoding = formEnctype , formEncoding = formEnctype
} }
$(widgetFile "profile")
getProfileDataR :: Handler Html getProfileDataR :: Handler Html

View File

@ -199,11 +199,12 @@ getSheetListR tid ssh csh = do
let stats = sheetTypeSum sheetType in -- for statistics over all shown rows let stats = sheetTypeSum sheetType in -- for statistics over all shown rows
case mbSub of case mbSub of
Nothing -> cellTell mempty $ stats Nothing Nothing -> cellTell mempty $ stats Nothing
(Just (Entity sid Submission{..})) -> (Just (Entity sid sub@Submission{..})) ->
let mkCid = encrypt sid let mkCid = encrypt sid
mkRoute = do mkRoute = do
cid' <- mkCid cid' <- mkCid
return $ CSubmissionR tid ssh csh sheetName cid' CorrectionR return $ CSubmissionR tid ssh csh sheetName cid' CorrectionR
mTuple mA mB = (,) <$> mA <*> mB -- Hamlet does not support enough haskell-syntax for this
acell = anchorCellM mkRoute $(widgetFile "widgets/rating/rating") acell = anchorCellM mkRoute $(widgetFile "widgets/rating/rating")
in cellTell acell $ stats submissionRatingPoints in cellTell acell $ stats submissionRatingPoints

View File

@ -462,10 +462,10 @@ sinkSubmission userId mExists isUpdate = do
case isUpdate of case isUpdate of
False -> lift . insert_ $ SubmissionEdit userId now submissionId False -> lift . insert_ $ SubmissionEdit userId now submissionId
True -> do True -> do
Submission{submissionRatingTime} <- lift $ getJust submissionId Submission{submissionRatingTime, submissionRatingBy} <- lift $ getJust submissionId
when (isNothing submissionRatingTime) $ tellSt mempty { sinkSubmissionNotifyRating = Any True } when (submissionRatingBy == Just userId) $ do
lift $ update submissionId [ SubmissionRatingBy =. Just userId, SubmissionRatingTime =. Just now ] when (isNothing submissionRatingTime) $ tellSt mempty { sinkSubmissionNotifyRating = Any True }
-- TODO: Should submissionRatingAssigned change here if userId changes? lift $ update submissionId [ SubmissionRatingTime =. Just now ]
tellSt $ mempty{ sinkSubmissionTouched = Any True } tellSt $ mempty{ sinkSubmissionTouched = Any True }
finalize :: SubmissionSinkState -> YesodJobDB UniWorX () finalize :: SubmissionSinkState -> YesodJobDB UniWorX ()

View File

@ -63,6 +63,7 @@ headedRowSelector toExternal fromExternal attrs colonnade tdata = do
-> return Nothing -> return Nothing
view _ name attributes val _ = view _ name attributes val _ =
-- TODO: move this to a *.hamlet file
[whamlet| [whamlet|
<label style="display: block"> <label style="display: block">
<input type=checkbox name=#{name} value=#{toPathPiece extId} *{attributes} :isRight val:checked> <input type=checkbox name=#{name} value=#{toPathPiece extId} *{attributes} :isRight val:checked>

View File

@ -595,7 +595,7 @@ instance Monoid x => Monoid (DBCell (RWST (Maybe (Env, FileEnv), UniWorX, [Lang]
instance IsDBTable m a => IsString (DBCell m a) where instance IsDBTable m a => IsString (DBCell m a) where
fromString = cell . fromString fromString = cell . fromString
-- | DB-backed tables with pagination, may short-circuit a handler
dbTable :: forall m x. IsDBTable m x => PSValidator m x -> DBTable m x -> DB (DBResult m x) dbTable :: forall m x. IsDBTable m x => PSValidator m x -> DBTable m x -> DB (DBResult m x)
dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> dbtIdent), dbtStyle = DBStyle{..}, .. } = do dbTable PSValidator{..} dbtable@DBTable{ dbtIdent = dbtIdent'@(toPathPiece -> dbtIdent), dbtStyle = DBStyle{..}, .. } = do
let let

View File

@ -15,7 +15,8 @@ import Yesod.Auth as Import
import Yesod.Core.Types as Import (loggerSet) import Yesod.Core.Types as Import (loggerSet)
import Yesod.Default.Config2 as Import import Yesod.Default.Config2 as Import
import Utils as Import import Utils as Import
import Utils.Modal as Import import Utils.Frontend.Modal as Import
import Utils.Frontend.I18n as Import
import Yesod.Core.Json as Import (provideJson) import Yesod.Core.Json as Import (provideJson)
import Yesod.Core.Types.Instances as Import () import Yesod.Core.Types.Instances as Import ()

View File

@ -16,10 +16,11 @@ import Data.Bitraversable
dispatchJobHelpRequest :: Either (Maybe Address) UserId dispatchJobHelpRequest :: Either (Maybe Address) UserId
-> UTCTime -> UTCTime
-> Maybe Text -- ^ Help Subject
-> Text -- ^ Help Request -> Text -- ^ Help Request
-> Maybe Text -- ^ Referer -> Maybe Text -- ^ Referer
-> Handler () -> Handler ()
dispatchJobHelpRequest jSender jRequestTime jHelpRequest jReferer = do dispatchJobHelpRequest jSender jRequestTime jHelpSubject jHelpRequest jReferer = do
supportAddress <- getsYesod $ appMailSupport . appSettings supportAddress <- getsYesod $ appMailSupport . appSettings
userInfo <- bitraverse return (runDB . getEntity) jSender userInfo <- bitraverse return (runDB . getEntity) jSender
let userAddress = either let userAddress = either
@ -28,8 +29,9 @@ dispatchJobHelpRequest jSender jRequestTime jHelpRequest jReferer = do
userInfo userInfo
mailT def $ do mailT def $ do
_mailTo .= [supportAddress] _mailTo .= [supportAddress]
whenIsJust userAddress $ addMailHeader "Reply-To" . renderAddress whenIsJust userAddress (_mailFrom .=)
setSubjectI MsgMailSubjectSupport replaceMailHeader "Auto-Submitted" $ Just "no"
setSubjectI $ maybe MsgMailSubjectSupport MsgMailSubjectSupportCustom jHelpSubject
setDate jRequestTime setDate jRequestTime
rtime <- formatTimeMail SelFormatDateTime jRequestTime rtime <- formatTimeMail SelFormatDateTime jRequestTime
addPart ($(ihamletFile "templates/mail/support.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX)) addPart ($(ihamletFile "templates/mail/support.hamlet") :: HtmlUrlI18n UniWorXMessage (Route UniWorX))

View File

@ -22,6 +22,7 @@ dispatchNotificationCorrectionsAssigned nUser nSheet jRecipient = do
] ]
return (course, sheet, nbrSubs) return (course, sheet, nbrSubs)
when (nbrSubs > 0) . userMailT jRecipient $ do when (nbrSubs > 0) . userMailT jRecipient $ do
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectCorrectionsAssigned courseShorthand sheetName setSubjectI $ MsgMailSubjectCorrectionsAssigned courseShorthand sheetName
MsgRenderer mr <- getMailMsgRenderer MsgRenderer mr <- getMailMsgRenderer

View File

@ -19,6 +19,7 @@ dispatchNotificationCorrectionsNotDistributed nSheet jRecipient = do
] ]
return (course, sheet, nbrSubs) return (course, sheet, nbrSubs)
when (nbrSubs > 0) . userMailT jRecipient $ do when (nbrSubs > 0) . userMailT jRecipient $ do
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectSubmissionsUnassigned courseShorthand sheetName setSubjectI $ MsgMailSubjectSubmissionsUnassigned courseShorthand sheetName
MsgRenderer mr <- getMailMsgRenderer MsgRenderer mr <- getMailMsgRenderer
let termDesc = mr . ShortTermIdentifier $ unTermKey courseTerm let termDesc = mr . ShortTermIdentifier $ unTermKey courseTerm

View File

@ -17,6 +17,7 @@ dispatchNotificationSheetActive nSheet jRecipient = userMailT jRecipient $ do
sheet <- getJust nSheet sheet <- getJust nSheet
course <- belongsToJust sheetCourse sheet course <- belongsToJust sheetCourse sheet
return (course, sheet) return (course, sheet)
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectSheetActive courseShorthand sheetName setSubjectI $ MsgMailSubjectSheetActive courseShorthand sheetName
MsgRenderer mr <- getMailMsgRenderer MsgRenderer mr <- getMailMsgRenderer

View File

@ -20,6 +20,7 @@ dispatchNotificationSheetSoonInactive nSheet jRecipient = userMailT jRecipient $
sheet <- getJust nSheet sheet <- getJust nSheet
course <- belongsToJust sheetCourse sheet course <- belongsToJust sheetCourse sheet
return (course, sheet) return (course, sheet)
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectSheetSoonInactive courseShorthand sheetName setSubjectI $ MsgMailSubjectSheetSoonInactive courseShorthand sheetName
MsgRenderer mr <- getMailMsgRenderer MsgRenderer mr <- getMailMsgRenderer
@ -45,6 +46,7 @@ dispatchNotificationSheetInactive nSheet jRecipient = userMailT jRecipient $ do
-- E.distinctOn [E.don (subUser E.^. SubmissionUserUser)] -- Not necessary due to UniqueSubmisionUser -- E.distinctOn [E.don (subUser E.^. SubmissionUserUser)] -- Not necessary due to UniqueSubmisionUser
return (E.countRows :: E.SqlExpr (E.Value Int64)) return (E.countRows :: E.SqlExpr (E.Value Int64))
return (course, sheet, nrSubs, nrSubmitters) return (course, sheet, nrSubs, nrSubmitters)
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectSheetInactive courseShorthand sheetName setSubjectI $ MsgMailSubjectSheetInactive courseShorthand sheetName
MsgRenderer mr <- getMailMsgRenderer MsgRenderer mr <- getMailMsgRenderer

View File

@ -22,6 +22,7 @@ dispatchNotificationSubmissionRated nSubmission jRecipient = userMailT jRecipien
course <- belongsToJust sheetCourse sheet course <- belongsToJust sheetCourse sheet
corrector <- traverse getJust submissionRatingBy corrector <- traverse getJust submissionRatingBy
return (course, sheet, submission, corrector) return (course, sheet, submission, corrector)
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectSubmissionRated courseShorthand setSubjectI $ MsgMailSubjectSubmissionRated courseShorthand
csid <- encrypt nSubmission csid <- encrypt nSubmission

View File

@ -19,6 +19,7 @@ dispatchNotificationUserRightsUpdate nUser _originalRights jRecipient = userMai
adminSchools <- getSchoolsOf nUser UserAdminSchool UserAdminUser adminSchools <- getSchoolsOf nUser UserAdminSchool UserAdminUser
lecturerSchools <- getSchoolsOf nUser UserLecturerSchool UserLecturerUser lecturerSchools <- getSchoolsOf nUser UserLecturerSchool UserLecturerUser
return (user,adminSchools,lecturerSchools) return (user,adminSchools,lecturerSchools)
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI $ MsgMailSubjectUserRightsUpdate userDisplayName setSubjectI $ MsgMailSubjectUserRightsUpdate userDisplayName
-- MsgRenderer mr <- getMailMsgRenderer -- MsgRenderer mr <- getMailMsgRenderer
addAlternatives $ do addAlternatives $ do

View File

@ -13,6 +13,7 @@ import Utils.Lens
dispatchJobSendTestEmail :: Email -> MailContext -> Handler () dispatchJobSendTestEmail :: Email -> MailContext -> Handler ()
dispatchJobSendTestEmail jEmail jMailContext = mailT jMailContext $ do dispatchJobSendTestEmail jEmail jMailContext = mailT jMailContext $ do
_mailTo .= [Address Nothing jEmail] _mailTo .= [Address Nothing jEmail]
replaceMailHeader "Auto-Submitted" $ Just "auto-generated"
setSubjectI MsgMailTestSubject setSubjectI MsgMailTestSubject
now <- liftIO getCurrentTime now <- liftIO getCurrentTime
nDT <- formatTimeMail SelFormatDateTime now nDT <- formatTimeMail SelFormatDateTime now

View File

@ -18,7 +18,10 @@ data Job = JobSendNotification { jRecipient :: UserId, jNotification :: Notifica
| JobQueueNotification { jNotification :: Notification } | JobQueueNotification { jNotification :: Notification }
| JobHelpRequest { jSender :: Either (Maybe Address) UserId | JobHelpRequest { jSender :: Either (Maybe Address) UserId
, jRequestTime :: UTCTime , jRequestTime :: UTCTime
, jHelpRequest :: Text, jReferer :: Maybe Text } , jHelpSubject :: Maybe Text
, jHelpRequest :: Text
, jReferer :: Maybe Text
}
| JobSetLogSettings { jInstance :: InstanceId, jLogSettings :: LogSettings } | JobSetLogSettings { jInstance :: InstanceId, jLogSettings :: LogSettings }
| JobDistributeCorrections { jSheet :: SheetId } | JobDistributeCorrections { jSheet :: SheetId }
deriving (Eq, Ord, Show, Read, Generic, Typeable) deriving (Eq, Ord, Show, Read, Generic, Typeable)

View File

@ -27,7 +27,7 @@ module Mail
, setSubjectI, setMailObjectId, setMailObjectId' , setSubjectI, setMailObjectId, setMailObjectId'
, setDate, setDateCurrent , setDate, setDateCurrent
, setMailSmtpData , setMailSmtpData
, _mailFrom, _mailTo, _mailCc, _mailBcc, _mailHeaders, _mailParts , _mailFrom, _mailTo, _mailCc, _mailBcc, _mailHeaders, _mailHeader, _mailParts
, _partType, _partEncoding, _partFilename, _partHeaders, _partContent , _partType, _partEncoding, _partFilename, _partHeaders, _partContent
) where ) where
@ -99,9 +99,18 @@ import Data.Universe.Instances.Reverse.Hashable ()
import GHC.Exts (IsList) import GHC.Exts (IsList)
import Control.Monad.Trans.Maybe (MaybeT(..))
import Data.CaseInsensitive (CI)
import qualified Data.CaseInsensitive as CI
makeLenses_ ''Mail makeLenses_ ''Mail
makeLenses_ ''Part makeLenses_ ''Part
_mailHeader :: CI ByteString -> Traversal' Mail Text
_mailHeader hdrName = _mailHeaders . traverse . filtered (views _1 $ (== hdrName) . CI.mk) . _2
newtype MailT m a = MailT { _unMailT :: RWST MailContext MailSmtpData Mail m a } newtype MailT m a = MailT { _unMailT :: RWST MailContext MailSmtpData Mail m a }
deriving newtype ( MonadTrans, Monad, Functor, MonadFail, Applicative, Alternative, MonadPlus deriving newtype ( MonadTrans, Monad, Functor, MonadFail, Applicative, Alternative, MonadPlus
@ -443,7 +452,10 @@ setDate time = do
setMailSmtpData :: (MonadHandler m, YesodMail (HandlerSite m)) => MailT m () setMailSmtpData :: (MonadHandler m, YesodMail (HandlerSite m)) => MailT m ()
setMailSmtpData = do setMailSmtpData = do
Address _ from <- use _mailFrom Just (Address _ from) <- runMaybeT $ asum
[ MaybeT . preuses (_mailHeader "Sender") $ fromString . unpack
, use _mailFrom
]
recps <- Set.fromList . map addressEmail . concat <$> forM [_mailTo, _mailCc, _mailBcc] use recps <- Set.fromList . map addressEmail . concat <$> forM [_mailTo, _mailCc, _mailBcc] use
tell $ mempty { smtpRecipients = recps } tell $ mempty { smtpRecipients = recps }

View File

@ -0,0 +1,41 @@
module Utils.Frontend.I18n
( FrontendMessage(..)
) where
import ClassyPrelude
import Data.Universe
import Control.Lens
import Utils.PathPiece
import Web.PathPieces
import Data.Aeson
import Data.Aeson.Types (toJSONKeyText)
import Data.Aeson.TH
import qualified Data.Char as Char
-- | I18n-Messages used in JavaScript-Frontend
--
-- Only nullary constructors are supported
--
-- @MsgCamelCaseIdentifier@ gets translated to @camelCaseIdentifier@ in Frontend (see `nullaryPathPiece` and `deriveJSON` below)
data FrontendMessage = MsgFilesSelected
| MsgSelectFile
| MsgSelectFiles
| MsgAsyncFormFailure
deriving (Eq, Ord, Enum, Bounded, Read, Show, Generic, Typeable)
instance Universe FrontendMessage
instance Finite FrontendMessage
instance Hashable FrontendMessage
nullaryPathPiece ''FrontendMessage $ over _head Char.toLower . mconcat . drop 1 . splitCamel
deriveJSON defaultOptions
{ constructorTagModifier = over _head Char.toLower . mconcat . drop 1 . splitCamel
} ''FrontendMessage
instance ToJSONKey FrontendMessage where
toJSONKey = toJSONKeyText toPathPiece
instance FromJSONKey FrontendMessage where
fromJSONKey = FromJSONKeyTextParser $ parseJSON . String

View File

@ -1,4 +1,4 @@
module Utils.Modal module Utils.Frontend.Modal
( Modal(..) ( Modal(..)
, customModal , customModal
, modal , modal
@ -7,7 +7,6 @@ module Utils.Modal
import ClassyPrelude.Yesod import ClassyPrelude.Yesod
import Control.Lens import Control.Lens
import Control.Lens.Extras (is)
import Utils.Route import Utils.Route
import Settings (widgetFile) import Settings (widgetFile)
@ -22,13 +21,11 @@ data Modal site = Modal
customModal :: Modal site -> WidgetT site IO () customModal :: Modal site -> WidgetT site IO ()
customModal Modal{..} = do customModal Modal{..} = do
let isDynamic = is _Left modalContent
modalId' <- maybe newIdent return modalId
triggerId' <- maybe newIdent return modalTriggerId triggerId' <- maybe newIdent return modalTriggerId
$(widgetFile "widgets/modal/modal") $(widgetFile "widgets/modal/modal")
route <- for (modalContent ^? _Left) toTextUrl route <- traverse toTextUrl $ modalContent ^? _Left
modalTrigger route triggerId' modalTrigger route triggerId'
-- | Create a link to a modal -- | Create a link to a modal

View File

@ -1,23 +1,3 @@
/* ALERTS */
/**
.alert
Regular Info Alert
Disappears automatically after 30 seconds
Disappears after x seconds if explicitly specified via data-decay='x'
Can be told not to disappear with data-decay='0'
.alert-success
Disappears automatically after 30 seconds
.alert-warning
Does not disappear
Orange regardless of user's selected theme
.alert-error
Does not disappear
Red regardless of user's selected theme
*/
.alerts { .alerts {
position: fixed; position: fixed;
bottom: 0; bottom: 0;

View File

@ -55,10 +55,6 @@
.asidenav__box-title { .asidenav__box-title {
font-size: 18px; font-size: 18px;
padding-left: 10px; padding-left: 10px;
&.js-show-hide__toggle::before {
z-index: 1;
}
} }
} }
} }
@ -94,18 +90,9 @@
margin-top: 30px; margin-top: 30px;
background-color: transparent; background-color: transparent;
transition: all .2s ease; transition: all .2s ease;
padding: 30px 13px 10px; padding: 10px 13px;
margin: 0; margin: 0;
border-bottom: 1px solid var(--color-grey); border-bottom: 1px solid var(--color-grey);
&.js-show-hide__toggle {
&::before {
left: auto;
right: 20px;
color: var(--color-font);
}
}
} }
/* LOGO */ /* LOGO */
@ -361,9 +348,5 @@
background-color: var(--color-lightwhite); background-color: var(--color-lightwhite);
} }
} }
.js-show-hide__toggle::before {
content: none;
}
} }
} }

View File

@ -1,4 +1,4 @@
.async-form-response { .async-form__response {
margin: 20px 0; margin: 20px 0;
position: relative; position: relative;
width: 100%; width: 100%;
@ -7,15 +7,15 @@
padding-top: 60px; padding-top: 60px;
} }
.async-form-response::before, .async-form__response::before,
.async-form-response::after { .async-form__response::after {
position: absolute; position: absolute;
top: 0px; top: 0px;
left: 50%; left: 50%;
display: block; display: block;
} }
.async-form-response--success::before { .async-form__response--success::before {
content: ''; content: '';
width: 17px; width: 17px;
height: 28px; height: 28px;
@ -24,7 +24,7 @@
transform: translateX(-50%) rotate(45deg); transform: translateX(-50%) rotate(45deg);
} }
.async-form-response--info::before { .async-form__response--info::before {
content: ''; content: '';
width: 5px; width: 5px;
height: 30px; height: 30px;
@ -32,7 +32,7 @@
background-color: #777; background-color: #777;
transform: translateX(-50%); transform: translateX(-50%);
} }
.async-form-response--info::after { .async-form__response--info::after {
content: ''; content: '';
width: 5px; width: 5px;
height: 5px; height: 5px;
@ -40,14 +40,14 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
.async-form-response--warning::before { .async-form__response--warning::before {
content: ''; content: '';
width: 5px; width: 5px;
height: 30px; height: 30px;
background-color: rgb(255, 187, 0); background-color: rgb(255, 187, 0);
transform: translateX(-50%); transform: translateX(-50%);
} }
.async-form-response--warning::after { .async-form__response--warning::after {
content: ''; content: '';
width: 5px; width: 5px;
height: 5px; height: 5px;
@ -56,14 +56,14 @@
transform: translateX(-50%); transform: translateX(-50%);
} }
.async-form-response--error::before { .async-form__response--error::before {
content: ''; content: '';
width: 5px; width: 5px;
height: 40px; height: 40px;
background-color: #940d0d; background-color: #940d0d;
transform: translateX(-50%) rotate(-45deg); transform: translateX(-50%) rotate(-45deg);
} }
.async-form-response--error::after { .async-form__response--error::after {
content: ''; content: '';
width: 5px; width: 5px;
height: 40px; height: 40px;
@ -71,7 +71,7 @@
transform: translateX(-50%) rotate(45deg); transform: translateX(-50%) rotate(45deg);
} }
.async-form-loading { .async-form--loading {
opacity: 0.1; opacity: 0.1;
transition: opacity 800ms ease-out; transition: opacity 800ms ease-out;
pointer-events: none; pointer-events: none;

View File

@ -16,8 +16,8 @@
label { label {
display: block; display: block;
height: 24px; height: 20px;
width: 24px; width: 20px;
background-color: #f3f3f3; background-color: #f3f3f3;
box-shadow: inset 0 1px 2px 1px rgba(50, 50, 50, 0.05); box-shadow: inset 0 1px 2px 1px rgba(50, 50, 50, 0.05);
border: 2px solid var(--color-primary); border: 2px solid var(--color-primary);
@ -55,14 +55,16 @@
:checked + label::before { :checked + label::before {
background-color: white; background-color: white;
transform: rotate(45deg); transform: rotate(45deg);
left: 4px; left: 2px;
top: 11px;
} }
:checked + label::after { :checked + label::after {
background-color: white; background-color: white;
transform: rotate(-45deg); transform: rotate(-45deg);
top: 11px; top: 9px;
width: 13px; width: 12px;
left: 7px;
} }
[disabled] + label { [disabled] + label {

View File

@ -17,7 +17,7 @@ fieldset {
} }
} }
[data-autosubmit][type="submit"] { [uw-auto-submit-button][type="submit"] {
animation: fade-in 500ms ease-in-out backwards; animation: fade-in 500ms ease-in-out backwards;
animation-delay: 500ms; animation-delay: 500ms;
} }

View File

@ -25,27 +25,26 @@
color: var(--color-fontsec); color: var(--color-fontsec);
} }
.form-group__label { .form-group-label {
font-weight: 600; font-weight: 600;
padding-top: 6px; padding-top: 6px;
} }
.form-group__hint { .form-group-label__hint {
margin-top: 7px; margin-top: 7px;
color: var(--color-fontsec); color: var(--color-fontsec);
font-size: 0.9rem; font-size: 0.9rem;
} }
.form-group--required { .form-group--required {
.form-group-label__caption::after {
.form-group__label::after {
content: ' *'; content: ' *';
color: var(--color-error); color: var(--color-error);
} }
} }
.form-group--optional { .form-group--optional {
.form-group__label::after { .form-group-label__caption::after {
content: ''; content: '';
} }
} }
@ -196,7 +195,11 @@ option {
} }
} }
/* CUSTOM FILE INPUT */ /* FILE INPUT */
.file-input {
display: none;
}
.file-input__label { .file-input__label {
cursor: pointer; cursor: pointer;
display: inline-block; display: inline-block;
@ -206,6 +209,31 @@ option {
border-radius: 3px; border-radius: 3px;
} }
.file-input__input--hidden { .file-input__info {
display: none; font-size: .9rem;
font-style: italic;
margin: 10px 0;
color: var(--color-fontsec);
}
.file-input__list {
margin-left: 40px;
margin-top: 10px;
font-weight: 600;
}
/* PREVIOUSLY UPLOADED FILES */
.file-uploads-label {
margin-bottom: 10px;
}
.file-container {
display: flex;
align-items: center;
margin-bottom: 10px;
.checkbox {
margin-left: 12px;
}
} }

View File

@ -14,9 +14,6 @@
padding: 0 65px 0 20px; padding: 0 65px 0 20px;
overflow: auto; overflow: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
transition:
opacity .2s .1s ease-in-out,
transform .3s ease-in-out;
pointer-events: none; pointer-events: none;
opacity: 0; opacity: 0;
@ -25,6 +22,9 @@
pointer-events: auto; pointer-events: auto;
z-index: 200; z-index: 200;
transform: translate(-50%, -50%) scale(1, 1); transform: translate(-50%, -50%) scale(1, 1);
transition:
opacity .2s .1s ease-in-out,
transform .3s ease-in-out;
} }
} }

View File

@ -5,10 +5,6 @@
display: flex; display: flex;
} }
.radio-group__option {
min-width: 30px;
}
.radio { .radio {
position: relative; position: relative;
display: inline-block; display: inline-block;

View File

@ -1,10 +1,9 @@
$show-hide-toggle-size: 6px; $show-hide-toggle-size: 6px;
.js-show-hide__toggle { .show-hide__toggle {
position: relative; position: relative;
cursor: pointer; cursor: pointer;
padding: 3px 7px;
&:hover { &:hover {
background-color: var(--color-grey-lighter); background-color: var(--color-grey-lighter);
@ -12,32 +11,33 @@ $show-hide-toggle-size: 6px;
} }
} }
.js-show-hide__toggle::before { .show-hide__toggle::before {
content: ''; content: '';
position: absolute; position: absolute;
width: $show-hide-toggle-size; width: $show-hide-toggle-size;
height: $show-hide-toggle-size; height: $show-hide-toggle-size;
left: -15px; left: -15px;
top: 12px - $show-hide-toggle-size / 2; top: 50%;
color: var(--color-primary); color: var(--color-primary);
border-right: 2px solid currentColor; border-right: 2px solid currentColor;
border-top: 2px solid currentColor; border-top: 2px solid currentColor;
transition: transform .2s ease; transition: transform .2s ease;
transform-origin: ($show-hide-toggle-size / 2); transform: translateY(-50%) rotate(-45deg);
transform: translateY($show-hide-toggle-size) rotate(-45deg);
} }
.js-show-hide__target { .show-hide__toggle--right::before {
transition: all .2s ease; left: auto;
right: 20px;
color: var(--color-font);
} }
.js-show-hide--collapsed { .show-hide--collapsed {
.js-show-hide__toggle::before { .show-hide__toggle::before {
transform: translateY($show-hide-toggle-size / 3) rotate(135deg); transform: translateY(-50%) rotate(135deg);
} }
.js-show-hide__target { :not(.show-hide__toggle) {
display: block; display: block;
height: 0; height: 0;
margin: 0; margin: 0;

View File

@ -1,9 +1,7 @@
(function collonadeClosure() { (function collonadeClosure() {
'use strict'; 'use strict';
window.utils = window.utils || {}; window.HttpClient = (function() {
window.utils.httpClient = (function() {
function _fetch(url, method, additionalHeaders, body) { function _fetch(url, method, additionalHeaders, body) {
var requestOptions = { var requestOptions = {

View File

@ -0,0 +1,44 @@
(function() {
'use strict';
/**
* I18n
*
* This module stores and serves translated strings, according to the users language settings.
*
* Translations are stored in /messages/frontend/*.msg.
*
* To make additions to any of these files accessible to JavaScrip Utilities
* you need to add them to the respective *.msg file and to the list of FrontendMessages
* in /src/Utils/Frontend/I18n.hs.
*
*/
window.I18n = (function() {
var translations = {};
function addTranslation(id, translation) {
translations[id] = translation;
}
function addManyTranslations(manyTranslations) {
Object.keys(manyTranslations).forEach(function(key) {
addTranslation(key, manyTranslations[key]);
});
}
function getTranslation(id) {
if (!translations[id]) {
throw new Error('I18N Error: Translation missing for »' + id + '«!');
}
return translations[id];
}
// public API
return {
add: addTranslation,
addMany: addManyTranslations,
get: getTranslation,
};
})();
})();

View File

@ -0,0 +1,155 @@
(function() {
'use strict';
var registeredUtils = [];
var activeUtilInstances = [];
var DEBUG_MODE = /localhost/.test(window.location.href) && 0;
// Registry
// (revealing module pattern)
window.UtilRegistry = (function() {
/**
* function registerUtil
*
* utils need to have at least these properties:
* name: string | utils name, e.g. 'example'
* selector: string | utils selector, e.g. '[uw-example]'
* setup: Function | utils setup function, see below
*
* setup function must return instance object with at least these properties:
* name: string | utils name
* element: HTMLElement | element the util is applied to
* destroy: Function | function to destroy the util and remove any listeners
*
* @param util Object Utility that should be added to the registry
*/
function registerUtil(util) {
if (DEBUG_MODE > 2) {
console.log('registering util "' + util.name + '"');
console.log({ util });
}
registeredUtils.push(util);
}
function deregisterUtil(name, destroy) {
var utilIndex = _findUtilIndex(name);
if (utilIndex >= 0) {
if (destroy === true) {
_destroyUtilInstances(name);
}
registeredUtils.splice(utilIndex, 1);
}
}
function setupAllUtils(scope) {
if (DEBUG_MODE > 1) {
console.info('registered js utilities:');
console.table(registeredUtils);
}
registeredUtils.forEach(function(util) {
setupUtil(util, scope);
});
}
function setupUtil(util, scope) {
if (DEBUG_MODE > 2) {
console.log('setting up util', { util });
}
scope = scope || document.body;
if (util && typeof util.setup === 'function') {
const elements = _findUtilElements(util, scope);
elements.forEach(function(element) {
var utilInstance = null;
try {
utilInstance = util.setup(element);
} catch(err) {
if (DEBUG_MODE > 0) {
console.warn('Error while trying to initialize a utility!', { util , element, err });
}
}
if (utilInstance) {
if (DEBUG_MODE > 2) {
console.info('Got utility instance for utility "' + util.name + '"', { utilInstance });
}
activeUtilInstances.push(utilInstance);
}
});
}
}
function findUtil(name) {
return registeredUtils.find(function(util) {
return util.name === name;
});
}
function _findUtilElements(util, scope) {
if (scope && scope.matches(util.selector)) {
return [scope];
}
return Array.from(scope.querySelectorAll(util.selector));
}
function _findUtilIndex(name) {
return registeredUtils.findIndex(function(util) {
return util.name === name;
});
}
function _destroyUtilInstances(name) {
activeUtilInstances
.map(function(util, index) {
return {
util: util,
index: index,
};
}).filter(function(activeUtil) {
// find utils instances to destroy
return activeUtil.util.name === name;
}).forEach(function(activeUtil) {
// destroy util instance
activeUtil.util.destroy();
delete activeUtilInstances[activeUtil.index];
});
// get rid of now empty array slots
activeUtilInstances = activeUtilInstances.filter(function(util) {
return !!util;
})
}
// public API
return {
register: registerUtil,
deregister: deregisterUtil,
setupAll: setupAllUtils,
setup: setupUtil,
find: findUtil,
}
})();
document.addEventListener('DOMContentLoaded', function() {
window.UtilRegistry.setupAll();
});
// REMOVE ME. JUST HERE TO AVOID JS ERRORS
window.utils = {
setup: function(name) {
console.log('not really setting up', name);
},
};
})();

View File

@ -1,68 +1,126 @@
(function() { (function() {
'use strict'; 'use strict';
window.utils = window.utils || {}; /**
*
* Alerts Utility
* makes alerts interactive
*
* Attribute: uw-alerts
*
* Types of alerts:
* [default]
* Regular Info Alert
* Disappears automatically after 30 seconds
* Disappears after x seconds if explicitly specified via data-decay='x'
* Can be told not to disappear with data-decay='0'
*
* [success]
* Currently no special visual appearance
* Disappears automatically after 30 seconds
*
* [warning]
* Will be coloured warning-orange regardless of user's selected theme
* Does not disappear
*
* [error]
* Will be coloured error-red regardless of user's selected theme
* Does not disappear
*
* Example usage:
* <div .alerts uw-alerts>
* <div .alerts__toggler>
* <div .alert.alert-info>
* <div .alert__closer>
* <div .alert__icon>
* <div .alert__content>
* This is some information
*
*/
var ALERTS_CLASS = 'alerts'; var ALERTS_UTIL_NAME = 'alerts';
var ALERTS_UTIL_SELECTOR = '[uw-alerts]';
var ALERTS_INITIALIZED_CLASS = 'alerts--initialized';
var ALERTS_TOGGLER_CLASS = 'alerts__toggler'; var ALERTS_TOGGLER_CLASS = 'alerts__toggler';
var ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible'; var ALERTS_TOGGLER_VISIBLE_CLASS = 'alerts__toggler--visible';
var ALERTS_TOGGLER_APPEAR_DELAY = 120; var ALERTS_TOGGLER_APPEAR_DELAY = 120;
var ALERT_CLASS = 'alert'; var ALERT_CLASS = 'alert';
var ALERT_INITIALIZED_CLASS = 'alert--initialized';
var ALERT_CLOSER_CLASS = 'alert__closer'; var ALERT_CLOSER_CLASS = 'alert__closer';
var ALERT_INVISIBLE_CLASS = 'alert--invisible'; var ALERT_INVISIBLE_CLASS = 'alert--invisible';
var ALERT_AUTO_HIDE_DELAY = 10; var ALERT_AUTO_HIDE_DELAY = 10;
var ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success'; var ALERT_AUTOCLOSING_MATCHER = '.alert-info, .alert-success';
var JS_INITIALIZED_CLASS = 'js-initialized'; var alertsUtil = function(element) {
window.utils.alerts = function(alertsEl) {
if (alertsEl.classList.contains(JS_INITIALIZED_CLASS)) {
return false;
}
if (!alertsEl || !alertsEl.classList.contains(ALERTS_CLASS)) {
throw new Error('utils.alerts has to be called with alerts element');
}
var togglerCheckRequested = false; var togglerCheckRequested = false;
var togglerElement;
var alertElements;
var togglerEl = alertsEl.querySelector('.' + ALERTS_TOGGLER_CLASS); function init() {
if (!element) {
throw new Error('Alerts util has to be called with an element!');
}
var alertElements = Array.from(alertsEl.querySelectorAll('.' + ALERT_CLASS)) if (element.classList.contains(ALERTS_INITIALIZED_CLASS)) {
.filter(function(alert) { return false;
return !alert.classList.contains(JS_INITIALIZED_CLASS); }
togglerElement = element.querySelector('.' + ALERTS_TOGGLER_CLASS);
alertElements = gatherAlertElements();
initToggler();
initAlerts();
// mark initialized
element.classList.add(ALERTS_INITIALIZED_CLASS);
return {
name: ALERTS_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function gatherAlertElements() {
return Array.from(element.querySelectorAll('.' + ALERT_CLASS)).filter(function(alert) {
return !alert.classList.contains(ALERT_INITIALIZED_CLASS);
}); });
}
function initToggler() { function initToggler() {
togglerEl.addEventListener('click', function() { togglerElement.addEventListener('click', function() {
alertElements.forEach(function(alertEl) { alertElements.forEach(function(alertEl) {
toggleAlert(alertEl, true); toggleAlert(alertEl, true);
}); });
togglerEl.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS); togglerElement.classList.remove(ALERTS_TOGGLER_VISIBLE_CLASS);
}); });
alertsEl.classList.add(JS_INITIALIZED_CLASS); element.classList.add(ALERTS_INITIALIZED_CLASS);
} }
function initAlert(alertEl) { function initAlerts() {
alertElements.forEach(initAlert);
}
function initAlert(alertElement) {
var autoHideDelay = ALERT_AUTO_HIDE_DELAY; var autoHideDelay = ALERT_AUTO_HIDE_DELAY;
if (alertEl.dataset.decay) { if (alertElement.dataset.decay) {
autoHideDelay = parseInt(alertEl.dataset.decay, 10); autoHideDelay = parseInt(alertElement.dataset.decay, 10);
} }
var closeEl = alertEl.querySelector('.' + ALERT_CLOSER_CLASS); var closeEl = alertElement.querySelector('.' + ALERT_CLOSER_CLASS);
closeEl.addEventListener('click', function() { closeEl.addEventListener('click', function() {
toggleAlert(alertEl); toggleAlert(alertElement);
}); });
if (autoHideDelay > 0 && alertEl.matches(ALERT_AUTOCLOSING_MATCHER)) { if (autoHideDelay > 0 && alertElement.matches(ALERT_AUTOCLOSING_MATCHER)) {
window.setTimeout(function() { window.setTimeout(function() {
toggleAlert(alertEl); toggleAlert(alertElement);
}, autoHideDelay * 1000); }, autoHideDelay * 1000);
} }
alertEl.classList.add(JS_INITIALIZED_CLASS); alertElement.classList.add(ALERTS_INITIALIZED_CLASS);
} }
function toggleAlert(alertEl, visible) { function toggleAlert(alertEl, visible) {
@ -80,17 +138,19 @@
}, true); }, true);
window.setTimeout(function() { window.setTimeout(function() {
togglerEl.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden); togglerElement.classList.toggle(ALERTS_TOGGLER_VISIBLE_CLASS, alertsHidden);
togglerCheckRequested = false; togglerCheckRequested = false;
}, ALERTS_TOGGLER_APPEAR_DELAY); }, ALERTS_TOGGLER_APPEAR_DELAY);
} }
initToggler(); return init();
alertElements.forEach(initAlert);
return {
scope: alertsEl,
destroy: function() {},
};
}; };
if (UtilRegistry) {
UtilRegistry.register({
name: ALERTS_UTIL_NAME,
selector: ALERTS_UTIL_SELECTOR,
setup: alertsUtil,
});
}
})(); })();

View File

@ -1,31 +1,74 @@
(function() { (function() {
'use strict'; 'use strict';
window.utils = window.utils || {}; /**
*
* Asidenav Utility
* Correctly positions hovered asidenav submenus and handles the favorites button on mobile
*
* Attribute: uw-asidenav
*
* Example usage:
* <div uw-asidenav>
* <div .asidenav>
* <div .asidenav__box>
* <ul .asidenav__list.list--iconless>
* <li .asidenav__list-item>
* <a .asidenav__link-wrapper href="#">
* <div .asidenav__link-shorthand>EIP
* <div .asidenav__link-label>Einführung in die Programmierung
* <div .asidenav__nested-list-wrapper>
* <ul .asidenav__nested-list.list--iconless>
* Übungsblätter
* ...
*
*/
var ASIDENAV_UTIL_NAME = 'asidenav';
var ASIDENAV_UTIL_SELECTOR = '[uw-asidenav]';
var FAVORITES_BTN_CLASS = 'navbar__list-item--favorite'; var FAVORITES_BTN_CLASS = 'navbar__list-item--favorite';
var FAVORITES_BTN_ACTIVE_CLASS = 'navbar__list-item--active'; var FAVORITES_BTN_ACTIVE_CLASS = 'navbar__list-item--active';
var ASIDENAV_INITIALIZED_CLASS = 'asidenav--initialized';
var ASIDENAV_EXPANDED_CLASS = 'main__aside--expanded'; var ASIDENAV_EXPANDED_CLASS = 'main__aside--expanded';
var ASIDENAV_LIST_ITEM_CLASS = 'asidenav__list-item'; var ASIDENAV_LIST_ITEM_CLASS = 'asidenav__list-item';
var ASIDENAV_SUBMENU_CLASS = 'asidenav__nested-list-wrapper'; var ASIDENAV_SUBMENU_CLASS = 'asidenav__nested-list-wrapper';
window.utils.aside = function(asideEl) { var asidenavUtil = function(element) {
if (!asideEl) { function init() {
throw new Error('asideEl not defined'); if (!element) {
throw new Error('Asidenav utility cannot be setup without an element!');
}
if (element.classList.contains(ASIDENAV_INITIALIZED_CLASS)) {
return false;
}
initFavoritesButton();
initAsidenavSubmenus();
// mark initialized
element.classList.add(ASIDENAV_INITIALIZED_CLASS);
return {
name: ASIDENAV_UTIL_NAME,
element: element,
destroy: function() {},
};
} }
function initFavoritesButton() { function initFavoritesButton() {
var favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS); var favoritesBtn = document.querySelector('.' + FAVORITES_BTN_CLASS);
favoritesBtn.addEventListener('click', function(event) { favoritesBtn.addEventListener('click', function(event) {
favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS); favoritesBtn.classList.toggle(FAVORITES_BTN_ACTIVE_CLASS);
asideEl.classList.toggle(ASIDENAV_EXPANDED_CLASS); element.classList.toggle(ASIDENAV_EXPANDED_CLASS);
event.preventDefault(); event.preventDefault();
}, true); }, true);
} }
function initAsidenavSubmenus() { function initAsidenavSubmenus() {
var asidenavLinksWithSubmenus = Array.from(asideEl.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS)) var asidenavLinksWithSubmenus = Array.from(element.querySelectorAll('.' + ASIDENAV_LIST_ITEM_CLASS))
.map(function(listItem) { .map(function(listItem) {
var submenu = listItem.querySelector('.' + ASIDENAV_SUBMENU_CLASS); var submenu = listItem.querySelector('.' + ASIDENAV_SUBMENU_CLASS);
return { listItem, submenu }; return { listItem, submenu };
@ -53,12 +96,14 @@
}; };
} }
initFavoritesButton(); return init();
initAsidenavSubmenus();
return {
scope: asideEl,
destroy: function() {},
};
}; };
if (UtilRegistry) {
UtilRegistry.register({
name: ASIDENAV_UTIL_NAME,
selector: ASIDENAV_UTIL_SELECTOR,
setup: asidenavUtil,
});
}
})(); })();

View File

@ -1,33 +1,70 @@
(function collonadeClosure() { (function collonadeClosure() {
'use strict'; 'use strict';
window.utils = window.utils || {}; /**
*
* Async Form Utility
* prevents form submissions from reloading the page but instead firing an AJAX request
*
* Attribute: uw-async-form
* (works only on <form> elements)
*
* Example usage:
* <form uw-async-form method='POST' action='...'>
* ...
*
* Internationalization:
* This utility expects the following translations to be available:
* asyncFormFailure: text that gets shown if an async form request fails
* example: "Oops. Something went wrong."
*/
var ASYNC_FORM_RESPONSE_CLASS = 'async-form-response'; var ASYNC_FORM_UTIL_NAME = 'asyncForm';
var ASYNC_FORM_LOADING_CLASS = 'async-form-loading'; var ASYNC_FORM_UTIL_SELECTOR = 'form[uw-async-form]';
var ASYNC_FORM_INITIALIZED_CLASS = 'check-all--initialized';
var ASYNC_FORM_RESPONSE_CLASS = 'async-form__response';
var ASYNC_FORM_LOADING_CLASS = 'async-form--loading';
var ASYNC_FORM_MIN_DELAY = 600; 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) { var MODAL_SELECTOR = '.modal';
var MODAL_HEADER_KEY = 'Is-Modal';
var MODAL_HEADER_VALUE = 'True';
options = options || {}; var asyncFormUtil = function(element) {
var lastRequestTimestamp = 0; var lastRequestTimestamp = 0;
function setup() { function init() {
formElement.addEventListener('submit', submitHandler); if (!element) {
throw new Error('Async Form Utility cannot be setup without an element!');
}
if (element.classList.contains(ASYNC_FORM_INITIALIZED_CLASS)) {
return false;
}
element.addEventListener('submit', submitHandler);
element.classList.add(ASYNC_FORM_INITIALIZED_CLASS);
return {
name: ASYNC_FORM_UTIL_NAME,
element: element,
destroy: function() {},
};
} }
function processResponse(response) { function processResponse(response) {
var responseElement = makeResponseElement(response.content, response.status); var responseElement = makeResponseElement(response.content, response.status);
var parentElement = formElement.parentElement; var parentElement = element.parentElement;
// make sure there is a delay between click and response // make sure there is a delay between click and response
var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now()); var delay = Math.max(0, ASYNC_FORM_MIN_DELAY + lastRequestTimestamp - Date.now());
setTimeout(function() { setTimeout(function() {
parentElement.insertBefore(responseElement, formElement); parentElement.insertBefore(responseElement, element);
formElement.remove(); element.remove();
}, delay); }, delay);
} }
@ -43,20 +80,23 @@
function submitHandler(event) { function submitHandler(event) {
event.preventDefault(); event.preventDefault();
formElement.classList.add(ASYNC_FORM_LOADING_CLASS) if (!HttpClient) {
lastRequestTimestamp = Date.now(); throw new Error('HttpClient not found! Can\'t fetch submit form asynchronously!');
var url = formElement.getAttribute('action');
var headers = { };
var body = new FormData(formElement);
if (options && options.headers) {
Object.keys(options.headers).forEach(function(headerKey) {
headers[headerKey] = options.headers[headerKey];
});
} }
window.utils.httpClient.post(url, headers, body) element.classList.add(ASYNC_FORM_LOADING_CLASS)
lastRequestTimestamp = Date.now();
var url = element.getAttribute('action');
var headers = { };
var body = new FormData(element);
var isModal = element.closest(MODAL_SELECTOR);
if (!!isModal) {
headers[MODAL_HEADER_KEY] = MODAL_HEADER_VALUE;
}
HttpClient.post(url, headers, body)
.then(function(response) { .then(function(response) {
if (response.headers.get("content-type").indexOf("application/json") !== -1) {// checking response header if (response.headers.get("content-type").indexOf("application/json") !== -1) {// checking response header
return response.json(); return response.json();
@ -66,21 +106,21 @@
}).then(function(response) { }).then(function(response) {
processResponse(response[0]); processResponse(response[0]);
}).catch(function(error) { }).catch(function(error) {
var failureMessage = DEFAULT_FAILURE_MESSAGE; var failureMessage = I18n.get('asyncFormFailure');
if (options.i18n && options.i18n.asyncFormFailure) {
failureMessage = options.i18n.asyncFormFailure;
}
processResponse({ content: failureMessage }); processResponse({ content: failureMessage });
formElement.classList.remove(ASYNC_FORM_LOADING_CLASS); element.classList.remove(ASYNC_FORM_LOADING_CLASS);
}); });
} }
setup(); return init();
return {
scope: formElement,
destroy: function() {},
};
}; };
if (UtilRegistry) {
UtilRegistry.register({
name: ASYNC_FORM_UTIL_NAME,
selector: ASYNC_FORM_UTIL_SELECTOR,
setup: asyncFormUtil
});
}
})(); })();

View File

@ -1,112 +1,272 @@
(function collonadeClosure() { (function collonadeClosure() {
'use strict'; 'use strict';
window.utils = window.utils || {}; /**
*
* Async Table Utility
* makes table filters, sorting and pagination behave asynchronously via AJAX calls
*
* Attribute: uw-async-table
*
* Example usage:
* (regular table)
*/
var INPUT_DEBOUNCE = 600;
var HEADER_HEIGHT = 80; var HEADER_HEIGHT = 80;
var RESET_OPTIONS = [ 'scrollTo' ];
var TABLE_FILTER_FORM_CLASS = 'table-filter-form'; var ASYNC_TABLE_UTIL_NAME = 'asyncTable';
var ASYNC_TABLE_CONTENT_CHANGED_CLASS = 'async-table--changed'; var ASYNC_TABLE_UTIL_SELECTOR = '[uw-async-table]';
var ASYNC_TABLE_LOCAL_STORAGE_KEY = 'ASYNC_TABLE';
var ASYNC_TABLE_SCROLLTABLE_SELECTOR = '.scrolltable';
var ASYNC_TABLE_INITIALIZED_CLASS = 'async-table--initialized';
var ASYNC_TABLE_LOADING_CLASS = 'async-table--loading'; var ASYNC_TABLE_LOADING_CLASS = 'async-table--loading';
var JS_INITIALIZED_CLASS = 'js-async-table-initialized';
window.utils.asyncTable = function(wrapper, options) { var ASYNC_TABLE_FILTER_FORM_SELECTOR = '.table-filter-form';
var ASYNC_TABLE_FILTER_FORM_ID_SELECTOR = '[name="form-identifier"]';
options = options || {};
var tableIdent = options.dbtIdent; var asyncTableUtil = function(element) {
var shortCircuitHeader = options ? options.headerDBTableShortcircuit : null; var asyncTableHeader;
var asyncTableId;
var ths = []; var ths = [];
var pageLinks = []; var pageLinks = [];
var pagesizeForm; var pagesizeForm;
var scrollTable; var scrollTable;
var utilInstances = []; var tableFilterInputs = {
search: [],
input: [],
change: [],
select: [],
}
function init() { function init() {
var table = wrapper.querySelector('#' + tableIdent); if (!element) {
throw new Error('Async Table utility cannot be setup without an element!');
if (!table) {
return;
} }
scrollTable = wrapper.querySelector('.scrolltable'); // param asyncTableDbHeader
if (element.dataset.asyncTableDbHeader !== undefined) {
asyncTableHeader = element.dataset.asyncTableDbHeader;
}
// sortable table headers asyncTableId = element.querySelector('table').id;
ths = Array.from(table.querySelectorAll('th.sortable')).map(function(th) {
// find scrolltable wrapper
scrollTable = element.querySelector(ASYNC_TABLE_SCROLLTABLE_SELECTOR);
if (!scrollTable) {
throw new Error('Async Table cannot be set up without a scrolltable element!');
}
setupSortableHeaders();
setupPagination();
setupPageSizeSelect();
setupTableFilter();
processLocalStorage();
// clear currentTableUrl from previous requests
setLocalStorageParameter('currentTableUrl', null);
// mark initialized
element.classList.add(ASYNC_TABLE_INITIALIZED_CLASS);
return {
name: ASYNC_TABLE_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function setupSortableHeaders() {
ths = Array.from(scrollTable.querySelectorAll('th.sortable')).map(function(th) {
return { element: th }; return { element: th };
}); });
// pagination links ths.forEach(function(th) {
var pagination = wrapper.querySelector('#' + tableIdent + '-pagination'); th.clickHandler = function(event) {
setLocalStorageParameter('horizPos', (scrollTable || {}).scrollLeft);
linkClickHandler(event);
};
th.element.addEventListener('click', th.clickHandler);
});
}
function setupPagination() {
var pagination = element.querySelector('#' + asyncTableId + '-pagination');
if (pagination) { if (pagination) {
pageLinks = Array.from(pagination.querySelectorAll('.page-link')).map(function(link) { pageLinks = Array.from(pagination.querySelectorAll('.page-link')).map(function(link) {
return { element: link }; return { element: link };
}); });
pageLinks.forEach(function(link) {
link.clickHandler = function(event) {
var tableBoundingRect = scrollTable.getBoundingClientRect();
if (tableBoundingRect.top < HEADER_HEIGHT) {
var scrollTo = {
top: (scrollTable.offsetTop || 0) - HEADER_HEIGHT,
left: scrollTable.offsetLeft || 0,
behavior: 'smooth',
};
setLocalStorageParameter('scrollTo', scrollTo);
}
linkClickHandler(event);
}
link.element.addEventListener('click', link.clickHandler);
});
} }
// 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.scrollTo) {
window.scrollTo(options.scrollTo);
}
if (options.horizPos && scrollTable) {
scrollTable.scrollLeft = options.horizPos;
}
setupListeners();
wrapper.classList.add(JS_INITIALIZED_CLASS);
} }
function setupListeners() { function setupPageSizeSelect() {
ths.forEach(function(th) { // pagesize form
th.clickHandler = function(event) { pagesizeForm = element.querySelector('#' + asyncTableId + '-pagesize-form');
var boundClickHandler = clickHandler.bind(this);
var horizPos = (scrollTable || {}).scrollLeft;
boundClickHandler(event, { horizPos });
};
th.element.addEventListener('click', th.clickHandler);
});
pageLinks.forEach(function(link) {
link.clickHandler = function(event) {
var boundClickHandler = clickHandler.bind(this);
var tableBoundingRect = scrollTable.getBoundingClientRect();
var tableOptions = {};
if (tableBoundingRect.top < HEADER_HEIGHT) {
tableOptions.scrollTo = {
top: (scrollTable.offsetTop || 0) - HEADER_HEIGHT,
left: scrollTable.offsetLeft || 0,
behavior: 'smooth',
};
}
boundClickHandler(event, tableOptions);
}
link.element.addEventListener('click', link.clickHandler);
});
if (pagesizeForm) { if (pagesizeForm) {
var pagesizeSelect = pagesizeForm.querySelector('[name=' + tableIdent + '-pagesize]'); var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]');
pagesizeSelect.addEventListener('change', changePagesizeHandler); pagesizeSelect.addEventListener('change', changePagesizeHandler);
} }
} }
function setupTableFilter() {
var tableFilterForm = element.querySelector(ASYNC_TABLE_FILTER_FORM_SELECTOR);
if (tableFilterForm) {
gatherTableFilterInputs(tableFilterForm);
addTableFilterEventListeners();
}
}
function gatherTableFilterInputs(tableFilterForm) {
Array.from(tableFilterForm.querySelectorAll('input[type="search"]')).forEach(function(input) {
tableFilterInputs.search.push(input);
});
Array.from(tableFilterForm.querySelectorAll('input[type="text"]')).forEach(function(input) {
tableFilterInputs.input.push(input);
});
Array.from(tableFilterForm.querySelectorAll('input:not([type="text"]):not([type="search"])')).forEach(function(input) {
tableFilterInputs.change.push(input);
});
Array.from(tableFilterForm.querySelectorAll('select')).forEach(function(input) {
tableFilterInputs.select.push(input);
});
}
function addTableFilterEventListeners() {
tableFilterInputs.search.forEach(function(input) {
var debouncedInput = debounce(function() {
if (input.value.length === 0 || input.value.length > 2) {
updateFromTableFilter();
}
}, INPUT_DEBOUNCE);
input.addEventListener('input', debouncedInput);
});
tableFilterInputs.input.forEach(function(input) {
var debouncedInput = debounce(function() {
if (input.value.length === 0 || input.value.length > 2) {
updateFromTableFilter();
}
}, INPUT_DEBOUNCE);
input.addEventListener('input', debouncedInput);
});
tableFilterInputs.change.forEach(function(input) {
input.addEventListener('change', function() {
updateFromTableFilter();
});
});
tableFilterInputs.select.forEach(function(input) {
input.addEventListener('change', function() {
updateFromTableFilter();
});
});
element.addEventListener('submit', function(event) {
event.preventDefault();
updateFromTableFilter();
});
}
function updateFromTableFilter() {
var url = serializeTableFilterToURL();
var callback = null;
var focusedSearch = tableFilterInputs.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;
}
};
}
updateTableFrom(url, callback);
}
function serializeTableFilterToURL() {
var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
var formIdElement = element.querySelector(ASYNC_TABLE_FILTER_FORM_ID_SELECTOR);
if (!formIdElement) {
// cannot serialize the filter form without an identifier
return;
}
url.searchParams.set('form-identifier', formIdElement.value);
url.searchParams.set('_hasdata', 'true');
url.searchParams.set(asyncTableId + '-page', '0');
tableFilterInputs.search.forEach(function(input) {
url.searchParams.set(input.name, input.value);
});
tableFilterInputs.input.forEach(function(input) {
url.searchParams.set(input.name, input.value);
});
tableFilterInputs.change.forEach(function(input) {
if (input.checked) {
url.searchParams.set(input.name, input.value);
}
});
tableFilterInputs.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;
}
function processLocalStorage() {
var scrollTo = getLocalStorageParameter('scrollTo');
if (scrollTo && scrollTable) {
window.scrollTo(scrollTo);
}
setLocalStorageParameter('scrollTo', null);
var horizPos = getLocalStorageParameter('horizPos');
if (horizPos && scrollTable) {
scrollTable.scrollLeft = horizPos;
}
setLocalStorageParameter('horizPos', null);
}
function removeListeners() { function removeListeners() {
ths.forEach(function(th) { ths.forEach(function(th) {
th.element.removeEventListener('click', th.clickHandler); th.element.removeEventListener('click', th.clickHandler);
@ -117,121 +277,124 @@
}); });
if (pagesizeForm) { if (pagesizeForm) {
var pagesizeSelect = pagesizeForm.querySelector('[name=' + tableIdent + '-pagesize]') var pagesizeSelect = pagesizeForm.querySelector('[name=' + asyncTableId + '-pagesize]')
pagesizeSelect.removeEventListener('change', changePagesizeHandler); pagesizeSelect.removeEventListener('change', changePagesizeHandler);
} }
} }
function clickHandler(event, tableOptions) { function linkClickHandler(event) {
event.preventDefault(); event.preventDefault();
var url = getClickDestination(this); var url = getClickDestination(event.target);
if (!url.match(/^http/)) { if (!url.match(/^http/)) {
url = new URL(window.location.origin + window.location.pathname + getClickDestination(this)); url = window.location.origin + window.location.pathname + url;
} }
updateTableFrom(url, tableOptions);
}
function getClickDestination(el) {
if (!el.querySelector('a')) {
return '';
}
return el.querySelector('a').getAttribute('href');
}
function changePagesizeHandler(event) {
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); updateTableFrom(url);
} }
// fetches new sorted table from url with params and replaces contents of current table function getClickDestination(el) {
function updateTableFrom(url, tableOptions, callback) { if (!el.matches('a') && !el.querySelector('a')) {
if (!window.utils.httpClient) { return '';
throw new Error('httpClient not found!'); }
return el.getAttribute('href') || el.querySelector('a').getAttribute('href');
}
function changePagesizeHandler(event) {
var pagesizeParamKey = asyncTableId + '-pagesize';
var pageParamKey = asyncTableId + '-page';
var url = new URL(getLocalStorageParameter('currentTableUrl') || window.location.href);
url.searchParams.set(pagesizeParamKey, event.target.value);
url.searchParams.set(pageParamKey, 0);
updateTableFrom(url.href);
}
// fetches new sorted element from url with params and replaces contents of current element
function updateTableFrom(url, callback) {
if (!HttpClient) {
throw new Error('HttpClient not found!');
} }
wrapper.classList.add(ASYNC_TABLE_LOADING_CLASS); element.classList.add(ASYNC_TABLE_LOADING_CLASS);
tableOptions = tableOptions || {};
var headers = { var headers = {
'Accept': 'text/html', 'Accept': 'text/html',
[shortCircuitHeader]: tableIdent [asyncTableHeader]: asyncTableId
}; };
window.utils.httpClient.get(url, headers).then(function(response) {
HttpClient.get(url, headers).then(function(response) {
if (!response.ok) { if (!response.ok) {
throw new Error('Looks like there was a problem fetching ' + url.href + '. Status Code: ' + response.status); throw new Error('Looks like there was a problem fetching ' + url.href + '. Status Code: ' + response.status);
} }
return response.text(); return response.text();
}).then(function(data) { }).then(function(data) {
tableOptions.currentUrl = url.href; setLocalStorageParameter('currentTableUrl', url.href);
// reset table
removeListeners(); removeListeners();
updateWrapperContents(data, tableOptions); element.classList.remove(ASYNC_TABLE_INITIALIZED_CLASS);
// update table with new
updateWrapperContents(data);
if (callback && typeof callback === 'function') { if (callback && typeof callback === 'function') {
callback(wrapper); callback(element);
} }
wrapper.classList.remove(ASYNC_TABLE_LOADING_CLASS); element.classList.remove(ASYNC_TABLE_LOADING_CLASS);
}).catch(function(err) { }).catch(function(err) {
console.error(err); console.error(err);
element.classList.remove(ASYNC_TABLE_LOADING_CLASS);
}); });
} }
function updateWrapperContents(newHtml, tableOptions) { function updateWrapperContents(newHtml) {
tableOptions = tableOptions || {}; var newPage = document.createElement('div');
wrapper.innerHTML = newHtml; newPage.innerHTML = newHtml;
wrapper.classList.remove(JS_INITIALIZED_CLASS); var newWrapperContents = newPage.querySelector('#' + element.id);
wrapper.classList.add(ASYNC_TABLE_CONTENT_CHANGED_CLASS); element.innerHTML = newWrapperContents.innerHTML;
destroyUtils(); if (UtilRegistry) {
UtilRegistry.setupAll(element);
// merge global options and table specific options }
var resetOptions = {};
Object.keys(options)
.filter(function(key) {
return !RESET_OPTIONS.includes(key);
})
.forEach(function(key) {
resetOptions[key] = options[key];
});
var combinedOptions = {};
combinedOptions = Object.keys(tableOptions)
.filter(function(key) {
return tableOptions.hasOwnProperty(key);
})
.map(function(key) {
return { key, value: tableOptions[key] }
})
.reduce(function(cumulatedOpts, opt) {
cumulatedOpts[opt.key] = opt.value;
return cumulatedOpts;
}, resetOptions);
window.utils.setup('asyncTable', wrapper, combinedOptions);
Array.from(wrapper.querySelectorAll('form')).forEach(function(form) {
utilInstances.push(window.utils.setup('form', form));
});
Array.from(wrapper.querySelectorAll('.modal')).forEach(function(modal) {
utilInstances.push(window.utils.setup('modal', modal));
});
} }
function destroyUtils() { return init();
utilInstances.filter(function(utilInstance) {
return !!utilInstance;
}).forEach(function(utilInstance) {
utilInstance.destroy();
});
}
init();
return {
scope: wrapper,
destroy: destroyUtils,
};
}; };
function setLocalStorageParameter(key, value) {
var currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
if (value !== null) {
currentLSState[key] = value;
} else {
delete currentLSState[key];
}
window.localStorage.setItem(ASYNC_TABLE_LOCAL_STORAGE_KEY, JSON.stringify(currentLSState));
}
function getLocalStorageParameter(key) {
var currentLSState = JSON.parse(window.localStorage.getItem(ASYNC_TABLE_LOCAL_STORAGE_KEY)) || {};
return currentLSState[key];
}
// 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);
};
}
// register async table utility
if (UtilRegistry) {
UtilRegistry.register({
name: ASYNC_TABLE_UTIL_NAME,
selector: ASYNC_TABLE_UTIL_SELECTOR,
setup: asyncTableUtil,
});
}
})(); })();

View File

@ -1,171 +0,0 @@
(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;
}
var formIdElement = formElement.querySelector('[name="form-identifier"]');
if (!formIdElement) {
return;
}
options = options || {};
var tableIdent = options.dbtIdent;
var formId = formIdElement.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('form-identifier', 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() {},
};
}
})();

View File

@ -1,40 +1,54 @@
(function() { (function() {
'use strict'; 'use strict';
window.utils = window.utils || {}; /**
*
* Check All Checkbox Utility
* adds a Check All Checkbox above columns with only checkboxes
*
* Attribute: [none]
* (will be set up automatically on tables)
*
* Example usage:
* (table with one column thats only checkboxes)
*/
var CHECK_ALL_UTIL_NAME = 'checkAll';
var CHECK_ALL_UTIL_SELECTOR = 'table';
var ASYNC_TABLE_CONTENT_CHANGED_CLASS = 'async-table--changed';
var JS_INITIALIZED_CLASS = 'js-check-all-initialized';
var CHECKBOX_SELECTOR = '[type="checkbox"]'; var CHECKBOX_SELECTOR = '[type="checkbox"]';
function getCheckboxId() { var CHECK_ALL_INITIALIZED_CLASS = 'check-all--initialized';
return 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
}
window.utils.checkAll = function(wrapper, options) {
if ((!wrapper || wrapper.classList.contains(JS_INITIALIZED_CLASS)) && !wrapper.classList.contains(ASYNC_TABLE_CONTENT_CHANGED_CLASS)) {
return false;
}
options = options || {};
var checkAllUtil = function(element) {
var columns = []; var columns = [];
var checkboxColumn = []; var checkboxColumn = [];
var checkAllCheckbox = null; var checkAllCheckbox = null;
var utilInstances = [];
function init() { function init() {
if (!element) {
throw new Error('Check All utility cannot be setup without an element!');
}
columns = gatherColumns(wrapper); gatherColumns();
setupCheckAllCheckbox();
setupCheckAllCheckbox(findCheckboxColumn(columns)); // mark initialized
element.classList.add(CHECK_ALL_INITIALIZED_CLASS);
wrapper.classList.add(JS_INITIALIZED_CLASS); return {
name: CHECK_ALL_UTIL_NAME,
element: element,
destroy: function() {},
};
} }
function gatherColumns(table) { function getCheckboxId() {
var rows = Array.from(table.querySelectorAll('tr')); return 'check-all-checkbox-' + Math.floor(Math.random() * 100000);
}
function gatherColumns(tble) {
var rows = Array.from(element.querySelectorAll('tr'));
var cols = []; var cols = [];
rows.forEach(function(tr) { rows.forEach(function(tr) {
var cells = Array.from(tr.querySelectorAll('td')); var cells = Array.from(tr.querySelectorAll('td'));
@ -45,7 +59,7 @@
cols[cellIndex].push(cell); cols[cellIndex].push(cell);
}); });
}); });
return cols; columns = cols;
} }
function findCheckboxColumn(columns) { function findCheckboxColumn(columns) {
@ -68,21 +82,26 @@
return onlyCheckboxes; return onlyCheckboxes;
} }
function setupCheckAllCheckbox(columnId) { function setupCheckAllCheckbox() {
if (columnId === null) { var checkboxColumnId = findCheckboxColumn(columns);
if (checkboxColumnId === null) {
return; return;
} }
checkboxColumn = columns[columnId]; checkboxColumn = columns[checkboxColumnId];
var firstRow = wrapper.querySelector('tr'); var firstRow = element.querySelector('tr');
var th = Array.from(firstRow.querySelectorAll('th, td'))[columnId]; var th = Array.from(firstRow.querySelectorAll('th, td'))[checkboxColumnId];
th.innerHTML = 'test'; th.innerHTML = 'test';
checkAllCheckbox = document.createElement('input'); checkAllCheckbox = document.createElement('input');
checkAllCheckbox.setAttribute('type', 'checkbox'); checkAllCheckbox.setAttribute('type', 'checkbox');
checkAllCheckbox.setAttribute('id', getCheckboxId()); checkAllCheckbox.setAttribute('id', getCheckboxId());
th.innerHTML = ''; th.innerHTML = '';
th.insertBefore(checkAllCheckbox, null); th.insertBefore(checkAllCheckbox, null);
utilInstances.push(window.utils.setup('checkbox', checkAllCheckbox));
// manually set up newly created checkbox
if (UtilRegistry) {
UtilRegistry.setup(UtilRegistry.find('checkbox'));
}
checkAllCheckbox.addEventListener('input', onCheckAllCheckboxInput); checkAllCheckbox.addEventListener('input', onCheckAllCheckboxInput);
setupCheckboxListeners(); setupCheckboxListeners();
@ -103,9 +122,9 @@
} }
function updateCheckAllCheckboxState() { function updateCheckAllCheckboxState() {
var allChecked = checkboxColumn.reduce(function(acc, cell) { var allChecked = checkboxColumn.every(function(cell) {
return acc && cell.querySelector(CHECKBOX_SELECTOR).checked; return cell.querySelector(CHECKBOX_SELECTOR).checked;
}, true); })
checkAllCheckbox.checked = allChecked; checkAllCheckbox.checked = allChecked;
} }
@ -115,17 +134,15 @@
}); });
} }
function destroy() { return init();
utilInstances.forEach(function(util) {
util.destroy();
});
}
init();
return {
scope: wrapper,
destroy: destroy,
};
}; };
// register check all checkbox util
if (UtilRegistry) {
UtilRegistry.register({
name: CHECK_ALL_UTIL_NAME,
selector: CHECK_ALL_UTIL_SELECTOR,
setup: checkAllUtil
});
}
})(); })();

View File

@ -1,202 +1,315 @@
(function() { (function() {
'use strict'; 'use strict';
window.utils = window.utils || {}; var formUtilities = [];
var JS_INITIALIZED = 'js-form-initialized'; /**
var SUBMIT_BUTTON_SELECTOR = '[type="submit"]:not([formnovalidate])'; *
var AUTOSUBMIT_BUTTON_SELECTOR = '[type="submit"][data-autosubmit]'; * Reactive Submit Button Utility
var AJAX_SUBMIT_FLAG = 'ajaxSubmit'; * disables a forms LAST sumit button as long as the required inputs are invalid
* (only checks if the value of the inputs are not empty)
*
* Attribute: [none]
* (automatically setup on all form tags)
*
* Params:
* data-formnorequired: string
* If present the submit button will never get disabled
*
* Example usage:
* <form uw-reactive-submit-button>
* <input type="text" required>
* <button type="submit">
* </form>
*/
var FORM_GROUP_CLASS = 'form-group'; var REACTIVE_SUBMIT_BUTTON_UTIL_NAME = 'reactiveSubmitButton';
var FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error'; var REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR = 'form';
function formValidator(inputs) { var REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS = 'reactive-submit-button--initialized';
var done = true;
inputs.forEach(function(inp) { var reactiveSubmitButtonUtil = function(element) {
var len = inp.value.trim().length; var requiredInputs;
if (done && len === 0) { var submitButton;
done = false;
function init() {
if (!element) {
throw new Error('Reactive Submit Button utility cannot be setup without an element!');
} }
});
return done;
}
window.utils.form = function(form, options) { if (element.classList.contains(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS)) {
options = options || {}; return false;
}
// dont initialize form if it is in a modal and is not forced // abort if form has param data-formnorequired
if (form.closest('.modal') && !options.force) { if (element.dataset.formnorequired !== undefined) {
return false; throw new Error('Form has formnorequired data attribute. Will skip setup of reactive submit button.');
}
requiredInputs = Array.from(element.querySelectorAll('[required]'));
if (!requiredInputs) {
// abort if form has no required inputs
throw new Error('Submit button has formnorequired data attribute. Will skip setup of reactive submit button.');
}
var submitButtons = Array.from(element.querySelectorAll('[type="submit"]'));
if (!submitButtons || !submitButtons.length) {
throw new Error('Reactive Submit Button utility couldn\'t find any submit buttons!');
}
submitButton = submitButtons.reverse()[0];
// abort if form has param data-formnorequired
if (submitButton.dataset.formnorequired !== undefined) {
return false;
}
setupInputs();
updateButtonState();
element.classList.add(REACTIVE_SUBMIT_BUTTON_INITIALIZED_CLASS);
return {
name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
element: element,
destroy: function() {},
};
} }
// dont initialize form if already initialized and should not be force-initialized function setupInputs() {
if (form.classList.contains(JS_INITIALIZED) && !options.force) { requiredInputs.forEach(function(el) {
return false; var checkbox = el.getAttribute('type') === 'checkbox';
} var eventType = checkbox ? 'change' : 'input';
el.addEventListener(eventType, function() {
var utilInstances = []; updateButtonState();
});
// reactive buttons
utilInstances.push(window.utils.setup('reactiveButton', form));
// conditonal fieldsets
var fieldSets = Array.from(form.querySelectorAll('fieldset[data-conditional-id][data-conditional-value]'));
utilInstances.push(window.utils.setup('interactiveFieldset', form, { fieldSets }));
// hide autoSubmit submit button
utilInstances.push(window.utils.setup('autoSubmit', form, options));
// async form
if (AJAX_SUBMIT_FLAG in form.dataset) {
utilInstances.push(window.utils.setup('asyncForm', form, options));
}
// inputs
utilInstances.push(window.utils.setup('inputs', form, options));
// form group errors
var formGroups = Array.from(form.querySelectorAll('.' + FORM_GROUP_CLASS));
formGroups.forEach(function(formGroup) {
utilInstances.push(window.utils.setup('errorRemover', formGroup, options));
});
form.classList.add(JS_INITIALIZED);
function destroyUtils() {
utilInstances.filter(function(utilInstance) {
return !!utilInstance;
}).forEach(function(utilInstance) {
utilInstance.destroy();
}); });
} }
return {
scope: form,
destroy: destroyUtils,
};
};
// registers input-listener for each element in <inputs> (array) and
// enables <button> if <formValidator> for these inputs returns true
window.utils.reactiveButton = function(form, options) {
var button = form.querySelector(SUBMIT_BUTTON_SELECTOR);
var requireds = Array.from(form.querySelectorAll('[required]'));
if (!button || button.matches(AUTOSUBMIT_BUTTON_SELECTOR)) {
return false;
}
if (requireds.length == 0) {
return false;
}
if (typeof button.dataset.formnorequired !== 'undefined' && button.dataset.formnorequired !== null) {
button.addEventListener('click', function() {
form.submit();
});
return false;
}
updateButtonState();
requireds.forEach(function(el) {
var checkbox = el.getAttribute('type') === 'checkbox';
var eventType = checkbox ? 'change' : 'input';
el.addEventListener(eventType, function() {
updateButtonState();
});
});
function updateButtonState() { function updateButtonState() {
if (formValidator(requireds) === true) { if (inputsValid()) {
button.removeAttribute('disabled'); submitButton.removeAttribute('disabled');
} else { } else {
button.setAttribute('disabled', 'true'); submitButton.setAttribute('disabled', 'true');
} }
} }
return { function inputsValid() {
scope: form, var done = true;
destroy: function() {}, requiredInputs.forEach(function(inp) {
}; var len = inp.value.trim().length;
}; if (done && len === 0) {
done = false;
window.utils.interactiveFieldset = function(form, options) { }
options = options || {}; });
var fieldSets = options.fieldSets; return done;
if (!fieldSets) {
throw new Error('interactiveFieldset must be passed fieldSets via options');
} }
var fields = fieldSets.map(function(fs) { return init();
};
formUtilities.push({
name: REACTIVE_SUBMIT_BUTTON_UTIL_NAME,
selector: REACTIVE_SUBMIT_BUTTON_UTIL_SELECTOR,
setup: reactiveSubmitButtonUtil,
});
/**
*
* Interactive Fieldset Utility
* shows/hides inputs based on value of particular input
*
* Attribute: uw-interactive-fieldset
*
* Params:
* data-conditional-input: string
* Selector for the input that this fieldset watches for changes
* data-conditional-value: string
* The value the conditional input needs to be set to for this fieldset to be shown
*
* Example usage:
* <input id="input-0" type="text">
* <fieldset uw-interactive-fieldset data-conditional-input="#input-0" data-conditional-value="yes">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#input-0" data-conditional-value="no">...</fieldset>
* ## example with <select>
* <select id="select-0">
* <option value="0">Zero
* <option value="1">One
* <fieldset uw-interactive-fieldset data-conditional-input="#select-0" data-conditional-value="0">...</fieldset>
* <fieldset uw-interactive-fieldset data-conditional-input="#select-0" data-conditional-value="1">...</fieldset>
*/
var INTERACTIVE_FIELDSET_UTIL_NAME = 'interactiveFieldset';
var INTERACTIVE_FIELDSET_UTIL_SELECTOR = '[uw-interactive-fieldset]';
var INTERACTIVE_FIELDSET_INITIALIZED_CLASS = 'interactive-fieldset--initialized';
var interactiveFieldsetUtil = function(element) {
var conditionalInput;
var conditionalValue;
function init() {
if (!element) {
throw new Error('Interactive Fieldset utility cannot be setup without an element!');
}
if (element.classList.contains(INTERACTIVE_FIELDSET_INITIALIZED_CLASS)) {
return false;
}
// param conditionalInput
if (!element.dataset.conditionalInput) {
throw new Error('Interactive Fieldset needs a selector for a conditional input!');
}
conditionalInput = document.querySelector('#' + element.dataset.conditionalInput);
if (!conditionalInput) {
// abort if form has no required inputs
throw new Error('Couldn\'t find the conditional input. Aborting setup for interactive fieldset.');
}
// param conditionalValue
if (!element.dataset.conditionalValue) {
throw new Error('Interactive Fieldset needs a conditional value!');
}
conditionalValue = element.dataset.conditionalValue;
// add event listener
conditionalInput.addEventListener('input', updateVisibility);
// initial visibility update
updateVisibility();
// mark as initialized
element.classList.add(INTERACTIVE_FIELDSET_INITIALIZED_CLASS);
return { return {
fieldSet: fs, name: INTERACTIVE_FIELDSET_UTIL_NAME,
condId: fs.dataset.conditionalId, element: element,
condValue: fs.dataset.conditionalValue, destroy: function() {},
condEl: form.querySelector('#' + fs.dataset.conditionalId),
}; };
}).filter(function(field) {
return !!field.condEl;
});
function updateFields() {
fields.forEach(function(field) {
field.fieldSet.classList.toggle('hidden', field.condEl.value !== field.condValue);
});
} }
function addEventListeners() { function updateVisibility() {
fields.forEach(function(field) { element.classList.toggle('hidden', conditionalInput.value !== conditionalValue);
field.condEl.addEventListener('input', updateFields)
});
} }
if (fieldSets.length) { return init();
addEventListeners(); };
updateFields();
formUtilities.push({
name: INTERACTIVE_FIELDSET_UTIL_NAME,
selector: INTERACTIVE_FIELDSET_UTIL_SELECTOR,
setup: interactiveFieldsetUtil,
});
/**
*
* Auto Submit Button Utility
* Hides submit buttons in forms that are submitted programmatically
* We hide the button using JavaScript so no-js users will still be able to submit the form
*
* Attribute: uw-auto-submit-button
*
* Example usage:
* <button type="submit" uw-auto-submit-button>Submit
*/
var AUTO_SUBMIT_BUTTON_UTIL_NAME = 'autoSubmitButton';
var AUTO_SUBMIT_BUTTON_UTIL_SELECTOR = '[uw-auto-submit-button]';
var AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS = 'auto-submit-button--initialized';
var AUTO_SUBMIT_BUTTON_HIDDEN_CLASS = 'hidden';
var autoSubmitButtonUtil = function(element) {
if (!element) {
throw new Error('Auto Submit Button utility needs to be passed an element!');
} }
// hide and mark initialized
element.classList.add(AUTO_SUBMIT_BUTTON_HIDDEN_CLASS, AUTO_SUBMIT_BUTTON_INITIALIZED_CLASS);
return { return {
scope: form, name: AUTO_SUBMIT_BUTTON_UTIL_NAME,
element: element,
destroy: function() {}, destroy: function() {},
}; };
}; };
window.utils.autoSubmit = function(form, options) { formUtilities.push({
var button = form.querySelector(AUTOSUBMIT_BUTTON_SELECTOR); name: AUTO_SUBMIT_BUTTON_UTIL_NAME,
if (button) { selector: AUTO_SUBMIT_BUTTON_UTIL_SELECTOR,
button.classList.add('hidden'); setup: autoSubmitButtonUtil,
} });
return { /**
scope: form, *
destroy: function() {}, * Form Error Remover Utility
}; * Removes errors from inputs when they are focused
}; *
* Attribute: [none]
* (automatically setup on all form tags)
*
* Example usage:
* (any regular form that can show input errors)
*/
// listens for focus events and removes any errors on an input var FORM_ERROR_REMOVER_UTIL_NAME = 'formErrorRemover';
window.utils.errorRemover = function(formGroup, options) { var FORM_ERROR_REMOVER_UTIL_SELECTOR = 'form';
var inputElement = formGroup.querySelector('input:not([type="hidden"]), textarea, select'); var FORM_ERROR_REMOVER_INITIALIZED_CLASS = 'form-error-remover--initialized';
if (!inputElement) { var FORM_ERROR_REMOVER_INPUTS_SELECTOR = 'input:not([type="hidden"]), textarea, select';
return false;
}
inputElement.addEventListener('focus', focusListener); var FORM_GROUP_SELECTOR = '.form-group';
var FORM_GROUP_WITH_ERRORS_CLASS = 'form-group--has-error';
function focusListener() { var formErrorRemoverUtil = function(element) {
var hasError = formGroup.classList.contains(FORM_GROUP_WITH_ERRORS_CLASS); var formGroups;
if (hasError) {
formGroup.classList.remove(FORM_GROUP_WITH_ERRORS_CLASS); function init() {
if (!element) {
throw new Error('Form Error Remover utility needs to be passed an element!');
} }
// find form groups
formGroups = Array.from(element.querySelectorAll(FORM_GROUP_SELECTOR));
formGroups.forEach(function(formGroup) {
if (!formGroup.classList.contains(FORM_GROUP_WITH_ERRORS_CLASS)) {
return;
}
var inputElements = Array.from(formGroup.querySelectorAll(FORM_ERROR_REMOVER_INPUTS_SELECTOR));
if (!inputElements) {
return false;
}
inputElements.forEach(function(inputElement) {
inputElement.addEventListener('input', function() {
formGroup.classList.remove(FORM_GROUP_WITH_ERRORS_CLASS);
});
});
});
// mark initialized
element.classList.add(FORM_ERROR_REMOVER_INITIALIZED_CLASS);
return {
name: FORM_ERROR_REMOVER_UTIL_NAME,
element: element,
destroy: function() {},
};
} }
return { return init();
scope: formGroup,
destroy: function() {
inputElement.removeEventListener('focus', focusListener);
},
};
}; };
formUtilities.push({
name: FORM_ERROR_REMOVER_UTIL_NAME,
selector: FORM_ERROR_REMOVER_UTIL_SELECTOR,
setup: formErrorRemoverUtil,
});
// register the collected form utilities
if (UtilRegistry) {
formUtilities.forEach(UtilRegistry.register);
}
})(); })();

View File

@ -1,253 +1,200 @@
(function() { (function() {
'use strict'; 'use strict';
window.utils = window.utils || {}; var inputUtilities = [];
var JS_INITIALIZED_CLASS = 'js-inputs-initialized'; /**
*
* FileInput Utility
* wraps native file input
*
* Attribute: uw-file-input
* (element must be an input of type='file')
*
* Example usage:
* <input type='file' uw-file-input>
*
* Internationalization:
* This utility expects the following translations to be available:
* »filesSelected«: label of multi-input button after selection
* example: "Dateien ausgewählt" (will be prepended by number of selected files)
* »selectFile«: label of single-input button before selection
* example: "Datei auswählen"
* »selectFiles«: label of multi-input button before selection
* example: "Datei(en) auswählen"
*
*/
window.utils.inputs = function(wrapper, options) { var FILE_INPUT_UTIL_NAME = 'fileInput';
options = options || {}; var FILE_INPUT_UTIL_SELECTOR = 'input[type="file"][uw-file-input]';
var utilInstances = [];
if (wrapper.classList.contains(JS_INITIALIZED_CLASS) && !options.force) { var FILE_INPUT_CLASS = 'file-input';
return false; var FILE_INPUT_INITIALIZED_CLASS = 'file-input--initialized';
} var FILE_INPUT_LIST_CLASS = 'file-input__list';
var FILE_INPUT_UNPACK_CHECKBOX_CLASS = 'file-input__unpack';
var FILE_INPUT_LABEL_CLASS = 'file-input__label';
// checkboxes var fileInputUtil = function(element) {
var checkboxes = Array.from(wrapper.querySelectorAll('input[type="checkbox"]')); var isMultiFileInput = false;
checkboxes.forEach(function(checkbox) { var fileList;
utilInstances.push(window.utils.setup('checkbox', checkbox)); var label;
});
// radios function init() {
var radios = Array.from(wrapper.querySelectorAll('input[type="radio"]')); if (!element) {
radios.forEach(function(radio) { throw new Error('FileInput utility cannot be setup without an element!');
utilInstances.push(window.utils.setup('radio', radio)); }
});
// file-uploads if (element.classList.contains(FILE_INPUT_INITIALIZED_CLASS)) {
var fileUploads = Array.from(wrapper.querySelectorAll('input[type="file"]')); throw new Error('FileInput utility already initialized!');
fileUploads.forEach(function(input) { }
utilInstances.push(window.utils.setup('fileUpload', input, options));
});
// file-checkboxes // check if is multi-file input
var fileCheckboxes = Array.from(wrapper.querySelectorAll('.file-checkbox')); isMultiFileInput = element.hasAttribute('multiple');
fileCheckboxes.forEach(function(input) { if (isMultiFileInput) {
utilInstances.push(window.utils.setup('fileCheckbox', input, options)); fileList = createFileList();
}); }
function destroyUtils() { label = createFileLabel();
utilInstances.filter(function(utilInstance) { updateLabel();
return !!utilInstance;
}).forEach(function(utilInstance) { // add change listener
utilInstance.destroy(); element.addEventListener('change', function() {
updateLabel();
renderFileList();
}); });
// add util class for styling and mark as initialized
element.classList.add(FILE_INPUT_CLASS, FILE_INPUT_INITIALIZED_CLASS);
return {
name: FILE_INPUT_UTIL_NAME,
element: element,
destroy: function() {},
};
} }
wrapper.classList.add(JS_INITIALIZED_CLASS); function renderFileList() {
if (!fileList) {
return;
}
return { var files = element.files;
scope: wrapper,
destroy: destroyUtils,
};
};
// (multiple) dynamic file uploads
// expects i18n object with following strings:
// »filesSelected«: label of multi-upload button after selection
// example: "Dateien ausgewählt" (will be prepended by number of selected files)
// »selectFile«: label of single-upload button before selection
// example: "Datei auswählen"
// »selectFiles«: label of multi-upload button before selection
// example: "Datei(en) auswählen"
var FILE_UPLOAD_INPUT_LIST_CLASS = 'file-input__list';
var FILE_UPLOAD_INPUT_UNPACK_CHECKBOX_CLASS = 'file-input__unpack';
var FILE_UPLOAD_INPUT_LABEL_CLASS = 'file-input__label';
var FILE_UPLOAD_INPUT_HIDDEN_CLASS = 'file-input__input--hidden';
window.utils.fileUpload = function(input, options) {
var isMulti = input.hasAttribute('multiple');
var fileList = isMulti ? addFileList() : null;
var label = addFileLabel();
var i18n = options.i18n;
if (!i18n) {
throw new Error('window.utils.fileUpload(input, options) needs to be passed i18n object via options');
}
function renderFileList(files) {
fileList.innerHTML = ''; fileList.innerHTML = '';
Array.from(files).forEach(function(file, index) { Array.from(files).forEach(function(file) {
var fileDisplayEl = document.createElement('li'); var fileDisplayEl = document.createElement('li');
fileDisplayEl.innerHTML = file.name; fileDisplayEl.innerHTML = file.name;
fileList.appendChild(fileDisplayEl); fileList.appendChild(fileDisplayEl);
}); });
} }
function updateLabel(files) { function createFileList() {
if (files.length) {
if (isMulti) {
label.innerText = files.length + ' ' + i18n.filesSelected;
} else {
label.innerHTML = files[0].name;
}
} else {
resetFileLabel();
}
}
function addFileList() {
var list = document.createElement('ol'); var list = document.createElement('ol');
list.classList.add(FILE_UPLOAD_INPUT_LIST_CLASS); list.classList.add(FILE_INPUT_LIST_CLASS);
var unpackEl = input.parentElement.querySelector('.' + FILE_UPLOAD_INPUT_UNPACK_CHECKBOX_CLASS); var unpackEl = element.parentElement.querySelector('.' + FILE_INPUT_UNPACK_CHECKBOX_CLASS);
if (unpackEl) { if (unpackEl) {
input.parentElement.insertBefore(list, unpackEl); element.parentElement.insertBefore(list, unpackEl);
} else { } else {
input.parentElement.appendChild(list); element.parentElement.appendChild(list);
} }
return list; return list;
} }
function addFileLabel() { function createFileLabel() {
var label = document.createElement('label'); var label = document.createElement('label');
label.classList.add(FILE_UPLOAD_INPUT_LABEL_CLASS); label.classList.add(FILE_INPUT_LABEL_CLASS);
label.setAttribute('for', input.id); label.setAttribute('for', element.id);
input.parentElement.insertBefore(label, input); element.parentElement.insertBefore(label, element);
return label; return label;
} }
function resetFileLabel() { function updateLabel() {
if (isMulti) { var files = element.files;
label.innerText = i18n.selectFiles; if (files && files.length) {
label.innerText = isMultiFileInput ? files.length + ' ' + I18n.get('filesSelected') : files[0].name;
} else { } else {
label.innerText = i18n.selectFile; label.innerText = isMultiFileInput ? I18n.get('selectFiles') : I18n.get('selectFile');
} }
} }
// initial setup return init();
resetFileLabel();
input.classList.add(FILE_UPLOAD_INPUT_HIDDEN_CLASS);
input.addEventListener('change', function() {
input.dispatchEvent(new Event('input'));
if (isMulti) {
renderFileList(input.files);
}
updateLabel(input.files);
});
return {
scope: input,
destroy: function() {},
};
} }
// to remove previously uploaded files inputUtilities.push({
name: FILE_INPUT_UTIL_NAME,
selector: FILE_INPUT_UTIL_SELECTOR,
setup: fileInputUtil,
})
var FILE_UPLOAD_CONTAINER_CLASS = 'file-container'; /**
var FILE_UPLOAD_CONTAINER_CHECKED_CLASS = 'file-container--checked'; *
* Checkbox Utility
* wraps native checkbox
*
* Attribute: (none)
* (element must be an input of type="checkbox")
*
* Example usage:
* <input type="checkbox">
*
*/
window.utils.fileCheckbox = function(input) { var CHECKBOX_UTIL_NAME = 'checkbox';
// adds eventlistener(s) var CHECKBOX_UTIL_SELECTOR = 'input[type="checkbox"]';
function addListener(container) {
input.addEventListener('change', function(event) {
container.classList.toggle(FILE_UPLOAD_CONTAINER_CHECKED_CLASS, this.checked);
});
}
// initial setup var CHECKBOX_CLASS = 'checkbox';
function setup() { var CHECKBOX_INITIALIZED_CLASS = 'checkbox--initialized';
var cont = input.parentNode;
while (cont !== document.body) { var checkboxUtil = function(element) {
if (cont.matches('.' + FILE_UPLOAD_CONTAINER_CLASS)) {
break; function init() {
} if (!element) {
cont = cont.parentNode; throw new Error('Checkbox utility cannot be setup without an element!');
} }
addListener(cont);
}
setup(); if (element.classList.contains(CHECKBOX_INITIALIZED_CLASS)) {
// throw new Error('Checkbox utility already initialized!');
return false;
}
return { if (element.parentElement.classList.contains(CHECKBOX_CLASS)) {
scope: input, // throw new Error('Checkbox element\'s wrapper already has class "' + CHECKBOX_CLASS + '"!');
destroy: function() {}, return false;
}; }
}
// turns native checkboxes into custom ones var siblingEl = element.nextElementSibling;
window.utils.checkbox = function(input) { var parentEl = element.parentElement;
if (!input.parentElement.classList.contains('checkbox')) {
var parentEl = input.parentElement;
var siblingEl = input.nextElementSibling;
var wrapperEl = document.createElement('div'); var wrapperEl = document.createElement('div');
wrapperEl.classList.add(CHECKBOX_CLASS);
var labelEl = document.createElement('label'); var labelEl = document.createElement('label');
wrapperEl.classList.add('checkbox'); labelEl.setAttribute('for', element.id);
labelEl.setAttribute('for', input.id);
wrapperEl.appendChild(input); wrapperEl.appendChild(element);
wrapperEl.appendChild(labelEl); wrapperEl.appendChild(labelEl);
if (siblingEl) { parentEl.insertBefore(wrapperEl, siblingEl);
parentEl.insertBefore(wrapperEl, siblingEl);
} else { element.classList.add(CHECKBOX_INITIALIZED_CLASS);
parentEl.appendChild(wrapperEl);
} return {
name: CHECKBOX_UTIL_NAME,
element: element,
destroy: function() {},
};
} }
return { return init();
scope: input,
destroy: function() {},
};
} }
// turns native radio buttons into custom ones inputUtilities.push({
window.utils.radio = function(input) { name: CHECKBOX_UTIL_NAME,
selector: CHECKBOX_UTIL_SELECTOR,
setup: checkboxUtil,
});
if (!input.parentElement.classList.contains('radio')) { // register the collected input utilities
var parentEl = input.parentElement; if (UtilRegistry) {
var siblingEl = input.nextElementSibling; inputUtilities.forEach(UtilRegistry.register);
var wrapperEl = document.createElement('div');
wrapperEl.classList.add('radio');
wrapperEl.appendChild(input);
if (siblingEl && siblingEl.matches('label')) {
wrapperEl.appendChild(siblingEl);
}
parentEl.appendChild(wrapperEl);
}
return {
scope: input,
destroy: function() {},
};
}
// Override implicit submit (pressing enter) behaviour to trigger a specified submit button instead of the default
window.utils.implicitSubmit = function(input, options) {
var submit = options.submit;
if (!submit) {
throw new Error('window.utils.implicitSubmit(input, options) needs to be passed a submit element via options');
}
var doSubmit = function(event) {
if (event.keyCode == 13) {
event.preventDefault();
submit.click();
}
};
input.addEventListener('keypress', doSubmit);
return {
scope: input,
destroy: function() {
input.removeEventListener('keypress', doSubmit);
},
};
} }
})(); })();

View File

@ -1,155 +1,196 @@
(function() { (function() {
'use strict'; 'use strict';
window.utils = window.utils || {}; /**
*
* Modal Utility
*
* Attribute: uw-modal
*
* Params:
* data-modal-trigger: string
* Selector for the element that toggles the modal.
* If trigger element has "href" attribute the modal will be dynamically loaded from the referenced page
* data-modal-closeable: boolean property
* If the param is present the modal will have a close-icon and can also be closed by clicking anywhere on the overlay
*
* Example usage:
* <div uw-modal data-modal-trigger='#trigger' data-modal-closeable>This is the modal content
* <div id='trigger'>Click me to open the modal
*/
var MODAL_UTIL_NAME = 'modal';
var MODAL_UTIL_SELECTOR = '[uw-modal]';
var JS_INITIALIZED_CLASS = 'js-modal-initialized';
var MODAL_OPEN_CLASS = 'modal--open';
var MODAL_TRIGGER_CLASS = 'modal__trigger';
var MODAL_CONTENT_CLASS = 'modal__content';
var MAIN_CONTENT_CLASS = 'main__content-body'
var MODAL_CLOSABLE_FLAG = 'closeable';
var MODAL_DYNAMIC_FLAG = 'dynamic';
var MODAL_HEADERS = { var MODAL_HEADERS = {
'Is-Modal': 'True', 'Is-Modal': 'True',
}; };
var OVERLAY_CLASS = 'modal__overlay';
var OVERLAY_OPEN_CLASS = 'modal__overlay--open';
var CLOSER_CLASS = 'modal__closer';
window.utils.modal = function(modalElement, options) { var MODAL_INITIALIZED_CLASS = 'modal--initialized';
var MODAL_CLASS = 'modal';
var MODAL_OPEN_CLASS = 'modal--open';
var MODAL_TRIGGER_CLASS = 'modal__trigger';
var MODAL_CONTENT_CLASS = 'modal__content';
var MODAL_OVERLAY_CLASS = 'modal__overlay';
var MODAL_OVERLAY_OPEN_CLASS = 'modal__overlay--open';
var MODAL_CLOSER_CLASS = 'modal__closer';
if (!modalElement || modalElement.classList.contains(JS_INITIALIZED_CLASS)) { var MAIN_CONTENT_CLASS = 'main__content-body'
return;
}
var utilInstances = []; var modalUtil = function(element) {
var overlayElement = document.createElement('div'); var overlayElement = document.createElement('div');
var closerElement = document.createElement('div'); var modalUrl;
var triggerElement = document.querySelector('#' + modalElement.dataset.trigger);
function setup() { function _init() {
document.body.insertBefore(modalElement, null); if (!element) {
throw new Error('Modal utility cannot be setup without an element!');
}
setupForm(); if (element.classList.contains(MODAL_INITIALIZED_CLASS)) {
setupCloser(); return false;
}
triggerElement.classList.add(MODAL_TRIGGER_CLASS); // param modalTrigger
triggerElement.addEventListener('click', openHandler, false); if (!element.dataset.modalTrigger) {
throw new Error('Modal utility cannot be setup without a trigger element!');
} else {
setupTrigger();
}
modalElement.classList.add(JS_INITIALIZED_CLASS); // param modalCloseable
if (element.dataset.modalCloseable !== undefined) {
setupCloser();
}
// setupForm();
// mark as initialized and add modal class for styling
element.classList.add(MODAL_INITIALIZED_CLASS, MODAL_CLASS);
return {
name: MODAL_UTIL_NAME,
element: element,
destroy: function() {}
};
} }
function openHandler(event) { function setupTrigger() {
var triggerSelector = element.dataset.modalTrigger;
if (!triggerSelector.startsWith('#')) {
triggerSelector = '#' + triggerSelector;
}
var triggerElement = document.querySelector(triggerSelector);
if (!triggerElement) {
throw new Error('Trigger element for Modal not found: "' + triggerSelector + '"');
}
triggerElement.classList.add(MODAL_TRIGGER_CLASS);
triggerElement.addEventListener('click', onTriggerClicked, false);
modalUrl = triggerElement.getAttribute('href');
}
function setupCloser() {
var closerElement = document.createElement('div');
element.insertBefore(closerElement, null);
closerElement.classList.add(MODAL_CLOSER_CLASS);
closerElement.addEventListener('click', onCloseClicked, false);
overlayElement.addEventListener('click', onCloseClicked, false);
}
function onTriggerClicked(event) {
event.preventDefault(); event.preventDefault();
open(); open();
} }
function open() { function onCloseClicked(event) {
modalElement.classList.add(MODAL_OPEN_CLASS);
overlayElement.classList.add(OVERLAY_CLASS);
document.body.insertBefore(overlayElement, modalElement);
overlayElement.classList.add(OVERLAY_OPEN_CLASS);
var modalUrl = triggerElement.getAttribute('href');
if (modalUrl && MODAL_DYNAMIC_FLAG in modalElement.dataset) {
fillModal(modalUrl);
}
document.addEventListener('keyup', keyupHandler);
}
function closeHandler(event) {
event.preventDefault(); event.preventDefault();
close(); close();
} }
function close() { function onKeyUp(event) {
overlayElement.classList.remove(OVERLAY_OPEN_CLASS); if (event.key === 'Escape') {
modalElement.classList.remove(MODAL_OPEN_CLASS); close();
}
}
document.removeEventListener('keyup', keyupHandler); function open() {
document.body.insertBefore(element, null);
element.classList.add(MODAL_OPEN_CLASS);
overlayElement.classList.add(MODAL_OVERLAY_CLASS);
document.body.insertBefore(overlayElement, element);
overlayElement.classList.add(MODAL_OVERLAY_OPEN_CLASS);
if (modalUrl) {
fillModal(modalUrl);
}
document.addEventListener('keyup', onKeyUp);
}
function close() {
overlayElement.classList.remove(MODAL_OVERLAY_OPEN_CLASS);
element.classList.remove(MODAL_OPEN_CLASS);
document.removeEventListener('keyup', onKeyUp);
}; };
function setupForm() {
var form = modalElement.querySelector('form');
if (form) {
utilInstances.push(window.utils.setup('form', form, { headers: MODAL_HEADERS, force: true }));
}
}
function setupCloser() {
if (MODAL_CLOSABLE_FLAG in modalElement.dataset) {
modalElement.insertBefore(closerElement, null);
closerElement.classList.add(CLOSER_CLASS);
closerElement.addEventListener('click', closeHandler, false);
overlayElement.addEventListener('click', closeHandler, false);
}
}
function fillModal(url) { function fillModal(url) {
if (!window.utils.httpClient) { if (!HttpClient) {
throw new Error('httpClient not found! Can\' fetch modal content from ' + url); throw new Error('HttpClient not found! Can\'t fetch modal content from ' + url);
} }
window.utils.httpClient.get(url, MODAL_HEADERS) HttpClient.get(url, MODAL_HEADERS)
.then(function(response) { .then(function(response) {
response.text().then(processResponse); response.text().then(processResponse);
}); });
} }
function processResponse(reponseBody) { function processResponse(responseBody) {
var responseElement = document.createElement('div');
responseElement.innerHTML = responseBody;
var modalContent = document.createElement('div'); var modalContent = document.createElement('div');
modalContent.classList.add(MODAL_CONTENT_CLASS); modalContent.classList.add(MODAL_CONTENT_CLASS);
modalContent.innerHTML = reponseBody;
var contentBody = modalContent.querySelector('.' + MAIN_CONTENT_CLASS); var contentBody = responseElement.querySelector('.' + MAIN_CONTENT_CLASS);
if (contentBody) { if (contentBody) {
modalContent.innerHTML = contentBody.innerHTML; modalContent.innerHTML = contentBody.innerHTML;
} }
var previousModalContent = modalElement.querySelector('.' + MODAL_CONTENT_CLASS); var previousModalContent = element.querySelector('.' + MODAL_CONTENT_CLASS);
if (previousModalContent) { if (previousModalContent) {
previousModalContent.remove(); previousModalContent.remove();
} }
modalContent = withPrefixedInputIDs(modalContent); modalContent = withPrefixedInputIDs(modalContent);
modalElement.insertBefore(modalContent, null); element.insertBefore(modalContent, null);
setupForm();
// setup any newly arrived utils
UtilRegistry.setupAll(element);
} }
function withPrefixedInputIDs(modalContent) { function withPrefixedInputIDs(modalContent) {
var idAttrs = ['id', 'for', 'data-conditional-id']; var idAttrs = ['id', 'for', 'data-conditional-input'];
idAttrs.forEach(function(attr) { idAttrs.forEach(function(attr) {
modalContent.querySelectorAll('[' + attr + ']').forEach(function(input) { Array.from(modalContent.querySelectorAll('[' + attr + ']')).forEach(function(input) {
var value = modalElement.id + '__' + input.getAttribute(attr); var value = element.id + '__' + input.getAttribute(attr);
input.setAttribute(attr, value); input.setAttribute(attr, value);
}); });
}); });
return modalContent; return modalContent;
} }
function keyupHandler(event) { return _init();
if (event.key === 'Escape') {
close();
}
}
setup();
function destroyUtils() {
utilInstances.filter(function(utilInstance) {
return !!utilInstance;
}).forEach(function(utilInstance) {
utilInstance.destroy();
});
}
return {
scope: modalElement,
destroy: destroyUtils,
};
}; };
if (UtilRegistry) {
UtilRegistry.register({
name: MODAL_UTIL_NAME,
selector: MODAL_UTIL_SELECTOR,
setup: modalUtil
});
}
})(); })();

View File

@ -1,114 +0,0 @@
(function() {
'use strict';
window.utils = window.utils || {};
var registeredSetupListeners = {};
var activeInstances = {};
/**
* setup function to initiate a util (utilName) on a scope (sope) with options (options).
*
* Utils need to be defined as property of `window.utils` and need to accept a scope and (optionally) options.
* Example: `window.utils.autoSubmit = function(scope, options) { ... };`
*/
window.utils.setup = function(utilName, scope, options) {
if (!utilName || !scope) {
return;
}
options = options || {};
var utilInstance;
// i18n
if (window.I18N) {
options.i18n = window.I18N;
}
if (activeInstances[utilName]) {
var instanceWithSameScope = activeInstances[utilName]
.filter(function(instance) { return !!instance; })
.find(function(instance) {
return instance.scope === scope;
});
var isAlreadySetup = !!instanceWithSameScope;
if (isAlreadySetup) {
console.warn('Trying to setup a JS utility that\'s already been set up', { utility: utilName, scope, options });
if (!options.force) {
return false;
}
}
}
function setup() {
var listener = function(event) {
if (event.detail.targetUtil !== utilName) {
return false;
}
if (options.setupFunction) {
utilInstance = options.setupFunction(scope, options);
} else {
var util = window.utils[utilName];
if (!util) {
throw new Error('"' + utilName + '" is not a known js util');
}
utilInstance = util(scope, options);
}
if (utilInstance) {
if (activeInstances[utilName] && Array.isArray(activeInstances[utilName])) {
activeInstances[utilName].push(utilInstance);
} else {
activeInstances[utilName] = [ utilInstance ];
}
}
};
if (registeredSetupListeners[utilName] && Array.isArray(registeredSetupListeners[utilName])) {
window.utils.teardown(utilName);
}
if (!registeredSetupListeners[utilName] || Array.isArray(registeredSetupListeners[utilName])) {
registeredSetupListeners[utilName] = [];
}
registeredSetupListeners[utilName].push(listener);
document.addEventListener('setup', listener);
document.dispatchEvent(new CustomEvent('setup', {
detail: { targetUtil: utilName, module: 'none' },
bubbles: true,
cancelable: true,
}));
}
setup();
return utilInstance;
};
window.utils.teardown = function(utilName, destroy) {
if (registeredSetupListeners[utilName]) {
registeredSetupListeners[utilName]
.filter(function(listener) { return !!listener })
.forEach(function(listener) {
document.removeEventListener('setup', listener);
});
delete registeredSetupListeners[utilName];
}
if (destroy === true && activeInstances[utilName]) {
activeInstances[utilName]
.filter(function(instance) { return !!instance })
.forEach(function(instance) {
instance.destroy();
});
delete activeInstances[utilName];
}
}
})();

View File

@ -1,77 +1,114 @@
(function() { (function() {
'use strict'; 'use strict';
window.utils = window.utils || {}; var SHOW_HIDE_UTIL_NAME = 'showHide';
var SHOW_HIDE_UTIL_SELECTOR = '[uw-show-hide]';
var JS_INITIALIZED_CLASS = 'js-show-hide-initialized'; var SHOW_HIDE_LOCAL_STORAGE_KEY = 'SHOW_HIDE';
var LOCAL_STORAGE_SHOW_HIDE = 'SHOW_HIDE'; var SHOW_HIDE_INITIALIZED_CLASS = 'show-hide--initialized';
var SHOW_HIDE_TOGGLE_CLASS = 'js-show-hide__toggle'; var SHOW_HIDE_COLLAPSED_CLASS = 'show-hide--collapsed';
var SHOW_HIDE_COLLAPSED_CLASS = 'js-show-hide--collapsed'; var SHOW_HIDE_TOGGLE_CLASS = 'show-hide__toggle';
var SHOW_HIDE_TARGET_CLASS = 'js-show-hide__target'; var SHOW_HIDE_TOGGLE_RIGHT_CLASS = 'show-hide__toggle--right';
/** /**
* div *
* div.js-show-hide__toggle * ShowHide Utility
* toggle here *
* div * Attribute: uw-show-hide
* content here *
* Params: (all optional)
* data-show-hide-id: string
* If this param is given the state of the utility will be persisted in the clients local storage.
* data-show-hide-collapsed: boolean property
* If this param is present the ShowHide utility will be collapsed. This value will be overruled by any value stored in the LocalStorage.
* data-show-hide-align: 'right'
* Where to put the arrow that marks the element as a ShowHide toggle. Left of toggle by default.
*
* Example usage:
* <div>
* <div uw-show-hide>Click me
* <div>This will be toggled
* <div>This will be toggled as well
*/ */
window.utils.showHide = function(wrapper, options) { var showHideUtil = function(element) {
options = options || {}; var showHideId;
function addEventHandler(el) { function init() {
el.addEventListener('click', function elClickListener() { if (!element) {
var newState = el.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS); throw new Error('ShowHide utility cannot be setup without an element!');
updateLSState(el.dataset.shIndex || null, newState); }
if (element.classList.contains(SHOW_HIDE_INITIALIZED_CLASS)) {
return false;
}
// register click listener
addClickListener();
// param showHideId
if (element.dataset.showHideId) {
showHideId = element.dataset.showHideId;
}
// param showHideCollapsed
var collapsed = false;
if (element.dataset.showHideCollapsed !== undefined) {
collapsed = true;
}
if (showHideId) {
var localStorageCollapsed = getLocalStorage()[showHideId];
if (typeof localStorageCollapsed !== 'undefined') {
collapsed = localStorageCollapsed;
}
}
element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, collapsed);
// param showHideAlign
var alignment = element.dataset.showHideAlign;
if (alignment === 'right') {
element.classList.add(SHOW_HIDE_TOGGLE_RIGHT_CLASS);
}
// mark as initialized
element.classList.add(SHOW_HIDE_INITIALIZED_CLASS, SHOW_HIDE_TOGGLE_CLASS);
return {
name: SHOW_HIDE_UTIL_NAME,
element: element,
destroy: function() {},
};
}
function addClickListener() {
element.addEventListener('click', function clickListener() {
var newState = element.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS);
if (showHideId) {
setLocalStorage(showHideId, newState);
}
}); });
} }
function updateLSState(index, state) { function setLocalStorage(id, state) {
if (!index) { var lsData = getLocalStorage();
return false; lsData[id] = state;
} window.localStorage.setItem(SHOW_HIDE_LOCAL_STORAGE_KEY, JSON.stringify(lsData));
var lsData = getLocalStorageData();
lsData[index] = state;
window.localStorage.setItem(LOCAL_STORAGE_SHOW_HIDE, JSON.stringify(lsData));
} }
function collapsedStateInLocalStorage(index) { function getLocalStorage() {
var lsState = getLocalStorageData(); return JSON.parse(window.localStorage.getItem(SHOW_HIDE_LOCAL_STORAGE_KEY)) || {};
return lsState[index];
} }
function getLocalStorageData() { return init();
return JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_SHOW_HIDE)) || {};
}
Array
.from(wrapper.querySelectorAll('.' + SHOW_HIDE_TOGGLE_CLASS))
.forEach(function(el) {
if (el.classList.contains(JS_INITIALIZED_CLASS)) {
return false;
}
var index = el.dataset.shIndex || null;
var isCollapsed = el.dataset.collapsed === 'true';
var lsCollapsedState = collapsedStateInLocalStorage(index);
if (typeof lsCollapsedState !== 'undefined') {
isCollapsed = lsCollapsedState;
}
el.parentElement.classList.toggle(SHOW_HIDE_COLLAPSED_CLASS, isCollapsed);
Array.from(el.parentElement.children).forEach(function(el) {
if (!el.classList.contains('' + SHOW_HIDE_TOGGLE_CLASS)) {
el.classList.add(SHOW_HIDE_TARGET_CLASS);
}
});
el.classList.add(JS_INITIALIZED_CLASS);
addEventHandler(el);
});
return {
scope: wrapper,
destroy: function() {},
};
}; };
if (UtilRegistry) {
UtilRegistry.register({
name: SHOW_HIDE_UTIL_NAME,
selector: SHOW_HIDE_UTIL_SELECTOR,
setup: showHideUtil
});
}
})(); })();

View File

@ -6,7 +6,7 @@
Der Handler sollte jeweils aktuelle Beispiele für alle möglichen Funktionalitäten enthalten, so dass man immer weiß, wo man nachschlagen kann. Der Handler sollte jeweils aktuelle Beispiele für alle möglichen Funktionalitäten enthalten, so dass man immer weiß, wo man nachschlagen kann.
<section> <section>
<h2 .js-show-hide__toggle>Teilweise funktionierende Abschnitte <h2 uw-show-hide>Teilweise funktionierende Abschnitte
<ul> <ul>
<li .list-group-item> <li .list-group-item>
@ -26,11 +26,11 @@
<li> <li>
Knopf-Test: Knopf-Test:
^{btnForm} ^{btnForm}
<li><br>
Modals:
^{modal "Klick mich für Ajax-Test" (Left $ SomeRoute UsersR)}
^{modal "Klick mich für Content-Test" (Right "Test Inhalt für Modal")}
<li> <li>
^{modal "Email-Test" (Right emailWidget')} Modals:
<ul>
<li>^{modal "Klick mich für Ajax-Test" (Left $ SomeRoute UsersR)}
<li>^{modal "Klick mich für Content-Test" (Right "Test Inhalt für Modal")}
<li>^{modal "Email-Test" (Right emailWidget')}
<li> <li>
Some icons: ^{isVisible False} ^{hasComment True} Some icons: ^{isVisible False} ^{hasComment True}

View File

@ -12,7 +12,7 @@ $newline never
^{pageHead pc} ^{pageHead pc}
<body .no-js .theme--#{toPathPiece currentTheme} :isAuth:.logged-in :isModal:.modal> <body .no-js .theme--#{toPathPiece currentTheme} :isAuth:.logged-in>
<!-- removes no-js class from body if client supports javascript --> <!-- removes no-js class from body if client supports javascript -->
<script> <script>
document.body.classList.remove('no-js'); document.body.classList.remove('no-js');

View File

@ -6,7 +6,7 @@ $if not isModal
<div .main> <div .main>
<div .main__content> <div .main__content uw-poc>
$if not isModal $if not isModal
<!-- breadcrumbs --> <!-- breadcrumbs -->

View File

@ -35,16 +35,12 @@ function setupDatepicker(wrapper) {
}); });
} }
// this global I18N object will be picked up automatically by the setup util if (I18n) {
window.I18N = { I18n.addMany(#{frontendI18n});
filesSelected: 'Dateien ausgewählt', // TODO: interpolate these to be translated } else {
selectFile: 'Datei auswählen', throw new Error('I18n JavaScript service is missing!');
selectFiles: 'Datei(en) auswählen', }
asyncFormFailure: 'Da ist etwas schief gelaufen, das tut uns Leid.<br>Falls das erneut passiert schicke uns gerne eine kurze Beschreibung dieses Ereignisses über das Hilfe-Widget rechts oben.<br><br>Vielen Dank für deine Hilfe',
};
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
window.utils.setup('flatpickr', document.body, { setupFunction: setupDatepicker }); setupDatepicker(document.body);
window.utils.setup('showHide', document.body);
window.utils.setup('inputs', document.body);
}); });

View File

@ -189,27 +189,21 @@ h4 {
} }
@media (min-width: 426px) { @media (min-width: 426px) {
:not(.modal) { .main__content {
.main__content { margin-left: var(--asidenav-width-md, 50px);
margin-left: var(--asidenav-width-md, 50px);
}
} }
} }
@media (min-width: 769px) { @media (min-width: 769px) {
:not(.modal) { .main__content {
.main__content { margin-left: var(--asidenav-width-lg, 20%);
margin-left: var(--asidenav-width-lg, 20%); margin-top: var(--header-height);
margin-top: var(--header-height);
}
} }
} }
@media (min-width: 1200px) { @media (min-width: 1200px) {
:not(.modal) { .main__content {
.main__content { margin-left: var(--asidenav-width-xl, 250px);
margin-left: var(--asidenav-width-xl, 250px);
}
} }
} }

View File

@ -1,3 +1,3 @@
<p> <p>
_{MsgHelpIntroduction} _{MsgHelpIntroduction}
^{form} ^{formWidget}

View File

@ -1,3 +1,4 @@
$newline text
<section> <section>
UniWorX erfahrene Veranstalter finden UniWorX erfahrene Veranstalter finden
hier die wichtigsten Neuerungen. hier die wichtigsten Neuerungen.
@ -6,6 +7,9 @@
<h2>Bekannte Probleme in Bearbeitung <h2>Bekannte Probleme in Bearbeitung
<dl .deflist> <dl .deflist>
<dt .deflist__dt>Derzeit keine bekannt.
$# $#
$# MOVE ITEM TO SECTION "VERANSTALTUNGEN", once it is implemented: $# MOVE ITEM TO SECTION "VERANSTALTUNGEN", once it is implemented:
$# $#
@ -81,6 +85,15 @@
von der Studentenkanzelei keine aktuelle und vollständige Schlüsselzuordnung von der Studentenkanzelei keine aktuelle und vollständige Schlüsselzuordnung
bekommen können. bekommen können.
<dt .deflist__dt> Aus Studentensicht
<dd .deflist__dd>
<p>
UniWorX hatte spezielle Links "Aus Studentensicht", welche in Uni2work überflüssig geworden sind.
Stattdessen kann man sich in Uni2work #
<a href=@{AuthPredsR}>Berechtigungen hier temporär selbst entziehen
. Um die eigene Veranstaltung aus Sicht eines Teilnehmers zu sehen, deaktiviert man #
die Berechtigungsprüfungen "_{MsgAuthTagLecturer}" und/oder "_{MsgAuthTagCorrector}"
<section> <section>
<h2>Übungsbetrieb <h2>Übungsbetrieb
@ -114,14 +127,18 @@
<dt .deflist__dt> Lösungshinweise <dt .deflist__dt> Lösungshinweise
<dd .deflist__dd> <dd .deflist__dd>
Zusätzlich zu Aufgabe und Lösung können Hinweise ab einem Zusätzlich zu Aufgabe und Lösung können Hinweise ab einem
Datum vor Abgabfrist freigeschaltet werden, Datum vor Ende des Abgabezeitraums freigeschaltet werden,
z.B. Lösungen zu Präsenzaufgaben. z.B. Lösungen zu Präsenzaufgaben.
<dt .deflist__dt> Sichtbarkeit <dt .deflist__dt> Sichtbarkeit
<dd .deflist__dd> <dd .deflist__dd>
Übungsblätter können bis zu einem Datum vor den Teilnehmern versteckt werden.
<p> <p>
Die Aufgabenstellung ist erst mit Eröffnung der Abgabe erhältlich, Übungsblätter können bis zu einem Datum "Sichtabr ab" vor allen Teilnehmern versteckt werden.
Das kann nützlich sein, um Tutoren und Korrektoren ein provisorisches Übungsblatt verfügbar zu machen,
dessen Bewertungsmodalitäten und Fristen sich noch ändern können.
<p>
Erst wenn das Blatt sichtbar wird, sehen die Teilnehmer in Ihrer Übersichtsliste.
Alle Dateien zur Aufgabenstellung sind aber erst mit Beginn des Abgabezeitraums erhältlich,
so wie bisher in UniWorX auch. so wie bisher in UniWorX auch.
<dt .deflist__dt> Zeitstempel <dt .deflist__dt> Zeitstempel

View File

@ -1,15 +1,22 @@
$newline never $newline never
$forall FileUploadInfo{..} <- fileInfos $if not (null fileInfos)
<div .file-container :fuiChecked:.file-container--checked> <div .file-uploads-label>_{MsgPreviouslyUploadedInfo}
<label .file-container__label.btn for=#{fuiHtmlId}>#{fuiTitle} <ol .file-input__list>
<div .checkbox> $forall FileUploadInfo{..} <- fileInfos
<input .file-container__checkbox.file-checkbox id=#{fuiHtmlId} name=#{fieldName} :fuiChecked:checked value=#{toPathPiece fuiId} type="checkbox"> <li>
<label for=#{fuiHtmlId}> <div .file-container>
<label for=#{fuiHtmlId}>#{fuiTitle}
<input type=checkbox id=#{fuiHtmlId} name=#{fieldName} :fuiChecked:checked value=#{toPathPiece fuiId}>
<div .file-input__info>
_{MsgPreviouslyUploadedDeletionInfo}
<div .file-uploads-label>_{MsgAddMoreFiles}
$# new files $# new files
<input type="file" name=#{fieldName} id=#{fieldId} multiple :req:required="required"> <input type="file" uw-file-input name=#{fieldName} id=#{fieldId} multiple :req:required="required">
<div .file-input__multi-info> <div .file-input__info>
_{MsgMultiFileUploadInfo} _{MsgMultiFileUploadInfo}
<div .file-input__unpack> <div .file-input__unpack>

View File

@ -9,47 +9,3 @@
margin-left: 7px; margin-left: 7px;
} }
} }
.file-input__multi-info {
font-size: .9rem;
font-style: italic;
margin-top: 10px;
color: var(--color-fontsec);
}
.file-input__list {
margin-left: 15px;
margin-top: 10px;
font-weight: 600;
}
.file-container {
display: flex;
align-items: center;
margin-bottom: 20px;
&.file-container--checked {
&::after {
content: none;
}
.file-container__label {
opacity: 1;
}
}
&::after {
/* TODO: get this from .msg-file */
content: '(wird entfernt)';
margin-left: 12px;
}
.checkbox {
margin-left: 12px;
}
}
.file-container__label {
opacity: 0.4;
}

24
templates/profile.julius Normal file
View File

@ -0,0 +1,24 @@
(function() {
'use strict';
var DEFAULT_THEME = 'theme--default';
document.addEventListener('DOMContentLoaded', function() {
var themeSwitcher = document.querySelector('#theme-select');
var currentTheme = DEFAULT_THEME;
if (themeSwitcher) {
currentTheme = 'theme--' + themeSwitcher.value;
themeSwitcher.addEventListener('input', function() {
var desiredTheme = 'theme--' + themeSwitcher.value;
document.body.classList.remove(currentTheme);
document.body.classList.add(desiredTheme);
currentTheme = desiredTheme;
});
}
});
})();

View File

@ -1,6 +1,6 @@
$newline never $newline never
<div .scrolltable> <div .scrolltable>
<table *{dbsAttrs'}> <table *{dbsAttrs'} data-async-table-db-header=#{toPathPiece HeaderDBTableShortcircuit}>
$maybe wHeaders' <- wHeaders $maybe wHeaders' <- wHeaders
<thead> <thead>
<tr .table__row.table__row--head> <tr .table__row.table__row--head>

View File

@ -1,6 +1,6 @@
$newline never $newline never
<div .table-filter> <div .table-filter>
<h3 .js-show-hide__toggle data-sh-index=table-filter data-collapsed=true>Filter <h3 .table-filter__toggle uw-show-hide data-show-hide-id=table-filter data-show-hide-collapsed>Filter
<div> <div>
^{filterForm} ^{filterForm}
^{scrolltable} ^{scrolltable}

View File

@ -1,3 +1,7 @@
.table-filter { .table-filter {
margin-bottom: 13px; margin-bottom: 13px;
} }
.table-filter__toggle {
padding: 3px 7px;
}

View File

@ -1,3 +1,3 @@
$newline never $newline never
<div ##{wIdent "table-wrapper"}> <div ##{wIdent "table-wrapper"} uw-async-table>
^{table} ^{table}

View File

@ -1,10 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
var dbtIdent = #{String dbtIdent};
var headerDBTableShortcircuit = #{String (toPathPiece HeaderDBTableShortcircuit)};
var selector = '#' + dbtIdent + '-table-wrapper';
var wrapper = document.querySelector(selector);
if (wrapper) {
window.utils.setup('asyncTable', wrapper, { headerDBTableShortcircuit, dbtIdent });
}
});

View File

@ -3,7 +3,7 @@ $newline never
$case formLayout $case formLayout
$of FormDBTablePagesize $of FormDBTablePagesize
$forall view <- fieldViews $forall view <- fieldViews
<label .form-group__label.label-pagesize for=#{fvId view}>#{fvLabel view} <label .form-group-label.label-pagesize for=#{fvId view}>#{fvLabel view}
^{fvInput view} ^{fvInput view}
$of _ $of _
$forall view <- fieldViews $forall view <- fieldViews
@ -13,10 +13,11 @@ $case formLayout
$else $else
<div .form-group :fvRequired view:.form-group--required :not $ fvRequired view:.form-group--optional :isJust $ fvErrors view:.form-group--has-error> <div .form-group :fvRequired view:.form-group--required :not $ fvRequired view:.form-group--optional :isJust $ fvErrors view:.form-group--has-error>
$if not (Blaze.null $ fvLabel view) $if not (Blaze.null $ fvLabel view)
<label .form-group__label for=#{fvId view}> <label .form-group-label for=#{fvId view}>
#{fvLabel view} <span .form-group-label__caption>
#{fvLabel view}
$maybe hint <- fvTooltip view $maybe hint <- fvTooltip view
<div .form-group__hint>^{hint} <div .form-group-label__hint>^{hint}
<div .form-group__input> <div .form-group__input>
^{fvInput view} ^{fvInput view}
$maybe err <- fvErrors view $maybe err <- fvErrors view

View File

@ -1,4 +1,4 @@
<div #alerts-1 .alerts> <!-- make wIdent work here instead of '#alerts-1' --> <div #alerts-1 .alerts uw-alerts>
<div .alerts__toggler> <div .alerts__toggler>
$forall (status, msg) <- mmsgs $forall (status, msg) <- mmsgs
$with status2 <- bool status "info" (status == "") $with status2 <- bool status "info" (status == "")

View File

@ -1,4 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
var alertsElement = document.querySelector('#' + 'alerts-1');
window.utils.setup('alerts', alertsElement);
});

View File

@ -1,5 +1,5 @@
$newline never $newline never
<aside .main__aside> <aside .main__aside uw-asidenav>
<div .asidenav__logo> <div .asidenav__logo>
<a href="/" .asidenav__logo-link> <a href="/" .asidenav__logo-link>
<span .asidenav__logo-link-item.asidenav__logo-lmu>LMU <span .asidenav__logo-link-item.asidenav__logo-lmu>LMU
@ -7,10 +7,10 @@ $newline never
<div .asidenav> <div .asidenav>
$forall tid <- favouriteTerms $forall tid <- favouriteTerms
<div .asidenav__box.js-show-hide> <div .asidenav__box>
<h3 .asidenav__box-title.js-show-hide__toggle data-sh-index="#{termToText tid}"> <h3 .asidenav__box-title uw-show-hide data-show-hide-id="#{termToText tid}" data-show-hide-align=right>
_{ShortTermIdentifier tid} _{ShortTermIdentifier tid}
<ul .asidenav__list.js-show-hide__target.list--iconless> <ul .asidenav__list.list--iconless>
$forall (Course{courseShorthand, courseName}, courseRoute, pageActions) <- favouriteTerm tid $forall (Course{courseShorthand, courseName}, courseRoute, pageActions) <- favouriteTerm tid
<li .asidenav__list-item :highlight courseRoute:.asidenav__list-item--active> <li .asidenav__list-item :highlight courseRoute:.asidenav__list-item--active>
<a .asidenav__link-wrapper href=@{courseRoute}> <a .asidenav__link-wrapper href=@{courseRoute}>

View File

@ -1,4 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
var asidenavEl = document.querySelector('.main__aside');
window.utils.setup('aside', asidenavEl);
});

View File

@ -15,5 +15,5 @@ $# Wrapper for all kinds of forms
^{submitButtonView} ^{submitButtonView}
$of FormAutoSubmit $of FormAutoSubmit
^{formWidget} ^{formWidget}
<button type=submit data-autosubmit> <button type=submit uw-auto-submit-button>
^{btnLabel BtnSubmit} ^{btnLabel BtnSubmit}

View File

@ -1,3 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
window.utils.setup('form', document.querySelector('#' + #{String formId}));
});

View File

@ -6,7 +6,7 @@ document.addEventListener('DOMContentLoaded', function() {
var cellInputs = Array.from(form.querySelectorAll('.massinput--cell input:not([type=hidden])')); var cellInputs = Array.from(form.querySelectorAll('.massinput--cell input:not([type=hidden])'));
cellInputs.forEach(function(input) { cellInputs.forEach(function(input) {
window.utils.setup('implicitSubmit', input, { submit: formSubmit }); makeImplicitSubmit(input, formSubmit);
}); });
@ -15,7 +15,23 @@ document.addEventListener('DOMContentLoaded', function() {
var addInputs = Array.from(wrapper.querySelectorAll('input:not([type=hidden]):not(.btn-mass-input-add)')); var addInputs = Array.from(wrapper.querySelectorAll('input:not([type=hidden]):not(.btn-mass-input-add)'));
addInputs.forEach(function(input) { addInputs.forEach(function(input) {
window.utils.setup('implicitSubmit', input, { submit: addSubmit }); makeImplicitSubmit(input, addSubmit);
}); });
}); });
// Override implicit submit (pressing enter) behaviour to trigger a specified submit button instead of the default
function makeImplicitSubmit(input, submit) {
if (!submit) {
throw new Error('implicitSubmit(input, options) needs to be passed a submit element via options');
}
var doSubmit = function(event) {
if (event.keyCode == 13) {
event.preventDefault();
submit.click();
}
};
input.addEventListener('keypress', doSubmit);
}
}); });

View File

@ -1,5 +1,5 @@
$newline never $newline never
<div .modal.js-modal #modal-#{modalId'} data-trigger=#{triggerId'} data-closeable :isDynamic:data-dynamic> <div uw-modal data-modal-trigger=##{triggerId'} data-modal-closeable>
$case modalContent $case modalContent
$of Right content $of Right content
<div .modal__content> <div .modal__content>

View File

@ -1,6 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
var modal = document.querySelector('#modal-' + #{String modalId'});
if (modal) {
window.utils.setup('modal', modal);
}
});

View File

@ -1,4 +1,4 @@
<fieldset data-conditional-id="#{fvId actionView}" data-conditional-value="#{toPathPiece act}"> <fieldset uw-interactive-fieldset data-conditional-input=#{fvId actionView} data-conditional-value=#{toPathPiece act}>
<legend> <legend>
_{act} _{act}
^{w} ^{w}

View File

@ -1,31 +0,0 @@
(function () {
'use strict';
window.utils = window.utils || {};
window.utils.stickynav = function (nav) {
var ticking = false;
init();
function init() {
window.addEventListener('scroll', function (e) {
if (!ticking) {
window.requestAnimationFrame(update);
ticking = true;
}
}, false);
update();
}
function update() {
var sticky = window.scrollY > 30;
nav.classList.toggle('navbar--sticky', sticky);
ticking = false;
}
}
})();
document.addEventListener('setup', function (e) {
// utils.stickynav(e.detail.scope.querySelector('.js-sticky-navbar'));
});

View File

@ -7,7 +7,7 @@ $newline never
$of PageActionPrime $of PageActionPrime
<div .pagenav__list-item> <div .pagenav__list-item>
$if menuItemModal $if menuItemModal
<div .modal.js-modal #modal-#{menuIdent} data-trigger=#{menuIdent} data-closeable data-dynamic> <div uw-modal data-modal-trigger=#{menuIdent} data-modal-closeable>
<a .pagenav__link-wrapper href=#{route} ##{menuIdent}>_{SomeMessage menuItemLabel} <a .pagenav__link-wrapper href=#{route} ##{menuIdent}>_{SomeMessage menuItemLabel}
$of _ $of _
$if hasSecondaryPageActions $if hasSecondaryPageActions
@ -18,6 +18,6 @@ $newline never
$of PageActionSecondary $of PageActionSecondary
<div .pagenav__list-item.pagenav__list-item--secondary> <div .pagenav__list-item.pagenav__list-item--secondary>
$if menuItemModal $if menuItemModal
<div .modal.js-modal #modal-#{menuIdent} data-trigger=#{menuIdent} data-closeable data-dynamic> <div uw-modal data-modal-trigger=#{menuIdent} data-modal-closeable>
<a .pagenav__link-wrapper.pagenav__link-wrapper--secondary href=#{route} ##{menuIdent}>_{SomeMessage menuItemLabel} <a .pagenav__link-wrapper.pagenav__link-wrapper--secondary href=#{route} ##{menuIdent}>_{SomeMessage menuItemLabel}
$of _ $of _

View File

@ -1,8 +1,10 @@
$# Display Rating, expects $# Display Rating, expects
$# sub :: Submission
$# submissionRatingDone :: Submission -> Bool
$# submissionRatingPoints :: Maybe points $# submissionRatingPoints :: Maybe points
$maybe points <- submissionRatingPoints $if submissionRatingDone sub
$maybe grading <- preview _grading sheetType $maybe (grading, points) <- mTuple (preview _grading sheetType) submissionRatingPoints
$case grading $case grading
$of Points{..} $of Points{..}
_{MsgAchievedOf points maxPoints} _{MsgAchievedOf points maxPoints}

View File

@ -1,3 +1,3 @@
#!/usr/bin/env bash #!/usr/bin/env bash
exec -- stack build --test --fast --flag uniworx:dev --flag uniworx:library-only ${@} exec -- stack build --test --coverage --fast --flag uniworx:dev --flag uniworx:library-only ${@}