Version 3 #40
|
@ -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 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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
]
|
||||
]
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Reference in New Issue
Block a user