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
14 changes: 10 additions & 4 deletions .github/workflows/build.yml → .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
name: Build
name: Build & Test

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, ready_for_review]

jobs:
publish:
name: Build packages
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- name: Install .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.x

- name: Checkout
uses: actions/checkout@v4
Expand All @@ -25,3 +25,9 @@ jobs:

- name: Build (Release)
run: dotnet build -c Release

- name: Test (Debug)
run: dotnet test -c Debug --no-build

- name: Test (Release)
run: dotnet test -c Release --no-build
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,9 +669,9 @@ Alternatively, you can set the entire response handling configuration directly a

```html
<htmx-config response-handling="@new [] {
new ResponseHandlingEntry { Code = "204", Swap = false },
new ResponseHandlingEntry { Code = "[23]..", Swap = true },
new ResponseHandlingEntry { Code = "[45]..", Swap = false, Error = true }
new ResponseHandlingConfig { Code = "204", Swap = false },
new ResponseHandlingConfig { Code = "[23]..", Swap = true },
new ResponseHandlingConfig { Code = "[45]..", Swap = false, Error = true }
}" />
```

Expand Down
1 change: 1 addition & 0 deletions Ramstack.HtmxToolkit.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@
</Folder>
<Project Path="src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj" />
<Project Path="samples/Ramstack.HtmxToolkit.Demo/Ramstack.HtmxToolkit.Demo.csproj" />
<Project Path="tests/Ramstack.HtmxToolkit.Tests/Ramstack.HtmxToolkit.Tests.csproj" />
</Solution>
12 changes: 10 additions & 2 deletions src/Ramstack.HtmxToolkit/PendingEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ namespace Ramstack.HtmxToolkit;
/// Accumulates htmx events per <see cref="HtmxTriggerTiming"/> for a single request,
/// deferring header serialization until the response is about to start.
/// </summary>
internal sealed class PendingEvents(HttpResponse response)
internal sealed class PendingEvents
{
private const string ProxyEventName = "_r_proxy";

private readonly HttpResponse _response;
private Dictionary<string, object>? _receive;
private Dictionary<string, object>? _afterSwap;
private Dictionary<string, object>? _afterSettle;

/// <summary>
/// Initializes a new instance of the <see cref="PendingEvents"/> class.
/// </summary>
/// <param name="response">The HTTP response to which the events belong.</param>
private PendingEvents(HttpResponse response) =>
_response = response;

/// <summary>
/// Adds the specified events to the pending set for the given <paramref name="timing"/>.
/// When a key already exists, the duplicate event is accumulated under a <c>_r_proxy</c> key and replayed client-side.
Expand Down Expand Up @@ -135,6 +143,6 @@ public static PendingEvents GetOrCreate(HttpResponse response)
private void SetHeader(string name, Dictionary<string, object>? events)
{
if (events is not null)
response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase);
_response.Headers[name] = JsonSerializer.Serialize(events, JsonOptions.CamelCase);
}
}
4 changes: 4 additions & 0 deletions src/Ramstack.HtmxToolkit/Ramstack.HtmxToolkit.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@
<None Remove="Assets\htmx-toolkit.min.js" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Ramstack.HtmxToolkit.Tests" />
</ItemGroup>

<ItemGroup>
<EmbeddedResource Include="Assets\htmx-toolkit.js" LogicalName="htmx-toolkit.js" />
<EmbeddedResource Include="Assets\htmx-toolkit.min.js" LogicalName="htmx-toolkit.min.js" />
Expand Down
6 changes: 3 additions & 3 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxConfigTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -432,12 +432,12 @@ public string? TriggerSpecsCache

