From b74b871c2b3c91998421619e82d622021e3f843c Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Mon, 9 Jan 2023 08:44:01 -0500 Subject: [PATCH] Display messages as alerts --- src/JobsJobsJobs/Server/Handlers.fs | 45 ++++++++-------- src/JobsJobsJobs/Server/ViewModels.fs | 4 +- src/JobsJobsJobs/Server/Views/Layout.fs | 38 +++++++++++++- src/JobsJobsJobs/Server/wwwroot/script.js | 62 ++++++++--------------- 4 files changed, 78 insertions(+), 71 deletions(-) diff --git a/src/JobsJobsJobs/Server/Handlers.fs b/src/JobsJobsJobs/Server/Handlers.fs index 059046b..3a6e63c 100644 --- a/src/JobsJobsJobs/Server/Handlers.fs +++ b/src/JobsJobsJobs/Server/Handlers.fs @@ -17,6 +17,14 @@ module Vue = open Giraffe.Htmx +[] +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 diff --git a/src/JobsJobsJobs/Server/ViewModels.fs b/src/JobsJobsJobs/Server/ViewModels.fs index 8f4aea0..4a32d57 100644 --- a/src/JobsJobsJobs/Server/ViewModels.fs +++ b/src/JobsJobsJobs/Server/ViewModels.fs @@ -2,7 +2,7 @@ module JobsJobsJobs.ViewModels /// View model for the log on page -[] +[] 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 -[] +[] type RegisterViewModel = { /// The user's first name FirstName : string diff --git a/src/JobsJobsJobs/Server/Views/Layout.fs b/src/JobsJobsJobs/Server/Views/Layout.fs index ac1d0a6..55a92ab 100644 --- a/src/JobsJobsJobs/Server/Views/Layout.fs +++ b/src/JobsJobsJobs/Server/Views/Layout.fs @@ -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 ] ] diff --git a/src/JobsJobsJobs/Server/wwwroot/script.js b/src/JobsJobsJobs/Server/wwwroot/script.js index 538f608..9b17741 100644 --- a/src/JobsJobsJobs/Server/wwwroot/script.js +++ b/src/JobsJobsJobs/Server/wwwroot/script.js @@ -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 => `${typ.toUpperCase()}` - - 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") - 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() + /** @type {HTMLTemplateElement} */ + const alertTemplate = document.getElementById("alertTemplate") + /** @type {HTMLDivElement} */ + const alert = alertTemplate.content.firstElementChild.cloneNode(true) + alert.classList.add(`alert-${level === "error" ? "danger" : level}`) + + const prefix = level === "success" ? "" : `${level.toUpperCase()}: ` + alert.querySelector("p").innerHTML = `${prefix}${msg}` + + 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()