diff --git a/src/Common/Common.fs b/src/Common/Common.fs index 1f08f4f..79500cc 100644 --- a/src/Common/Common.fs +++ b/src/Common/Common.fs @@ -103,3 +103,9 @@ module HxSwap = /// Extension [] let Upsert = "upsert" + + /// Specify that the target of the htmx request should be downloaded + /// This requires the hx-download extension (included in the htmax bundle) + /// Documentation + [] + let Download = "download" diff --git a/src/Htmx/Htmx.fs b/src/Htmx/Htmx.fs index 2b3d312..e4bbab6 100644 --- a/src/Htmx/Htmx.fs +++ b/src/Htmx/Htmx.fs @@ -30,6 +30,11 @@ type IHeaderDictionary with /// true if the request is for history restoration after a miss in the local history cache member this.HxHistoryRestoreRequest with get () = hdr this "HX-History-Restore-Request" |> Option.map bool.Parse + + /// true if the request has been fired by the preload extension + /// preload is part of the htmax htmx-plus-extensions bundle + member this.HxPreloaded + with get () = hdr this "HX-Preloaded" |> Option.map bool.Parse /// The user response to an hx-prompt [] @@ -40,6 +45,11 @@ type IHeaderDictionary with member this.HxRequest with get () = hdr this "HX-Request" |> Option.map bool.Parse + /// The ID of the request (WebSocket extension requests only) + /// hx-ws is part of the htmax htmx-plus-extensions bundle + member this.HxRequestId + with get () = hdr this "HX-Request-ID" + /// The request type sent by htmx /// member this.HxRequestType @@ -110,6 +120,14 @@ module Handlers = open Giraffe.Htmx.Common + /// Instruct htmx to download a response from another path / URL + /// The path or URL where the downloadable content is found + /// An HTTP handler with the HX-Download header set + /// This requires the client-side hx-download extension (included in the htmax bundle) + /// Documentation + let withHxDownload (path: string) : HttpHandler = + setHttpHeader "HX-Download" path + /// Instruct htmx to perform a client-side redirect for content /// The path where the content should be found /// An HTTP handler with the HX-Location header set diff --git a/src/Tests/Common.fs b/src/Tests/Common.fs index 3c318a6..a760e01 100644 --- a/src/Tests/Common.fs +++ b/src/Tests/Common.fs @@ -63,6 +63,9 @@ let swap = test "Upsert is correct" { Expect.equal HxSwap.Upsert "upsert" "Upsert swap value incorrect" } + test "Download is correct" { + Expect.equal HxSwap.Download "download" "Download swap value incorrect" + } ] /// All tests for this module diff --git a/src/Tests/Htmx.fs b/src/Tests/Htmx.fs index 5eeb409..abbd0ab 100644 --- a/src/Tests/Htmx.fs +++ b/src/Tests/Htmx.fs @@ -73,6 +73,30 @@ let dictExtensions = ctx.Request.Headers.HxHistoryRestoreRequest.Value "The header should have been false" } ] + testList "HxPreloaded" [ + test "succeeds when the header is not present" { + let ctx = Substitute.For() + ctx.Request.Headers.ReturnsForAnyArgs(HeaderDictionary()) |> ignore + Expect.isNone ctx.Request.Headers.HxPreloaded "There should not have been a header returned" + } + test "succeeds when the header is present and true" { + let ctx = Substitute.For() + let dic = HeaderDictionary() + dic.Add("HX-Preloaded", "true") + ctx.Request.Headers.ReturnsForAnyArgs dic |> ignore + Expect.isSome ctx.Request.Headers.HxPreloaded "There should be a header present" + Expect.isTrue ctx.Request.Headers.HxPreloaded.Value "The header should have been true" + } + // This is not a condition fired by the extension; the header will be either absent or true + test "succeeds when the header is present and false" { + let ctx = Substitute.For() + let dic = HeaderDictionary() + dic.Add("HX-Preloaded", "false") + ctx.Request.Headers.ReturnsForAnyArgs dic |> ignore + Expect.isSome ctx.Request.Headers.HxPreloaded "There should be a header present" + Expect.isFalse ctx.Request.Headers.HxPreloaded.Value "The header should have been false" + } + ] testList "HxRequest" [ test "succeeds when the header is not present" { let ctx = Substitute.For() @@ -96,6 +120,21 @@ let dictExtensions = Expect.isFalse ctx.Request.Headers.HxRequest.Value "The header should have been false" } ] + testList "HxRequestId" [ + test "succeeds when the header is not present" { + let ctx = Substitute.For() + ctx.Request.Headers.ReturnsForAnyArgs(HeaderDictionary()) |> ignore + Expect.isNone ctx.Request.Headers.HxRequestId "There should not have been a header returned" + } + test "succeeds when the header is present" { + let ctx = Substitute.For() + let dic = HeaderDictionary() + dic.Add("HX-Request-ID", "abcd1234") + ctx.Request.Headers.ReturnsForAnyArgs dic |> ignore + Expect.isSome ctx.Request.Headers.HxRequestId "There should be a header present" + Expect.equal ctx.Request.Headers.HxRequestId.Value "abcd1234" "The header value was incorrect" + } + ] testList "HxRequestType" [ test "succeeds when the header is not present" { let ctx = Substitute.For() @@ -253,6 +292,14 @@ let next (ctx: HttpContext) = Task.FromResult(Some ctx) /// Tests for the HttpHandler functions provided in the Handlers module let handlers = testList "HandlerTests" [ + testTask "withHxDownload succeeds" { + let ctx = Substitute.For() + let dic = HeaderDictionary() + ctx.Response.Headers.ReturnsForAnyArgs dic |> ignore + let! _ = withHxDownload "/files/stuff.pdf" next ctx + Expect.isTrue (dic.ContainsKey "HX-Download") "The HX-Download header should be present" + Expect.equal dic["HX-Download"].[0] "/files/stuff.pdf" "The HX-Download value was incorrect" + } testTask "withHxLocation succeeds" { let ctx = Substitute.For() let dic = HeaderDictionary() diff --git a/src/Tests/ViewEngineMax.fs b/src/Tests/ViewEngineMax.fs index 17fa3d7..35ce814 100644 --- a/src/Tests/ViewEngineMax.fs +++ b/src/Tests/ViewEngineMax.fs @@ -3,6 +3,118 @@ module ViewEngineMax open Expecto open Giraffe.ViewEngine.Htmax +/// Tests for the HxBrowserIndicatorConfigItem type +let hxBrowserIndicatorConfigItem = + testList "HxBrowserIndicatorConfigItem" [ + testList "ToHcon" [ + test "succeeds for BoostBrowserIndicator" { + Expect.equal + ((BoostBrowserIndicator false).ToHcon()) + "boostBrowserIndicator:false" + "The HCON value was incorrect" + } + ] + ] + +/// Tests for the HxPreloadConfigItem type +let hxPreloadConfigItem = + testList "HxPreloadConfigItem" [ + testList "ToHcon" [ + test "succeeds for AutoBoost" { + Expect.equal ((AutoBoost false).ToHcon()) "autoBoost:false" "The HCON value was incorrect" + } + test "succeeds for BoostEvent" { + Expect.equal ((BoostEvent "mouseover").ToHcon()) "boostEvent:mouseover" "The HCON value was incorrect" + } + test "succeeds for BoostTimeout" { + Expect.equal ((BoostTimeout 30_000).ToHcon()) "boostTimeout:30000" "The HCON value was incorrect" + } + ] + ] + +/// Tests for the HxSseWsConfigItem type +let hxSseWsConfigItem = + testList "HxSseWsConfigItem" [ + testList "ToHcon" [ + test "succeeds for Reconnect" { + Expect.equal ((Reconnect true).ToHcon()) "reconnect:true" "The HCON value was incorrect" + } + testList "ReconnectDelay" [ + test "succeeds for numeric value" { + Expect.equal ((ReconnectDelay "3000").ToHcon()) "reconnectDelay:3000" "The HCON value was incorrect" + } + test "succeeds for value with units" { + Expect.equal ((ReconnectDelay "3s").ToHcon()) "reconnectDelay:3s" "The HCON value was incorrect" + } + ] + testList "ReconnectMaxDelay" [ + test "succeeds for numeric value" { + Expect.equal + ((ReconnectMaxDelay "30000").ToHcon()) "reconnectMaxDelay:30000" "The HCON value was incorrect" + } + test "succeeds for value with units" { + Expect.equal + ((ReconnectMaxDelay "50s").ToHcon()) "reconnectMaxDelay:50s" "The HCON value was incorrect" + } + ] + testList "ReconnectMaxAttempts" [ + test "succeeds for negative 1" { + Expect.equal + ((ReconnectMaxAttempts -1).ToHcon()) + "reconnectMaxAttempts:Infinity" + "The HCON value was incorrect" + } + test "succeeds for zero" { + Expect.equal + ((ReconnectMaxAttempts 0).ToHcon()) "reconnectMaxAttempts:0" "The HCON value was incorrect" + } + test "succeeds for positive number" { + Expect.equal + ((ReconnectMaxAttempts 7).ToHcon()) "reconnectMaxAttempts:7" "The HCON value was incorrect" + } + ] + test "succeeds for ReconnectJitter" { + Expect.equal ((ReconnectJitter 0.7).ToHcon()) "reconnectJitter:0.7" "The HCON value was incorrect" + } + test "succeeds for PauseOnBackground" { + Expect.equal + ((PauseOnBackground false).ToHcon()) "pauseOnBackground:false" "The HCON value was incorrect" + } + test "succeeds for PendingRequestTtl" { + Expect.equal ((PendingRequestTtl 5000).ToHcon()) "pendingRequestTTL:5000" "The HCON value was incorrect" + } + ] + ] + +/// Tests for the HxConfig module +let hxConfig = + testList "HxConfig" [ + test "hxBrowserIndicatorConfig succeeds" { + Expect.equal + (hxBrowserIndicatorConfig [ BoostBrowserIndicator true ]) + "browser-indicator.boostBrowserIndicator:true" + "Config settings not correct" + } + test "hxPreloadConfig succeeds" { + Expect.equal + (hxPreloadConfig [ AutoBoost true; BoostTimeout 3_000 ]) + "preload.autoBoost:true preload.boostTimeout:3000" + "Config settings not correct" + } + test "hxSseConfig succeeds" { + Expect.equal + (hxSseConfig [ ReconnectDelay "5s"; PauseOnBackground false ]) + "sse.reconnectDelay:5s sse.pauseOnBackground:false" + "Config settings not correct" + } + test "hxWsConfig succeeds" { + Expect.equal + (hxWsConfig [ Reconnect false; PendingRequestTtl 40_000 ]) + "ws.reconnect:false ws.pendingRequestTTL:40000" + "Config settings not correct" + } + ] + /// Tests for the HxEvent module let hxEvent = testList "HxEvent" [ @@ -110,6 +222,37 @@ let hxEvent = (BeforeWsRequest.ToHxOnString()) "before:ws:request" "BeforeWsRequest hx-on event name not correct" } ] + testList "DownloadComplete" [ + test "ToString succeeds" { + Expect.equal (string DownloadComplete) "downloadComplete" "DownloadComplete event name not correct" + } + test "ToHxOnString succeeds" { + Expect.equal + (DownloadComplete.ToHxOnString()) + "download:complete" + "DownloadComplete hx-on event name not correct" + } + ] + testList "DownloadProgress" [ + test "ToString succeeds" { + Expect.equal (string DownloadProgress) "downloadProgress" "DownloadProgress event name not correct" + } + test "ToHxOnString succeeds" { + Expect.equal + (DownloadProgress.ToHxOnString()) + "download:progress" + "DownloadProgress hx-on event name not correct" + } + ] + testList "DownloadStart" [ + test "ToString succeeds" { + Expect.equal (string DownloadStart) "downloadStart" "DownloadStart event name not correct" + } + test "ToHxOnString succeeds" { + Expect.equal + (DownloadStart.ToHxOnString()) "download:start" "DownloadStart hx-on event name not correct" + } + ] testList "SseClose" [ test "ToString succeeds" { Expect.equal (string SseClose) "sseClose" "SseClose event name not correct" @@ -138,10 +281,25 @@ let shouldRender expected node = let attributes = testList "Attributes" [ + test "_hxBrowserIndicator succeeds" { + a [ _hxBrowserIndicator ] [] |> shouldRender """""" + } + test "_hxLive succeeds" { + main [ _hxLive "q().doStuff()" ] [] |> shouldRender """
""" + } test "_hxOnMax succeeds" { body [ _hxOnMax SseClose "alert(done)" ] [] |> shouldRender """""" } + testList "_hxPreload" [ + test "succeeds when given an event" { + blockquote [ _hxPreload (Some "focus") ] [] + |> shouldRender """
""" + } + test "succeeds when not given an event" { + em [ _hxPreload None ] [] |> shouldRender """""" + } + ] test "_hxSseClose succeeds" { form [ _hxSseClose "fin" ] [] |> shouldRender """
""" } @@ -149,6 +307,9 @@ let attributes = div [ _hxSseConnect "/path/to/resource" ] [] |> shouldRender """
""" } + test "_hxTargets succeeds" { + dl [ _hxTargets ".ephemeral" ] [] |> shouldRender """
""" + } test "_hxWsConnect succeeds" { p [ _hxWsConnect "/over/here" ] [] |> shouldRender """

