diff --git a/README.md b/README.md index 4cd482c..3ab9555 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Provides HTMX integration for ASP.NET Core applications. * [TagHelpers](#taghelpers) * [HtmxUrlTagHelper](#htmxurltaghelper) * [HtmxHeaderTagHelper](#htmxheadertaghelper) + * [HtmxRequestTagHelper](#htmxrequesttaghelper) * [HtmxConfigTagHelper](#htmxconfigtaghelper) * [Response Handling Configuration](#response-handling-configuration) * [Toolkit Script](#toolkit-script) @@ -437,11 +438,12 @@ allowing you to flexibly configure the `swap` header you need. ## TagHelpers -The library provides 3 tag helpers: +The library provides 4 tag helpers: * `HtmxUrlTagHelper` * `HtmxHeaderTagHelper` * `HtmxConfigTagHelper` +* `HtmxRequestTagHelper` To make them available in your project, add the `@addTagHelper` directive in the Razor view. @@ -580,6 +582,29 @@ you can assign them to the `hx-all-headers` attribute: The `HtmxHeaderTagHelper` will take care of all the remaining work regarding JSON serialization and escaping. +### HtmxRequestTagHelper + +The `HtmxRequestTagHelper` configures the htmx request options supported by HTMX 1.x and 2.x. +Use the typed `hx-request-*` attributes instead of writing JSON manually: + +```html + +``` + +The following HTML will be generated: + +```html + +``` + ### HtmxConfigTagHelper As with `hx-headers`, configuring `htmx` settings requires a JSON representation. diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml index d29a421..fb412ae 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml @@ -38,4 +38,35 @@ Choose a request type. + +
+
+

hx-request-timeout

+

The handler responds after 1200 ms. The first request times out after 1000 ms; the second completes normally.

+
+ +
+ + +
+ +
+ Choose a request mode. +
+
diff --git a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs index 5af5f37..8e09def 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs +++ b/samples/Ramstack.HtmxToolkit.Demo/Pages/Examples/HtmxRequest.cshtml.cs @@ -10,4 +10,10 @@ public IActionResult OnGetPartialOrFull() => Request.IsHtmxRequest() ? "Partial response (HTMX request detected via IsHtmxRequest())" : "Full page response. This wouldn't normally be a Content result, but demonstrates the check."); + + public async Task OnGetDelayedAsync() + { + await Task.Delay(1200); + return Content("Response received after 1200 ms."); + } } diff --git a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css index ee545b5..4d76be2 100644 --- a/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css +++ b/samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css @@ -185,10 +185,6 @@ code { padding: 2rem clamp(1.25rem, 4vw, 4rem) 5rem; } -.page-heading { - margin-bottom: 2.5rem; -} - .page-heading h1 { margin: 0.25rem 0 0.75rem; font-size: clamp(1.8rem, 5vw, 3rem); @@ -201,6 +197,11 @@ code { font-size: 1.25rem; } +.example-page { + display: grid; + row-gap: 2.5rem; +} + .eyebrow { margin: 0; color: var(--accent-strong); diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs new file mode 100644 index 0000000..9db20e7 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestJsonSerializerContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace Ramstack.HtmxToolkit.TagHelpers; + +[JsonSourceGenerationOptions( + WriteIndented = false, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + GenerationMode = JsonSourceGenerationMode.Serialization)] +[JsonSerializable(typeof(HtmxRequestTagHelper.HtmxRequestData))] +internal partial class HtmxRequestJsonSerializerContext : JsonSerializerContext; diff --git a/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs new file mode 100644 index 0000000..bb2b429 --- /dev/null +++ b/src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs @@ -0,0 +1,86 @@ +using System.Text.Json; + +using Microsoft.AspNetCore.Html; +using Microsoft.AspNetCore.Razor.TagHelpers; + +namespace Ramstack.HtmxToolkit.TagHelpers; + +/// +/// Represents a implementation that applies the hx-request attribute to matching elements. +/// +/// +/// hx-request is merge-inherited and can be placed on a parent element. +/// +[HtmlTargetElement(Attributes = RequestTimeoutAttributeName)] +[HtmlTargetElement(Attributes = RequestCredentialsAttributeName)] +[HtmlTargetElement(Attributes = RequestNoHeadersAttributeName)] +public sealed class HtmxRequestTagHelper : TagHelper +{ + private const string RequestTimeoutAttributeName = "hx-request-timeout"; + private const string RequestCredentialsAttributeName = "hx-request-credentials"; + private const string RequestNoHeadersAttributeName = "hx-request-no-headers"; + + private readonly HtmxRequestData _request = new(); + + /// + /// Gets or sets the timeout for the request in milliseconds. + /// + /// Supported in HTMX 1.x and 2.x. + [HtmlAttributeName(RequestTimeoutAttributeName)] + public int? Timeout + { + get => _request.Timeout; + set => _request.Timeout = value; + } + + /// + /// Gets or sets a value indicating whether the request sends credentials. + /// + /// Supported in HTMX 1.x and 2.x. + [HtmlAttributeName(RequestCredentialsAttributeName)] + public bool? Credentials + { + get => _request.Credentials; + set => _request.Credentials = value; + } + + /// + /// Gets or sets a value indicating whether htmx strips all request headers. + /// + /// Supported in HTMX 1.x and 2.x. + [HtmlAttributeName(RequestNoHeadersAttributeName)] + public bool? NoHeaders + { + get => _request.NoHeaders; + set => _request.NoHeaders = value; + } + + /// + public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output) + { + if (Timeout is not null || Credentials is not null || NoHeaders is not null) + { + var request = new HtmlString( + JsonSerializer.Serialize(_request, HtmxRequestJsonSerializerContext.Default.HtmxRequestData)); + + output.Attributes.SetAttribute( + new TagHelperAttribute("hx-request", request, HtmlAttributeValueStyle.SingleQuotes)); + } + + return Task.CompletedTask; + } + + #region Inner type: HtmxRequestData + + /// + /// Represents the serializable request configuration data. + /// + internal sealed class HtmxRequestData + { + public int? Timeout { get; set; } + public bool? Credentials { get; set; } + public bool? NoHeaders { get; set; } + } + + #endregion +} diff --git a/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs new file mode 100644 index 0000000..c66726b --- /dev/null +++ b/tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs @@ -0,0 +1,59 @@ +namespace Ramstack.HtmxToolkit.Tests; + +[TestFixture] +public class HtmxRequestTagHelperTests +{ + [Test] + public async Task ProcessAsync_SerializesRequestConfiguration() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = new HtmxRequestTagHelper + { + Timeout = 500, + Credentials = true, + NoHeaders = false + }; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-request"]; + + Assert.That(attribute, Is.Not.Null); + + var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + Assert.That(json["timeout"].GetInt32(), Is.EqualTo(500)); + Assert.That(json["credentials"].GetBoolean(), Is.True); + Assert.That(json["noHeaders"].GetBoolean(), Is.False); + } + + [Test] + public async Task ProcessAsync_OmitsUnsetProperties() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = new HtmxRequestTagHelper + { + Timeout = 500 + }; + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + var attribute = output.Attributes["hx-request"]; + + Assert.That(attribute, Is.Not.Null); + + var json = JsonHelper.ParseJson(attribute!.Value.ToString()!); + + Assert.That(json["timeout"].GetInt32(), Is.EqualTo(500)); + Assert.That(json.ContainsKey("credentials"), Is.False); + Assert.That(json.ContainsKey("noHeaders"), Is.False); + } + + [Test] + public async Task ProcessAsync_OmitsUnsetConfiguration() + { + var output = TestHelper.CreateTagHelperOutput(); + var helper = new HtmxRequestTagHelper(); + + await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output); + + Assert.That(output.Attributes["hx-request"], Is.Null); + } +}