feat(alerticons): allow alerts to have custom icons

This commit is contained in:
Steffen Jost 2019-07-25 00:19:09 +02:00
parent 495fdd18dd
commit d70a9585f0
10 changed files with 134 additions and 52 deletions

View File

@ -6,11 +6,15 @@
// https://fontawesome.com/icons // https://fontawesome.com/icons
export const ALERT_ICONS = { export const ALERT_ICONS = {
info: '"\\f05a"', calendarcheck: '"\\f274"',
calendartimes: '"\\f273"',
checkmark: '"\\f058"', checkmark: '"\\f058"',
exclamation: '"\\f06a"',
warning: '"\\f071"',
cross: '"\\f00d"', cross: '"\\f00d"',
registered: '"\\f274"',
deregistered: '"\\f273"', deregistered: '"\\f273"',
exclamation: '"\\f06a"',
info: '"\\f05a"',
registered: '"\\f274"',
userplus: '"\\f234"',
userslash: '"\\f504"',
warning: '"\\f071"',
}; };

View File

@ -5,7 +5,7 @@
module Foundation where module Foundation where
import Import.NoFoundation hiding (embedFile) import Import.NoFoundation hiding (embedFile)
import qualified ClassyPrelude.Yesod as Yesod (addMessage, getHttpManager) import qualified ClassyPrelude.Yesod as Yesod (getHttpManager)
import Database.Persist.Sql (ConnectionPool, runSqlPool) import Database.Persist.Sql (ConnectionPool, runSqlPool)
import Text.Hamlet (hamletFile) import Text.Hamlet (hamletFile)
@ -1216,9 +1216,10 @@ instance Yesod UniWorX where
, massInputShortcircuit , massInputShortcircuit
] ]
lift . bracketOnError getMessages (mapM_ $ uncurry Yesod.addMessage) $ \msgs -> do lift . bracketOnError getMessages (mapM_ addMessage') $ \msgs -> do
Just msgs' <- return . forM msgs $ \(msgState, content) -> Message <$> fromPathPiece msgState <*> return content -- @gkleen: the following line is redundant, but what does this block do anyway?
addCustomHeader HeaderAlerts . decodeUtf8 . urlEncode True . toStrict $ JSON.encode msgs' -- Just msgs' <- return . forM msgs $ \(msgState, content) -> Message <$> fromPathPiece msgState <*> return content
addCustomHeader HeaderAlerts . decodeUtf8 . urlEncode True . toStrict $ JSON.encode msgs
-- Since we implement `errorHandler` ourselves we don't need `defaultMessageWidget` -- Since we implement `errorHandler` ourselves we don't need `defaultMessageWidget`
defaultMessageWidget _title _body = error "defaultMessageWidget: undefined" defaultMessageWidget _title _body = error "defaultMessageWidget: undefined"

View File

@ -113,7 +113,7 @@ postAdminTestR = do
formResultModal emailResult AdminTestR $ \(email, ls) -> do formResultModal emailResult AdminTestR $ \(email, ls) -> do
jId <- mapWriterT runDB $ do jId <- mapWriterT runDB $ do
jId <- queueJob $ JobSendTestEmail email ls jId <- queueJob $ JobSendTestEmail email ls
tell . pure $ Message Success [shamlet|Email-test gestartet (Job ##{tshow (fromSqlKey jId)})|] tell . pure $ Message Success [shamlet|Email-test gestartet (Job ##{tshow (fromSqlKey jId)})|] Nothing
return jId return jId
writeJobCtl $ JobCtlPerform jId writeJobCtl $ JobCtlPerform jId
addMessage Warning [shamlet|Inkorrekt ausgegebener Alert|] -- For testing alert handling when short circuiting; for proper (not fallback-solution) handling always use `tell` within `formResultModal` addMessage Warning [shamlet|Inkorrekt ausgegebener Alert|] -- For testing alert handling when short circuiting; for proper (not fallback-solution) handling always use `tell` within `formResultModal`

View File

@ -1048,21 +1048,14 @@ postERegisterR tid ssh csh examn = do
now <- liftIO getCurrentTime now <- liftIO getCurrentTime
insert_ $ ExamRegistration eId uid Nothing now insert_ $ ExamRegistration eId uid Nothing now
audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent audit' $ TransactionExamRegister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent
addMessageWidget Success [whamlet| addMessageIconI Success IconExamRegisterTrue (MsgExamRegisteredSuccess examn)
<div>#{iconExamRegister True}
<div>&nbsp;
<div>_{MsgExamRegisteredSuccess examn}
|]
redirect $ CExamR tid ssh csh examn EShowR redirect $ CExamR tid ssh csh examn EShowR
BtnExamDeregister -> do BtnExamDeregister -> do
runDB $ do runDB $ do
deleteBy $ UniqueExamRegistration eId uid deleteBy $ UniqueExamRegistration eId uid
audit' $ TransactionExamDeregister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent audit' $ TransactionExamDeregister (unTermKey tid) (unSchoolKey ssh) csh examn userIdent
addMessageWidget Info [whamlet| addMessageIconI Info IconExamRegisterFalse (MsgExamDeregisteredSuccess examn)
<div>#{iconExamRegister False} -- yes, it's a success message, but it should be visually different from a positive success, since most will just note the positive green color! See discussion on commit 5f4925a4
<div>&nbsp;
<div>_{MsgExamDeregisteredSuccess examn}
|] -- yes, it's a success message, but it should be visually different from a positive success, since most will just note the positive green color! See discussion on commit 5f4925a4
redirect $ CExamR tid ssh csh examn EShowR redirect $ CExamR tid ssh csh examn EShowR
invalidArgs ["Register/Deregister button required"] invalidArgs ["Register/Deregister button required"]

View File

@ -3,7 +3,7 @@ module Import.NoModel
, MForm , MForm
) where ) where
import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static, boolField, identifyForm, cons, HasHttpManager(..)) import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, getMessages, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static, boolField, identifyForm, cons, HasHttpManager(..))
import Model.Types.TH.JSON as Import import Model.Types.TH.JSON as Import
import Model.Types.TH.Wordlist as Import import Model.Types.TH.Wordlist as Import
@ -53,7 +53,7 @@ import Data.Ratio as Import ((%))
import Net.IP as Import (IP) import Net.IP as Import (IP)
import Database.Persist.Sql as Import (SqlReadT, SqlWriteT, fromSqlKey, toSqlKey) import Database.Persist.Sql as Import (SqlReadT, SqlWriteT, fromSqlKey, toSqlKey)
import Ldap.Client.Pool as Import import Ldap.Client.Pool as Import
import System.Random as Import (Random(..)) import System.Random as Import (Random(..))
@ -70,7 +70,7 @@ import Data.Time.LocalTime as Import hiding (utcToLocalTime, localTimeToUTC)
import Time.Types as Import (WeekDay(..)) import Time.Types as Import (WeekDay(..))
import Network.Mime as Import import Network.Mime as Import
import Data.Aeson.TH as Import import Data.Aeson.TH as Import
import Data.Aeson.Types as Import (FromJSON(..), ToJSON(..), FromJSONKey(..), ToJSONKey(..), toJSONKeyText, FromJSONKeyFunction(..), ToJSONKeyFunction(..), Value) import Data.Aeson.Types as Import (FromJSON(..), ToJSON(..), FromJSONKey(..), ToJSONKey(..), toJSONKeyText, FromJSONKeyFunction(..), ToJSONKeyFunction(..), Value)

