Display messages as alerts

This commit is contained in:
Daniel J. Summers 2023-01-09 08:44:01 -05:00
parent f778df8e1d
commit b74b871c2b
4 changed files with 78 additions and 71 deletions

View File

@ -17,6 +17,14 @@ module Vue =
open Giraffe.Htmx
[<AutoOpen>]
module private HtmxHelpers =
/// Is the request from htmx?
let isHtmx (ctx : HttpContext) =
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
/// Handlers for error conditions
module Error =
@ -43,10 +51,6 @@ module Error =
return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
}
/// Is the request from htmx?
let isHtmx (ctx : HttpContext) =
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
let notAuthorized : HttpHandler = fun next ctx ->
if ctx.Request.Method = "GET" then
@ -139,7 +143,7 @@ module Helpers =
}
/// Get the messages from the session (destructively)
let messages ctx = task {
let popMessages ctx = task {
do! loadSession ctx
let msgs =
match ctx.Session.GetString "messages" with
@ -152,7 +156,7 @@ module Helpers =
/// Add a message to the response
let addMessage (level : string) (msg : string) ctx = task {
do! loadSession ctx
let! msgs = messages ctx
let! msgs = popMessages ctx
ctx.Session.SetString ("messages", JsonSerializer.Serialize ($"{level}|||{msg}" :: msgs))
}
@ -167,20 +171,17 @@ module Helpers =
}
/// Render a page-level view
let render pageTitle (next : HttpFunc) (ctx : HttpContext) content = task {
let renderFunc = if ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh then Layout.partial else Layout.full
let renderCtx : Layout.PageRenderContext = {
let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
let! messages = popMessages ctx
let renderCtx : Layout.PageRenderContext = {
IsLoggedOn = Option.isSome (tryUser ctx)
CurrentUrl = ctx.Request.Path.Value
PageTitle = pageTitle
Content = content
Messages = messages
}
let! msgs = messages ctx
let! newCtx = task {
if List.isEmpty msgs then return Some ctx
else return! (msgs |> List.map (fun m -> setHttpHeader "X-Toast" m) |> List.reduce (>=>)) next ctx
}
return! newCtx.Value.WriteHtmlViewAsync (renderFunc renderCtx)
let renderFunc = if isHtmx ctx then Layout.partial else Layout.full
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
}
/// Render as a composable HttpHandler
@ -197,23 +198,17 @@ module Helpers =
/// Require a user to be logged on for a route
let requireUser = requiresAuthentication Error.notAuthorized
/// Is the request from htmx?
// TODO: need to only define this once
let isHtmx (ctx : HttpContext) =
ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh
/// Redirect to another page, saving the session before redirecting
let redirectToGet (url : string) next ctx = task {
let redirectToGet url next ctx = task {
do! saveSession ctx
let action =
if not (isNull url)
&& not (url = "")
if Option.isSome (noneIfEmpty url)
// "/" or "/foo" but not "//" or "/\"
&& ( (url[0] = '/' && (url.Length = 1 || (url[1] <> '/' && url[1] <> '\\')))
// "~/" or "~/foo"
|| (url.Length > 1 && url[0] = '~' && url[1] = '/')) then
if isHtmx ctx then withHxRedirect url else redirectTo false url
else RequestErrors.BAD_REQUEST ""
else RequestErrors.BAD_REQUEST "Invalid redirect URL"
return! action next ctx
}
@ -445,7 +440,7 @@ module Continent =
module Home =
// GET: /
let home : HttpHandler =
let home =
renderHandler "Welcome" Home.home
// GET: /how-it-works

View File

@ -2,7 +2,7 @@
module JobsJobsJobs.ViewModels
/// View model for the log on page
[<CLIMutable>]
[<CLIMutable; NoComparison; NoEquality>]
type LogOnViewModel =
{ /// A message regarding an error encountered during a log on attempt
ErrorMessage : string option
@ -19,7 +19,7 @@ type LogOnViewModel =
/// View model for the registration page
[<CLIMutable>]
[<CLIMutable; NoComparison; NoEquality>]
type RegisterViewModel =
{ /// The user's first name
FirstName : string

View File

@ -17,6 +17,9 @@ type PageRenderContext =
/// The page content
Content : XmlNode
/// User messages to be displayed
Messages : string list
}
/// Append the application name to the page title
@ -130,6 +133,24 @@ let private htmlFoot =
]
]
/// Render any messages
let private messages ctx =
ctx.Messages
|> List.map (fun msg ->
let parts = msg.Split "|||"
let level = if parts[0] = "error" then "danger" else parts[0]
let message = parts[1]
div [ _class $"alert alert-{level} alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
_roleAlert ] [
p [ _class "mb-0" ] [
if level <> "success" then
strong [] [ rawText (parts[0].ToUpperInvariant ()); rawText ": " ]
rawText message
]
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
])
|> div [ _id "alerts" ]
/// Create a full view
let full ctx =
html [ _lang "en" ] [
@ -139,7 +160,10 @@ let full ctx =
yield! sideNavs ctx
div [ _class "jjj-main" ] [
yield! titleBars
main [ _class "jjj-content container-fluid" ] [ ctx.Content ]
main [ _class "jjj-content container-fluid" ] [
messages ctx
ctx.Content
]
htmlFoot
]
]
@ -149,6 +173,13 @@ let full ctx =
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
_crossorigin "anonymous" ] []
script [ _src "/script.js" ] []
template [ _id "alertTemplate" ] [
div [ _class $"alert alert-dismissable fade show d-flex justify-content-between p-2 mb-1 mt-1"
_roleAlert ] [
p [ _class "mb-0" ] []
button [ _type "button"; _class "btn-close"; _data "bs-dismiss" "alert"; _ariaLabel "Close" ] []
]
]
]
]
@ -162,7 +193,10 @@ let partial ctx =
yield! sideNavs ctx
div [ _class "jjj-main" ] [
yield! titleBars
main [ _class "jjj-content container-fluid" ] [ ctx.Content ]
main [ _class "jjj-content container-fluid" ] [
messages ctx
ctx.Content
]
htmlFoot
]
]