/// <summary>
/// Gets or sets the default response handling behavior for HTTP response status codes.
/// Accepts an array of <see cref="ResponseHandlingEntry"/> objects that define
/// Accepts an array of <see cref="ResponseHandlingConfig"/> objects that define
/// how htmx should handle responses matching specific status code patterns.
/// </summary>
/// <remarks>Supported only in HTMX 2.x.</remarks>
[HtmlAttributeName("response-handling")]
public IList<ResponseHandlingEntry>? ResponseHandling
public IList<ResponseHandlingConfig>? ResponseHandling
{
get => _config.ResponseHandling;
set => _config.ResponseHandling = value;
Expand Down Expand Up @@ -565,7 +565,7 @@ internal sealed class HtmxConfigData
public bool? IgnoreTitle { get; set; }
public bool? ScrollIntoViewOnBoost { get; set; }
public string? TriggerSpecsCache { get; set; }
public IList<ResponseHandlingEntry>? ResponseHandling { get; set; }
public IList<ResponseHandlingConfig>? ResponseHandling { get; set; }
public bool? AllowNestedOobSwaps { get; set; }
public bool? HistoryRestoreAsHxRequest { get; set; }
public bool? ReportValidityOfForms { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,5 @@
namespace Ramstack.HtmxToolkit.TagHelpers;

[JsonSerializable(typeof(IDictionary<string, string>))]
[JsonSourceGenerationOptions(
WriteIndented = false,
GenerationMode = JsonSourceGenerationMode.Serialization)]
[JsonSourceGenerationOptions(WriteIndented = false, GenerationMode = JsonSourceGenerationMode.Serialization)]
internal partial class HtmxHeaderJsonSerializerContext : JsonSerializerContext;
18 changes: 13 additions & 5 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,24 @@ public sealed class HtmxHeaderTagHelper : TagHelper
/// Gets or sets the <c>hx-header</c> attribute values.
/// </summary>
[HtmlAttributeName(HeadersDictionaryName, DictionaryAttributePrefix = HeadersPrefix)]
public IDictionary<string, string> Headers { get; set; } = new Dictionary<string, string>();
public IDictionary<string, string> Headers
{
get => field ??= new Dictionary<string, string>();
set;
}

/// <inheritdoc />
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var headers = new HtmlString(
JsonSerializer.Serialize(Headers, HtmxHeaderJsonSerializerContext.Default.IDictionaryStringString));
if (Headers is { Count: > 0 })
{
var info = HtmxHeaderJsonSerializerContext.Default.IDictionaryStringString;
var headers = new HtmlString(JsonSerializer.Serialize(Headers, info));

output.Attributes.SetAttribute(
new TagHelperAttribute("hx-headers", headers, HtmlAttributeValueStyle.SingleQuotes));
output.Attributes.SetAttribute(
new TagHelperAttribute("hx-headers", headers, HtmlAttributeValueStyle.SingleQuotes)
);
}

return Task.CompletedTask;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
namespace Ramstack.HtmxToolkit.TagHelpers;

/// <summary>
/// Represents a single entry in the htmx response handling configuration.
/// Each entry defines how htmx should handle responses matching a specific HTTP status code pattern.
/// Represents the response handling configuration for responses matching a specific HTTP status code pattern.
/// </summary>
public sealed class ResponseHandlingEntry
public sealed class ResponseHandlingConfig
{
/// <summary>
/// Gets or sets a regular expression that will be tested against response status codes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,18 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </summary>
/// <remarks>Supported only in HTMX 2.x.</remarks>
[HtmlTargetElement("response-handling", ParentTag = "htmx-config", TagStructure = TagStructure.WithoutEndTag)]
public sealed class ResponseHandlingEntryTagHelper : TagHelper
public sealed class ResponseHandlingTagHelper : TagHelper
{
private readonly ResponseHandlingEntry _entry = new();
private readonly ResponseHandlingConfig _config = new();

/// <summary>
/// Gets or sets a regular expression that will be tested against response status codes.
/// </summary>
[HtmlAttributeName("code")]
public string? Code
{
get => _entry.Code;
set => _entry.Code = value;
get => _config.Code;
set => _config.Code = value;
}

/// <summary>
Expand All @@ -29,8 +29,8 @@ public string? Code
[HtmlAttributeName("swap")]
public bool? Swap
{
get => _entry.Swap;
set => _entry.Swap = value;
get => _config.Swap;
set => _config.Swap = value;
}

/// <summary>
Expand All @@ -39,8 +39,8 @@ public bool? Swap
[HtmlAttributeName("error")]
public bool? Error
{
get => _entry.Error;
set => _entry.Error = value;
get => _config.Error;
set => _config.Error = value;
}

/// <summary>
Expand All @@ -49,8 +49,8 @@ public bool? Error
[HtmlAttributeName("ignore-title")]
public bool? IgnoreTitle
{
get => _entry.IgnoreTitle;
set => _entry.IgnoreTitle = value;
get => _config.IgnoreTitle;
set => _config.IgnoreTitle = value;
}

/// <summary>
Expand All @@ -59,8 +59,8 @@ public bool? IgnoreTitle
[HtmlAttributeName("select")]
public string? Select
{
get => _entry.Select;
set => _entry.Select = value;
get => _config.Select;
set => _config.Select = value;
}

/// <summary>
Expand All @@ -69,8 +69,8 @@ public string? Select
[HtmlAttributeName("target")]
public string? Target
{
get => _entry.Target;
set => _entry.Target = value;
get => _config.Target;
set => _config.Target = value;
}

/// <summary>
Expand All @@ -79,22 +79,29 @@ public string? Target
[HtmlAttributeName("swap-override")]
public string? SwapOverride
{
get => _entry.SwapOverride;
set => _entry.SwapOverride = value;
get => _config.SwapOverride;
set => _config.SwapOverride = value;
}

/// <inheritdoc />
public override void Process(TagHelperContext context, TagHelperOutput output)
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value))
if (context.Items.TryGetValue(typeof(HtmxConfigTagHelper), out var value) && value is HtmxConfigTagHelper parent)
{
if (value is HtmxConfigTagHelper config)
{
config.ResponseHandling ??= new List<ResponseHandlingEntry>();
config.ResponseHandling.Add(_entry);
}
parent.ResponseHandling ??= new List<ResponseHandlingConfig>();
parent.ResponseHandling.Add(_config);

output.SuppressOutput();
return Task.CompletedTask;
}

output.SuppressOutput();
Error_NotNested();
return Task.CompletedTask;
}

private static void Error_NotNested()
{
const string Message = "The '<response-handling>' tag helper can only be used inside the '<htmx-config>' tag helper";
throw new InvalidOperationException(Message);
}
}
89 changes: 89 additions & 0 deletions tests/Ramstack.HtmxToolkit.Tests/EnumHelperTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
namespace Ramstack.HtmxToolkit.Tests;