""" } @@ -165,6 +326,10 @@ let attributes = let allTests = testList "ViewEngine.Htmax" [ + hxBrowserIndicatorConfigItem + hxPreloadConfigItem + hxSseWsConfigItem + hxConfig hxEvent attributes ] diff --git a/src/ViewEngine.Htmx/Htmax.fs b/src/ViewEngine.Htmx/Htmax.fs index 6df6d75..3453652 100644 --- a/src/ViewEngine.Htmx/Htmax.fs +++ b/src/ViewEngine.Htmx/Htmax.fs @@ -1,6 +1,104 @@ /// Types and functions supporting htmax-bundled extension attributes in Giraffe View Engine module Giraffe.ViewEngine.Htmax +/// Items which can be configured for the hx-browser-indicator extension +/// Documentation +type HxBrowserIndicatorConfigItem = + /// Whether to show the browser's native loading indicator for hx-boosted links + | BoostBrowserIndicator of bool + + /// Get the HCON representation of this configuration value + member this.ToHcon() = + match this with + | BoostBrowserIndicator it -> "boostBrowserIndicator:" + (string it).ToLowerInvariant() + + +/// Items which can be configured for the hx-preload extension +/// Documentation +type HxPreloadConfigItem = + /// Whether items subject to hx-boost are automatically preloaded (default true) + | AutoBoost of bool + + /// The event to use for hx-boost auto-preloaded content (default "mousedown") + | BoostEvent of string + + /// The timeout (in ms) for hx-boost auto-preloaded requests (default 50_000) + | BoostTimeout of int + + /// Get the HCON representation of this configuration value + member this.ToHcon() = + match this with + | AutoBoost it -> "autoBoost:" + (string it).ToLowerInvariant() + | BoostEvent it -> $"boostEvent:{it}" + | BoostTimeout it -> $"boostTimeout:{it}" + + +/// Items which can be configured for the hx-sse or hx-ws extensions +/// SSE Documentation +/// WebSockets Documentation +type HxSseWsConfigItem = + /// Whether to automatically reconnect on stream end (default true) + | Reconnect of bool + + /// Delay for reconnect attempts (ms, or numbers with units "1s", "2m"; default "500") + | ReconnectDelay of string + + /// Maximum delay for reconnect attempts (ms, or numbers with units "1s, "2m"; default "60000") + | ReconnectMaxDelay of string + + /// Reconnect maximum attempts (use -1 for Infinity; default Infinity) + | ReconnectMaxAttempts of int + + /// Jitter to use when reconnecting (decimal between 0.0 and 1.0; default 0.3) + | ReconnectJitter of double + + /// Whether to pause when the current tab is in the background (default true) + | PauseOnBackground of bool + + /// Time-to-Live (TTL) for pending requests (hx-ws only) + | PendingRequestTtl of int + + /// Get the HCON representation of this configuration value + member this.ToHcon() = + match this with + | Reconnect it -> "reconnect:" + (string it).ToLowerInvariant() + | ReconnectDelay it -> $"reconnectDelay:{it}" + | ReconnectMaxDelay it -> $"reconnectMaxDelay:{it}" + | ReconnectMaxAttempts it -> "reconnectMaxAttempts:" + if it = -1 then "Infinity" else string it + | ReconnectJitter it -> $"reconnectJitter:{it}" + | PauseOnBackground it -> "pauseOnBackground:" + (string it).ToLowerInvariant() + | PendingRequestTtl it -> $"pendingRequestTTL:{it}" + + +/// Helpers for generating extension configuration parameters +[] +module HxConfig = + + /// Create an HCON section suitable for hx-config + let private hconSettings title values = + values |> Seq.map (sprintf "%s.%s" title) |> String.concat " " + + /// Generate configuration items for preload suitable for hx-config + /// The configuration items + let hxBrowserIndicatorConfig (items: HxBrowserIndicatorConfigItem seq) = + items |> Seq.map _.ToHcon() |> hconSettings "browser-indicator" + + /// Generate configuration items for preload suitable for hx-config + /// The configuration items + let hxPreloadConfig (items: HxPreloadConfigItem seq) = + items |> Seq.map _.ToHcon() |> hconSettings "preload" + + /// Generate configuration items for SSE suitable for hx-config + /// The configuration items + let hxSseConfig (items: HxSseWsConfigItem seq) = + items |> Seq.map _.ToHcon() |> hconSettings "sse" + + /// Generate configuration items for WebSockets suitable for hx-config + /// The configuration items + let hxWsConfig (items: HxSseWsConfigItem seq) = + items |> Seq.map _.ToHcon() |> hconSettings "ws" + + /// The events recognized by htmax-bundled extensions [] type HxEvent = @@ -29,6 +127,15 @@ type HxEvent = /// Fired before a WebSocket connection is established (cancelable) | BeforeWsConnection + /// Fired when a download handled by hx-download is complete + | DownloadComplete + + /// Fired when a download handled by hx-download is complete + | DownloadProgress + + /// Fired when a chunk is received for a download handled by hx-download + | DownloadStart + /// Fired before a received WebSocket message is processed (cancelable) | BeforeWsMessage @@ -53,6 +160,9 @@ type HxEvent = BeforeWsConnection, ("beforeWsConnection", "before:ws:connection") BeforeWsMessage, ("beforeWsMessage", "before:ws:message") BeforeWsRequest, ("beforeWsRequest", "before:ws:request") + DownloadComplete, ("downloadComplete", "download:complete") + DownloadProgress, ("downloadProgress", "download:progress") + DownloadStart, ("downloadStart", "download:start") SseClose, ("sseClose", "sse:close") SseError, ("sseError", "sse:error") ] @@ -68,6 +178,19 @@ type HxEvent = [] module HtmaxAttrs = + /// Display the browser's native loading indicator when an htmx request is in flight + /// A configured hx-browser-indicator attribute + /// Documentation + let _hxBrowserIndicator = + attr "hx-browser-indicator" "true" + + /// Run script each time the DOM changes + /// The script to be run each time the DOM changes (may use q helper) + /// A configured hx-live attribute + /// Documentation + let _hxLive script = + attr "hx-live" script + /// Generate an hx-on:htmx event for an htmax-bundled extension event /// The HxEvent to be handled /// The script to be executed when the event occurs @@ -76,6 +199,14 @@ module HtmaxAttrs = let _hxOnMax (event: HxEvent) handler = Htmx.HtmxAttrs._hxOnEvent $"htmx:{event.ToHxOnString()}" handler + /// Identify a resource as one that should be preloaded + /// The DOM event or htmx trigger which should cause the preload (optional; default behavior is + /// preloading on mousedown) + /// A configured hx-preload attribute + /// Documentation + let _hxPreload evt = + match evt with Some it -> attr "hx-preload" it | None -> flag "hx-preload" + /// Define an SSE message which indicates the connection should be closed /// The text of the message which indicates the SSE connection should be closed /// A configured hx-sse:close attribute @@ -90,6 +221,13 @@ module HtmaxAttrs = let _hxSseConnect url = attr "hx-sse:connect" url + /// Replace multiple targets with the same content + /// The CSS selector to identify targets for the replacement + /// A configured hx-targets attribute + /// Documentation + let _hxTargets selector = + attr "hx-targets" selector + /// Connect to a WebSocket URL /// The URL to which a WebSocket connection should be established /// A configured hx-ws:connect attribute diff --git a/src/ViewEngine.Htmx/Htmx.fs b/src/ViewEngine.Htmx/Htmx.fs index e3d1fae..0d8e03c 100644 --- a/src/ViewEngine.Htmx/Htmx.fs +++ b/src/ViewEngine.Htmx/Htmx.fs @@ -93,7 +93,7 @@ type HxEvent = | [] AfterSseMessage /// Triggered after a Server Sent Events (SSE) stream is closed - | [] AfterSseStream + | [] AfterSseStream /// Triggered after new content has been swapped in | AfterSwap @@ -144,7 +144,7 @@ type HxEvent = | [] BeforeSseReconnect /// Triggered before a Server Sent Events (SSE) stream is opened - | [] BeforeSseStream + | [] BeforeSseStream /// Triggered before a swap is done, allows you to configure the swap | BeforeSwap