Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
<button hx-get="/reports"
hx-request-timeout="5000"
hx-request-credentials="true"
hx-request-no-headers="false">
Load report
</button>
```

The following HTML will be generated:

```html
<button hx-get="/reports"
hx-request='{"timeout":5000,"credentials":true,"noHeaders":false}'>
Load report
</button>
```

### HtmxConfigTagHelper

As with `hx-headers`, configuring `htmx` settings requires a JSON representation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,35 @@
Choose a request type.
</div>
</section>

<section class="demo-card">
<div class="demo-card__header">
<h2><code>hx-request-timeout</code></h2>
<p>The handler responds after 1200 ms. The first request times out after 1000 ms; the second completes normally.</p>
</div>

<div class="demo-actions">
<button
hx-get
hx-page="/Examples/HtmxRequest"
hx-page-handler="Delayed"
hx-request-timeout="1000"
hx-on::timeout="document.querySelector('#timeout-result').textContent = 'Request timed out'"
hx-target="#timeout-result">
Request with 1000 ms timeout
</button>
<button
class="button button-secondary"
hx-get
hx-page="/Examples/HtmxRequest"
hx-page-handler="Delayed"
hx-target="#timeout-result">
Request without timeout
</button>
</div>

<div id="timeout-result" class="result">
Choose a request mode.
</div>
</section>
</article>
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ public IActionResult OnGetPartialOrFull() =>
Request.IsHtmxRequest()
? "Partial response (HTMX request detected via <code>IsHtmxRequest()</code>)"
: "Full page response. This wouldn't normally be a Content result, but demonstrates the check.");

public async Task<IActionResult> OnGetDelayedAsync()
{
await Task.Delay(1200);
return Content("<strong>Response received after 1200 ms.</strong>");
}
}
9 changes: 5 additions & 4 deletions samples/Ramstack.HtmxToolkit.Demo/wwwroot/css/demo.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -201,6 +197,11 @@ code {
font-size: 1.25rem;
}

.example-page {
display: grid;
row-gap: 2.5rem;
}

.eyebrow {
margin: 0;
color: var(--accent-strong);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
86 changes: 86 additions & 0 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using System.Text.Json;

using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace Ramstack.HtmxToolkit.TagHelpers;

/// <summary>
/// Represents a <see cref="TagHelper"/> implementation that applies the <c>hx-request</c> attribute to matching elements.
/// </summary>
/// <remarks>
/// <c>hx-request</c> is merge-inherited and can be placed on a parent element.
/// </remarks>
[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();

/// <summary>
/// Gets or sets the timeout for the request in milliseconds.
/// </summary>
/// <remarks>Supported in HTMX 1.x and 2.x.</remarks>
[HtmlAttributeName(RequestTimeoutAttributeName)]
public int? Timeout
{
get => _request.Timeout;
set => _request.Timeout = value;
}

/// <summary>
/// Gets or sets a value indicating whether the request sends credentials.
/// </summary>
/// <remarks>Supported in HTMX 1.x and 2.x.</remarks>
[HtmlAttributeName(RequestCredentialsAttributeName)]
public bool? Credentials
{
get => _request.Credentials;
set => _request.Credentials = value;
}

/// <summary>
/// Gets or sets a value indicating whether htmx strips all request headers.
/// </summary>
/// <remarks>Supported in HTMX 1.x and 2.x.</remarks>
[HtmlAttributeName(RequestNoHeadersAttributeName)]
public bool? NoHeaders
{
get => _request.NoHeaders;
set => _request.NoHeaders = value;
}

/// <inheritdoc />
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

/// <summary>
/// Represents the serializable request configuration data.
/// </summary>
internal sealed class HtmxRequestData
{
public int? Timeout { get; set; }
public bool? Credentials { get; set; }
public bool? NoHeaders { get; set; }
}

#endregion
}
59 changes: 59 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxRequestTagHelperTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading