Version 3 #40

Merged
danieljsummers merged 67 commits from version-2-3 into main 2023-02-02 23:47:28 +00:00
4 changed files with 78 additions and 71 deletions
Showing only changes of commit b74b871c2b - Show all commits

View File

@ -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

View File

@ -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

View File

@ -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
] ]
] ]

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 * @param {string} message The message to show
*/ */
showToast (message) { showAlert (message) {
const [level, msg] = message.split("|||") const [level, msg] = message.split("|||")
let header
if (level !== "success") {
const heading = typ => `<span class="me-auto"><strong>${typ.toUpperCase()}</strong></span>`
header = document.createElement("div")
header.className = "toast-header"
header.innerHTML = heading(level === "warning" ? level : "error")
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") /** @type {HTMLTemplateElement} */
body.className = "toast-body" const alertTemplate = document.getElementById("alertTemplate")
body.innerText = msg /** @type {HTMLDivElement} */
const alert = alertTemplate.content.firstElementChild.cloneNode(true)
const toastEl = document.createElement("div") alert.classList.add(`alert-${level === "error" ? "danger" : level}`)
toastEl.className = `toast bg-${level === "error" ? "danger" : level} text-white`
toastEl.setAttribute("role", "alert") const prefix = level === "success" ? "" : `<strong>${level.toUpperCase()}: </strong>`
toastEl.setAttribute("aria-live", "assertlive") alert.querySelector("p").innerHTML = `${prefix}${msg}`
toastEl.setAttribute("aria-atomic", "true")
toastEl.addEventListener("hidden.bs.toast", e => e.target.remove()) const alerts = document.getElementById("alerts")
if (header) toastEl.appendChild(header) alerts.appendChild(alert)
alerts.scrollIntoView()
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()