View File

@ -80,7 +80,7 @@ import Algebra.Lattice (top, bottom, (/\), (\/), BoundedJoinSemiLattice, Bounded
import Data.Constraint (Dict(..)) import Data.Constraint (Dict(..))
{- # ANN choice ("HLint: ignore Use asum" :: String) # -} {-# ANN module ("HLint: ignore Use asum" :: String) #-}
$(iconShortcuts) -- declares constants for all known icons $(iconShortcuts) -- declares constants for all known icons
@ -114,6 +114,10 @@ unsupportedAuthPredicate = do
unauthorizedI (UnsupportedAuthPredicate (toPathPiece tag) route) unauthorizedI (UnsupportedAuthPredicate (toPathPiece tag) route)
|] |]
-- | allows conditional attributes in hamlet via *{..} syntax
maybeAttribute :: Text -> (a -> Text) -> Maybe a -> [(Text,Text)]
maybeAttribute _ _ Nothing = []
maybeAttribute a c (Just v) = [(a,c v)]
--------------------- ---------------------

View File

@ -742,13 +742,14 @@ wformMessage = void . aFormToWForm . aformMessage
formMessage :: (MonadHandler m) => Message -> MForm m (FormResult (), FieldView site) formMessage :: (MonadHandler m) => Message -> MForm m (FormResult (), FieldView site)
formMessage Message{..} = do formMessage Message{..} = do
let icn = maybeAttribute "data-icon" iconJS messageIcon
return (FormSuccess (), FieldView return (FormSuccess (), FieldView
{ fvLabel = mempty { fvLabel = mempty
, fvTooltip = Nothing , fvTooltip = Nothing
, fvId = idFormMessageNoinput , fvId = idFormMessageNoinput
, fvErrors = Nothing , fvErrors = Nothing
, fvRequired = False , fvRequired = False
, fvInput = [whamlet|<div .notification .notification-#{toPathPiece messageStatus}>#{messageContent}|] , fvInput = [whamlet|<div .notification .notification-#{toPathPiece messageStatus} *{icn}>#{messageContent}|]
}) })
--------------------- ---------------------

View File

@ -1,6 +1,6 @@
module Utils.Icon where module Utils.Icon where
import ClassyPrelude.Yesod hiding (foldlM, Proxy) import ClassyPrelude.Yesod hiding (Proxy)
import Data.Universe import Data.Universe
import Data.Char import Data.Char
@ -12,6 +12,8 @@ import Language.Haskell.TH
import Language.Haskell.TH.Instances () import Language.Haskell.TH.Instances ()
import Language.Haskell.TH.Lift (deriveLift) import Language.Haskell.TH.Lift (deriveLift)
import Instances.TH.Lift () import Instances.TH.Lift ()
import Data.Aeson
import Data.Aeson.TH
-- | A @Widget@ for any site; no language interpolation, etc. -- | A @Widget@ for any site; no language interpolation, etc.
type WidgetSiteless = forall site. forall m. (MonadIO m, MonadThrow m, MonadBaseControl IO m) type WidgetSiteless = forall site. forall m. (MonadIO m, MonadThrow m, MonadBaseControl IO m)
@ -23,8 +25,10 @@ type WidgetSiteless = forall site. forall m. (MonadIO m, MonadThrow m, MonadBase
----------- -----------
-- We collect all used icons here for an overview. -- We collect all used icons here for an overview.
-- For consistency, some conditional icons are also provided, having suffix True/False -- For consistency, some conditional icons are also provided, having suffix True/False
-- IMPORTANT: Alert-Icons must be registered in alert-icons.js as well
---------------------------------------------------------------------------
-- IMPORTANT: Alert-Icons must be registered in alert-icons.js as well!!!
---------------------------------------------------------------------------
data Icon data Icon
= IconNew = IconNew
| IconOK | IconOK
@ -48,7 +52,7 @@ data Icon
| IconSFTHint -- for SheetFileType only | IconSFTHint -- for SheetFileType only
| IconSFTSolution -- for SheetFileType only | IconSFTSolution -- for SheetFileType only
| IconSFTMarking -- for SheetFileType only | IconSFTMarking -- for SheetFileType only
deriving (Eq, Enum, Bounded, Show, Read) deriving (Eq, Ord, Enum, Bounded, Show, Read)
iconText :: Icon -> Text iconText :: Icon -> Text
iconText = \case iconText = \case
@ -75,11 +79,19 @@ iconText = \case
IconSFTSolution -> "exclamation-circle" -- for SheetFileType only IconSFTSolution -> "exclamation-circle" -- for SheetFileType only
IconSFTMarking -> "check-circle" -- for SheetFileType only IconSFTMarking -> "check-circle" -- for SheetFileType only
-- | like iconText, but eliminates '-' since these are problemativ in alert-icons.js
iconJS :: Icon -> Text
iconJS = filter ('-' /=) . iconText
instance Universe Icon instance Universe Icon
instance Finite Icon instance Finite Icon
nullaryPathPiece ''Icon $ camelToPathPiece' 1 nullaryPathPiece ''Icon $ camelToPathPiece' 1
deriveLift ''Icon deriveLift ''Icon
deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece' 1
} ''Icon
-- Create an icon from font-awesome without additional space -- Create an icon from font-awesome without additional space
icon :: Icon -> Markup icon :: Icon -> Markup
icon ic = let ict = iconText ic in icon ic = let ict = iconText ic in
@ -93,11 +105,10 @@ iconShortcuts = foldMap mkIcon (universeF :: [Icon])
where where
mkIcon :: Icon -> Q [Dec] mkIcon :: Icon -> Q [Dec]
mkIcon ic = do mkIcon ic = do
do iname <- newName $ over (ix 0) Data.Char.toLower $ show ic
iname <- newName $ over (ix 0) Data.Char.toLower $ show ic isig <- sigD iname [t|Markup|]
isig <- sigD iname [t|Markup|] idef <- valD (varP iname) (normalB [|icon ic|]) []
idef <- valD (varP iname) (normalB [|icon ic|]) [] return [isig, idef]
return $ [isig, idef]
---------------------- ----------------------
@ -140,7 +151,7 @@ iconEnrol False = icon IconEnrolFalse
iconExamRegister :: Bool -> Markup iconExamRegister :: Bool -> Markup
iconExamRegister True = icon IconExamRegisterTrue iconExamRegister True = icon IconExamRegisterTrue
iconExamRegister False = icon IconExamRegisterTrue iconExamRegister False = icon IconExamRegisterFalse
---------------- ----------------

View File

@ -1,19 +1,23 @@
module Utils.Message module Utils.Message
( MessageStatus(..) ( MessageStatus(..), MessageIconStatus(..)
, UnknownMessageStatus(..) , UnknownMessageStatus(..)
, getMessages
, addMessage',addMessageIcon, addMessageIconI -- messages with special icons (needs registering in alert-icons.js)
, addMessage, addMessageI, addMessageIHamlet, addMessageFile, addMessageWidget , addMessage, addMessageI, addMessageIHamlet, addMessageFile, addMessageWidget
, statusToUrgencyClass , statusToUrgencyClass
, Message(..) , Message(..)
, messageI, messageIHamlet, messageFile, messageWidget , messageI, messageIHamlet, messageFile, messageWidget
, encodeMessageIconStatus, decodeMessageIconStatus, decodeMessageIconStatus'
) where ) where
import Data.Universe import Data.Universe
import Utils.Icon
import Utils.PathPiece import Utils.PathPiece
import Data.Aeson import Data.Aeson
import Data.Aeson.TH import Data.Aeson.TH
import qualified ClassyPrelude.Yesod (addMessage, addMessageI) import qualified ClassyPrelude.Yesod (addMessage, addMessageI, getMessages)
import ClassyPrelude.Yesod hiding (addMessage, addMessageI) import ClassyPrelude.Yesod hiding (addMessage, addMessageI, getMessages)
import Text.Hamlet import Text.Hamlet
@ -28,8 +32,11 @@ import Text.HTML.SanitizeXSS (sanitizeBalance)
data MessageStatus = Error | Warning | Info | Success data MessageStatus = Error | Warning | Info | Success
deriving (Eq, Ord, Enum, Bounded, Show, Read, Lift) deriving (Eq, Ord, Enum, Bounded, Show, Read, Lift)
instance Universe MessageStatus instance Universe MessageStatus
instance Finite MessageStatus instance Finite MessageStatus
instance Default MessageStatus where
def = Info
deriveJSON defaultOptions deriveJSON defaultOptions
{ constructorTagModifier = camelToPathPiece { constructorTagModifier = camelToPathPiece
@ -43,11 +50,52 @@ newtype UnknownMessageStatus = UnknownMessageStatus Text
instance Exception UnknownMessageStatus instance Exception UnknownMessageStatus
-- ms2mis :: MessageStatus -> MessageIconStatus
-- ms2mis s = def { misStatus= s}
data MessageIconStatus = MIS { misStatus :: MessageStatus, misIcon :: Maybe Icon }
deriving (Eq, Ord, Show, Read, Lift)
instance Default MessageIconStatus where
def = MIS { misStatus=def, misIcon=Nothing }
deriveJSON defaultOptions
{ fieldLabelModifier = camelToPathPiece' 1
} ''MessageIconStatus
encodeMessageStatus :: MessageStatus -> Text
encodeMessageStatus ms = encodeMessageIconStatus $ def{ misStatus=ms }
encodeMessageIconStatus :: MessageIconStatus -> Text
encodeMessageIconStatus = decodeUtf8 . toStrict . encode
decodeMessageIconStatus :: Text -> Maybe MessageIconStatus
decodeMessageIconStatus = decode' . fromStrict . encodeUtf8
decodeMessageIconStatus' :: Text -> MessageIconStatus
decodeMessageIconStatus' t
| Just mis <- decodeMessageIconStatus t = mis
| otherwise = def
decodeMessage :: (Text, Html) -> Message
decodeMessage (mis, msgContent)
| Just MIS{ misStatus=messageStatus, misIcon=messageIcon } <- decodeMessageIconStatus mis
= let messageContent = msgContent in Message{..}
| Just messageStatus <- fromPathPiece mis
= let messageIcon = Nothing -- legacy case, should no longer occur ($logDebug ???)
messageContent = msgContent <> "!!!"
in Message{..}
| otherwise -- should not happen, if refactored correctly ($logDebug ???)
= let messageStatus = Utils.Message.Warning
messageContent = msgContent <> "!!!!"
messageIcon = Nothing
in Message{..}
data Message = Message data Message = Message
{ messageStatus :: MessageStatus { messageStatus :: MessageStatus
, messageContent :: Html , messageContent :: Html
-- , messageIcon :: Maybe Icon , messageIcon :: Maybe Icon
} }
instance Eq Message where instance Eq Message where
@ -60,26 +108,39 @@ instance ToJSON Message where
toJSON Message{..} = object toJSON Message{..} = object
[ "status" .= messageStatus [ "status" .= messageStatus
, "content" .= renderHtml messageContent , "content" .= renderHtml messageContent
, "icon" .= messageIcon
] ]
instance FromJSON Message where instance FromJSON Message where
parseJSON = withObject "Message" $ \o -> do parseJSON = withObject "Message" $ \o -> do
messageStatus <- o .: "status" messageStatus <- o .: "status"
messageContent <- preEscapedText . sanitizeBalance <$> o .: "content" messageContent <- preEscapedText . sanitizeBalance <$> o .: "content"
messageIcon <- o .: "icon"
return Message{..} return Message{..}
statusToUrgencyClass :: MessageStatus -> Text statusToUrgencyClass :: MessageStatus -> Text
statusToUrgencyClass status = "urgency__" <> toPathPiece status statusToUrgencyClass status = "urgency__" <> toPathPiece status
addMessage' :: MonadHandler m => Message -> m ()
addMessage' Message{..} = ClassyPrelude.Yesod.addMessage (encodeMessageIconStatus mis) messageContent
where mis = MIS{misStatus=messageStatus, misIcon=messageIcon}
addMessageIcon :: MonadHandler m => MessageStatus -> Icon -> Html -> m ()
addMessageIcon ms mi = ClassyPrelude.Yesod.addMessage $ encodeMessageIconStatus MIS{misStatus=ms, misIcon=Just mi}
addMessageIconI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageStatus -> Icon -> msg -> m ()
addMessageIconI ms mi = ClassyPrelude.Yesod.addMessageI $ encodeMessageIconStatus MIS{misStatus=ms, misIcon=Just mi}
addMessage :: MonadHandler m => MessageStatus -> Html -> m () addMessage :: MonadHandler m => MessageStatus -> Html -> m ()
addMessage mc = ClassyPrelude.Yesod.addMessage (toPathPiece mc) addMessage mc = ClassyPrelude.Yesod.addMessage $ encodeMessageStatus mc
addMessageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageStatus -> msg -> m () addMessageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageStatus -> msg -> m ()
addMessageI mc = ClassyPrelude.Yesod.addMessageI (toPathPiece mc) addMessageI mc = ClassyPrelude.Yesod.addMessageI $ encodeMessageStatus mc
messageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageStatus -> msg -> m Message messageI :: (MonadHandler m, RenderMessage (HandlerSite m) msg) => MessageStatus -> msg -> m Message
messageI messageStatus msg = do messageI messageStatus msg = do
messageContent <- toHtml . ($ msg) <$> getMessageRender messageContent <- toHtml . ($ msg) <$> getMessageRender
let messageIcon = Nothing
return Message{..} return Message{..}
addMessageIHamlet :: ( MonadHandler m addMessageIHamlet :: ( MonadHandler m
@ -88,15 +149,16 @@ addMessageIHamlet :: ( MonadHandler m
) => MessageStatus -> HtmlUrlI18n msg (Route site) -> m () ) => MessageStatus -> HtmlUrlI18n msg (Route site) -> m ()
addMessageIHamlet mc iHamlet = do addMessageIHamlet mc iHamlet = do
mr <- getMessageRender mr <- getMessageRender
ClassyPrelude.Yesod.addMessage (toPathPiece mc) =<< withUrlRenderer (iHamlet $ toHtml . mr) ClassyPrelude.Yesod.addMessage (encodeMessageStatus mc) =<< withUrlRenderer (iHamlet $ toHtml . mr)
messageIHamlet :: ( MonadHandler m messageIHamlet :: ( MonadHandler m
, RenderMessage (HandlerSite m) msg , RenderMessage (HandlerSite m) msg
, HandlerSite m ~ site , HandlerSite m ~ site
) => MessageStatus -> HtmlUrlI18n msg (Route site) -> m Message ) => MessageStatus -> HtmlUrlI18n msg (Route site) -> m Message
messageIHamlet mc iHamlet = do messageIHamlet ms iHamlet = do
mr <- getMessageRender mr <- getMessageRender
Message mc <$> withUrlRenderer (iHamlet $ toHtml . mr) let mi = Nothing
Message ms <$> withUrlRenderer (iHamlet $ toHtml . mr) <*> pure mi
addMessageFile :: MessageStatus -> FilePath -> ExpQ addMessageFile :: MessageStatus -> FilePath -> ExpQ
addMessageFile mc tPath = [e|addMessageIHamlet mc $(ihamletFile tPath)|] addMessageFile mc tPath = [e|addMessageIHamlet mc $(ihamletFile tPath)|]
@ -123,3 +185,9 @@ messageWidget :: forall m site.
messageWidget mc wgt = do messageWidget mc wgt = do
PageContent{pageBody} <- liftHandlerT $ widgetToPageContent wgt PageContent{pageBody} <- liftHandlerT $ widgetToPageContent wgt
messageIHamlet mc (const pageBody :: HtmlUrlI18n (SomeMessage site) (Route site)) messageIHamlet mc (const pageBody :: HtmlUrlI18n (SomeMessage site) (Route site))
getMessages :: MonadHandler m => m [Message]
getMessages = fmap decodeMessage <$> ClassyPrelude.Yesod.getMessages

View File

@ -1,15 +1,15 @@
$newline never $newline never
<div #alerts-1 .alerts uw-alerts> <div #alerts-1 .alerts uw-alerts>
<div .alerts__toggler> <div .alerts__toggler>
$forall (status, msg) <- mmsgs $forall Message{..} <- mmsgs
$with status2 <- bool status "info" (status == "") $with icn <- maybeAttribute "data-icon" iconJS messageIcon
<!-- <div .alert .alert-#{toPathPiece messageStatus} *{icn}>
TODO:
If a custom icon is set for this alert then add "data-icon=#{icon}" to the .alert element.
A list of available icons can be found in frontend/src/utils/alerts/alert-icons.js
-->
<div .alert.alert-#{status2}>
<div .alert__closer> <div .alert__closer>
<div .alert__icon> <div .alert__icon>
<div .alert__content> <div .alert__content>
#{msg} #{messageContent}
<!--
TODO:
If a custom icon is set for this alert then add "data-icon=#{icon}" to the .alert element.
A list of available icons can be found in frontend/src/utils/alerts/alert-icons.js
-->