[TestFixture]
public class EnumHelperTests
{
[TestCase(HtmxSwap.InnerHtml, "innerHTML")]
[TestCase(HtmxSwap.OuterHtml, "outerHTML")]
[TestCase(HtmxSwap.BeforeBegin, "beforebegin")]
[TestCase(HtmxSwap.AfterBegin, "afterbegin")]
[TestCase(HtmxSwap.BeforeEnd, "beforeend")]
[TestCase(HtmxSwap.AfterEnd, "afterend")]
[TestCase(HtmxSwap.Delete, "delete")]
[TestCase(HtmxSwap.None, "none")]
public void GetSwapValue_ReturnsExpectedString(HtmxSwap value, string expected) =>
Assert.That(value.GetSwapValue(), Is.EqualTo(expected));

[Test]
public void GetSwapValue_ReturnsNull_ForNullValue()
{
HtmxSwap? value = null;
Assert.That(value.GetSwapValue(), Is.Null);
}

[TestCase("innerHTML", HtmxSwap.InnerHtml)]
[TestCase("outerHTML", HtmxSwap.OuterHtml)]
[TestCase("beforebegin", HtmxSwap.BeforeBegin)]
[TestCase("afterbegin", HtmxSwap.AfterBegin)]
[TestCase("beforeend", HtmxSwap.BeforeEnd)]
[TestCase("afterend", HtmxSwap.AfterEnd)]
[TestCase("delete", HtmxSwap.Delete)]
[TestCase("none", HtmxSwap.None)]
public void ParseHtmxSwap_ParsesValue(string expression, HtmxSwap expected) =>
Assert.That(EnumHelper.ParseHtmxSwap(expression), Is.EqualTo(expected));

[Test]
public void ParseHtmxSwap_IgnoresModifiers() =>
Assert.That(EnumHelper.ParseHtmxSwap("innerHTML show:#content"), Is.EqualTo(HtmxSwap.InnerHtml));

[Test]
public void ParseHtmxSwap_IsCaseInsensitive() =>
Assert.That(EnumHelper.ParseHtmxSwap("OuterHTML"), Is.EqualTo(HtmxSwap.OuterHtml));

[Test]
public void ParseHtmxSwap_ReturnsNull_ForUnknownValue() =>
Assert.That(EnumHelper.ParseHtmxSwap("bogus"), Is.Null);

[Test]
public void ParseHtmxSwap_ReturnsNull_ForNullExpression() =>
Assert.That(EnumHelper.ParseHtmxSwap(null), Is.Null);

[TestCase(HttpVerb.Get, "get")]
[TestCase(HttpVerb.Head, "head")]
[TestCase(HttpVerb.Post, "post")]
[TestCase(HttpVerb.Put, "put")]
[TestCase(HttpVerb.Delete, "delete")]
[TestCase(HttpVerb.Connect, "connect")]
[TestCase(HttpVerb.Options, "options")]
[TestCase(HttpVerb.Trace, "trace")]
[TestCase(HttpVerb.Patch, "patch")]
public void GetHttpVerbValue_ReturnsExpectedString(HttpVerb value, string expected) =>
Assert.That(value.GetHttpVerbValue(), Is.EqualTo(expected));

[TestCase(HtmxBinaryType.Blob, "blob")]
[TestCase(HtmxBinaryType.ArrayBuffer, "arraybuffer")]
public void GetWsBinaryTypeValue_ReturnsExpectedString(HtmxBinaryType value, string expected) =>
Assert.That(value.GetWsBinaryTypeValue(), Is.EqualTo(expected));

[TestCase(HtmxScrollBehavior.Auto, "auto")]
[TestCase(HtmxScrollBehavior.Smooth, "smooth")]
[TestCase(HtmxScrollBehavior.Instant, "instant")]
public void GetScrollBehaviorValue_ReturnsExpectedString(HtmxScrollBehavior value, string expected) =>
Assert.That(value.GetScrollBehaviorValue(), Is.EqualTo(expected));

[Test]
public void GetSwapValue_ReturnsNone_ForUndefinedValue() =>
Assert.That(((HtmxSwap)999).GetSwapValue(), Is.EqualTo("none"));

[Test]
public void GetHttpVerbValue_ReturnsPatch_ForUndefinedValue() =>
Assert.That(((HttpVerb)999).GetHttpVerbValue(), Is.EqualTo("patch"));

[Test]
public void GetScrollBehaviorValue_ReturnsInstant_ForUndefinedValue() =>
Assert.That(((HtmxScrollBehavior)999).GetScrollBehaviorValue(), Is.EqualTo("instant"));

[Test]
public void GetWsBinaryTypeValue_ReturnsArrayBuffer_ForUndefinedValue() =>
Assert.That(((HtmxBinaryType)999).GetWsBinaryTypeValue(), Is.EqualTo("arraybuffer"));
}
Loading
Loading