View File

@ -18,43 +18,24 @@ this.jjj = {
},
/**
* Show a message via toast
* Show a message via an alert
* @param {string} message The message to show
*/
showToast (message) {
showAlert (message) {
const [level, msg] = message.split("|||")
let header
if (level !== "success") {
const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
/** @type {HTMLTemplateElement} */
const alertTemplate = document.getElementById("alertTemplate")
/** @type {HTMLDivElement} */
const alert = alertTemplate.content.firstElementChild.cloneNode(true)
alert.classList.add(`alert-${level === "error" ? "danger" : level}`)
header = document.createElement("div")
header.className = "toast-header"
header.innerHTML = heading(level === "warning" ? level : "error")
const prefix = level === "success" ? "" : `<strong>${level.toUpperCase()}: </strong>`
alert.querySelector("p").innerHTML = `${prefix}${msg}`
const close = document.createElement("button")
close.type = "button"
close.className = "btn-close"
close.setAttribute("data-bs-dismiss", "toast")
close.setAttribute("aria-label", "Close")
header.appendChild(close)
}
const body = document.createElement("div")
body.className = "toast-body"
body.innerText = msg
const toastEl = document.createElement("div")
toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white`
toastEl.setAttribute("role", "alert")
toastEl.setAttribute("aria-live", "assertlive")
toastEl.setAttribute("aria-atomic", "true")
toastEl.addEventListener("hidden.bs.toast", e => e.target.remove())
if (header) toastEl.appendChild(header)
toastEl.appendChild(body)
document.getElementById("toasts").appendChild(toastEl)
new bootstrap.Toast(toastEl, { autohide: level === "success" }).show()
const alerts = document.getElementById("alerts")
alerts.appendChild(alert)
alerts.scrollIntoView()
},
/**
@ -73,15 +54,6 @@ this.jjj = {
}
}
htmx.on("htmx:afterOnLoad", function (evt) {
const hdrs = evt.detail.xhr.getAllResponseHeaders()
// Show a message if there was one in the response
console.info(`Here are the headers: ${hdrs}`)
if (hdrs.indexOf("x-toast") >= 0) {
jjj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
}
})
htmx.on("htmx:configRequest", function (evt) {
// Send the user's current time zone so that we can display local time
if (jjj.timeZone) {
@ -89,4 +61,10 @@ htmx.on("htmx:configRequest", function (evt) {
}
})
htmx.on("htmx:responseError", function (evt) {
/** @type {XMLHttpRequest} */
const xhr = evt.detail.xhr
jjj.showAlert(`error|||${xhr.status}: ${xhr.statusText}`)
})
jjj.deriveTimeZone()