Version 3 #40
|
@ -17,6 +17,14 @@ module Vue =
|
||||||
|
|
||||||
open Giraffe.Htmx
|
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
|
/// Handlers for error conditions
|
||||||
module Error =
|
module Error =
|
||||||
|
|
||||||
|
@ -43,10 +51,6 @@ module Error =
|
||||||
return! RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" next ctx
|
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
|
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||||
let notAuthorized : HttpHandler = fun next ctx ->
|
let notAuthorized : HttpHandler = fun next ctx ->
|
||||||
if ctx.Request.Method = "GET" then
|
if ctx.Request.Method = "GET" then
|
||||||
|
@ -139,7 +143,7 @@ module Helpers =
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the messages from the session (destructively)
|
/// Get the messages from the session (destructively)
|
||||||
let messages ctx = task {
|
let popMessages ctx = task {
|
||||||
do! loadSession ctx
|
do! loadSession ctx
|
||||||
let msgs =
|
let msgs =
|
||||||
match ctx.Session.GetString "messages" with
|
match ctx.Session.GetString "messages" with
|
||||||
|
@ -152,7 +156,7 @@ module Helpers =
|
||||||
/// Add a message to the response
|
/// Add a message to the response
|
||||||
let addMessage (level : string) (msg : string) ctx = task {
|
let addMessage (level : string) (msg : string) ctx = task {
|
||||||
do! loadSession ctx
|
do! loadSession ctx
|
||||||
let! msgs = messages ctx
|
let! msgs = popMessages ctx
|
||||||
ctx.Session.SetString ("messages", JsonSerializer.Serialize ($"{level}|||{msg}" :: msgs))
|
ctx.Session.SetString ("messages", JsonSerializer.Serialize ($"{level}|||{msg}" :: msgs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -167,20 +171,17 @@ module Helpers =
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render a page-level view
|
/// Render a page-level view
|
||||||
let render pageTitle (next : HttpFunc) (ctx : HttpContext) content = task {
|
let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task {
|
||||||
let renderFunc = if ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh then Layout.partial else Layout.full
|
let! messages = popMessages ctx
|
||||||
let renderCtx : Layout.PageRenderContext = {
|
let renderCtx : Layout.PageRenderContext = {
|
||||||
IsLoggedOn = Option.isSome (tryUser ctx)
|
IsLoggedOn = Option.isSome (tryUser ctx)
|
||||||
CurrentUrl = ctx.Request.Path.Value
|
CurrentUrl = ctx.Request.Path.Value
|
||||||
PageTitle = pageTitle
|
PageTitle = pageTitle
|
||||||
Content = content
|
Content = content
|
||||||
|
Messages = messages
|
||||||
}
|
}
|
||||||
let! msgs = messages ctx
|
let renderFunc = if isHtmx ctx then Layout.partial else Layout.full
|
||||||
let! newCtx = task {
|
return! ctx.WriteHtmlViewAsync (renderFunc renderCtx)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render as a composable HttpHandler
|
/// Render as a composable HttpHandler
|
||||||
|
@ -197,23 +198,17 @@ module Helpers =
|
||||||
/// Require a user to be logged on for a route
|
/// Require a user to be logged on for a route
|
||||||
let requireUser = requiresAuthentication Error.notAuthorized
|
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
|
/// 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
|
do! saveSession ctx
|
||||||
let action =
|
let action =
|
||||||
if not (isNull url)
|
if Option.isSome (noneIfEmpty url)
|
||||||
&& not (url = "")
|
|
||||||
// "/" or "/foo" but not "//" or "/\"
|
// "/" or "/foo" but not "//" or "/\"
|
||||||
&& ( (url[0] = '/' && (url.Length = 1 || (url[1] <> '/' && url[1] <> '\\')))
|
&& ( (url[0] = '/' && (url.Length = 1 || (url[1] <> '/' && url[1] <> '\\')))
|
||||||
// "~/" or "~/foo"
|
// "~/" or "~/foo"
|
||||||
|| (url.Length > 1 && url[0] = '~' && url[1] = '/')) then
|
|| (url.Length > 1 && url[0] = '~' && url[1] = '/')) then
|
||||||
if isHtmx ctx then withHxRedirect url else redirectTo false url
|
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
|
return! action next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -445,7 +440,7 @@ module Continent =
|
||||||
module Home =
|
module Home =
|
||||||
|
|
||||||
// GET: /
|
// GET: /
|
||||||
let home : HttpHandler =
|
let home =
|
||||||
renderHandler "Welcome" Home.home
|
renderHandler "Welcome" Home.home
|
||||||
|
|
||||||
// GET: /how-it-works
|
// GET: /how-it-works
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
module JobsJobsJobs.ViewModels
|
module JobsJobsJobs.ViewModels
|
||||||
|
|
||||||
/// View model for the log on page
|
/// View model for the log on page
|
||||||
[<CLIMutable>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type LogOnViewModel =
|
type LogOnViewModel =
|
||||||
{ /// A message regarding an error encountered during a log on attempt
|
{ /// A message regarding an error encountered during a log on attempt
|
||||||
ErrorMessage : string option
|
ErrorMessage : string option
|
||||||
|
@ -19,7 +19,7 @@ type LogOnViewModel =
|
||||||
|
|
||||||
|
|
||||||
/// View model for the registration page
|
/// View model for the registration page
|
||||||
[<CLIMutable>]
|
[<CLIMutable; NoComparison; NoEquality>]
|
||||||
type RegisterViewModel =
|
type RegisterViewModel =
|
||||||
{ /// The user's first name
|
{ /// The user's first name
|
||||||
FirstName : string
|
FirstName : string
|
||||||
|
|
|
@ -17,6 +17,9 @@ type PageRenderContext =
|
||||||
|
|
||||||
/// The page content
|
/// The page content
|
||||||
Content : XmlNode
|
Content : XmlNode
|
||||||
|
|
||||||
|
/// User messages to be displayed
|
||||||
|
Messages : string list
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Append the application name to the page title
|
/// 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
|
/// Create a full view
|
||||||
let full ctx =
|
let full ctx =
|
||||||
html [ _lang "en" ] [
|
html [ _lang "en" ] [
|
||||||
|
@ -139,7 +160,10 @@ let full ctx =
|
||||||
yield! sideNavs ctx
|
yield! sideNavs ctx
|
||||||
div [ _class "jjj-main" ] [
|
div [ _class "jjj-main" ] [
|
||||||
yield! titleBars
|
yield! titleBars
|
||||||
main [ _class "jjj-content container-fluid" ] [ ctx.Content ]
|
main [ _class "jjj-content container-fluid" ] [
|
||||||
|
messages ctx
|
||||||
|
ctx.Content
|
||||||
|
]
|
||||||
htmlFoot
|
htmlFoot
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
@ -149,6 +173,13 @@ let full ctx =
|
||||||
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
_integrity "sha384-A3rJD856KowSb7dwlZdYEkO39Gagi7vIsF0jrRAoQmDKKtQBHUuLZ9AsSv4jD4Xa"
|
||||||
_crossorigin "anonymous" ] []
|
_crossorigin "anonymous" ] []
|
||||||
script [ _src "/script.js" ] []
|
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
|
yield! sideNavs ctx
|
||||||
div [ _class "jjj-main" ] [
|
div [ _class "jjj-main" ] [
|
||||||
yield! titleBars
|
yield! titleBars
|
||||||
main [ _class "jjj-content container-fluid" ] [ ctx.Content ]
|
main [ _class "jjj-content container-fluid" ] [
|
||||||
|
messages ctx
|
||||||
|
ctx.Content
|
||||||
|
]
|
||||||
htmlFoot
|
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
|
* @param {string} message The message to show
|
||||||
*/
|
*/
|
||||||
showToast (message) {
|
showAlert (message) {
|
||||||
const [level, msg] = message.split("|||")
|
const [level, msg] = message.split("|||")
|
||||||
|
|
||||||
let header
|
/** @type {HTMLTemplateElement} */
|
||||||
if (level !== "success") {
|
const alertTemplate = document.getElementById("alertTemplate")
|
||||||
const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
|
/** @type {HTMLDivElement} */
|
||||||
|
const alert = alertTemplate.content.firstElementChild.cloneNode(true)
|
||||||
|
alert.classList.add(`alert-${level === "error" ? "danger" : level}`)
|
||||||
|
|
||||||
header = document.createElement("div")
|
const prefix = level === "success" ? "" : `<strong>${level.toUpperCase()}: </strong>`
|
||||||
header.className = "toast-header"
|
alert.querySelector("p").innerHTML = `${prefix}${msg}`
|
||||||
header.innerHTML = heading(level === "warning" ? level : "error")
|
|
||||||
|
|
||||||
const close = document.createElement("button")
|
const alerts = document.getElementById("alerts")
|
||||||
close.type = "button"
|
alerts.appendChild(alert)
|
||||||
close.className = "btn-close"
|
alerts.scrollIntoView()
|
||||||
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()
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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) {
|
htmx.on("htmx:configRequest", function (evt) {
|
||||||
// Send the user's current time zone so that we can display local time
|
// Send the user's current time zone so that we can display local time
|
||||||
if (jjj.timeZone) {
|
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()
|
jjj.deriveTimeZone()
|
||||||
|
|
Loading…
Reference in New Issue
Block a user