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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ The fluent API supports:
- Client navigation with `Location`, `Redirect`, `PushUrl`, and `ReplaceUrl`.
- Swap control with `Reswap`, `Retarget`, and `Reselect`.
- Page refresh with `Refresh`.
- Client events with `TriggerEvent` and `TriggerEvents`.
- Client events with `TriggerEvent`.

The same API works in Minimal API handlers:

Expand Down
2 changes: 0 additions & 2 deletions docs/articles/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,6 @@ Response.Htmx(htmx => htmx.TriggerEvent(
</aside>
```

Use `TriggerEvents` to add several event/detail pairs. Multiple Toolkit calls for the same response accumulate pending events.

HTMX 1.x and 2.x distinguish receive, after-swap, and after-settle response headers.
HTMX 4.x delivers these Toolkit events through `HX-Trigger` when the request completes, after the swap when one occurs.
See [Version compatibility](version-compatibility.md).
Expand Down
27 changes: 6 additions & 21 deletions src/Ramstack.HtmxToolkit/HtmxResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,25 +228,9 @@ public HtmxResponse TriggerEvent(string eventName, object detail, HtmxTriggerTim
return TriggerEventImpl(this, eventName, detail, timing);

static HtmxResponse TriggerEventImpl(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing) =>
AddEvents(response, new Dictionary<string, object> { [eventName] = detail }, timing);
AddEvent(response, eventName, detail, timing);
}

/// <summary>
/// Adds client-side events to the response header selected by <paramref name="timing" />.
/// </summary>
/// <remarks>
/// In HTMX 4.x, every <see cref="HtmxTriggerTiming" /> value is emitted through <c>HX-Trigger</c>
/// and runs when the request completes (after the swap whenever one is performed).
/// See <see href="https://github.com/bigskysoftware/htmx/pull/3900">PR #3900</see>.
/// </remarks>
/// <param name="events">The event names and their associated details.</param>
/// <param name="timing">The event timing. Defaults to <see cref="HtmxTriggerTiming.Receive" />.</param>
/// <returns>
/// The current <see cref="HtmxResponse" /> instance.
/// </returns>
public HtmxResponse TriggerEvents(IReadOnlyDictionary<string, object> events, HtmxTriggerTiming timing = HtmxTriggerTiming.Receive) =>
AddEvents(this, events, timing);

/// <summary>
/// Sets a response header and returns the response wrapper for fluent chaining.
/// </summary>
Expand All @@ -263,17 +247,18 @@ private static HtmxResponse SetHeader(HtmxResponse response, string key, string
}

/// <summary>
/// Adds pending client-side events and returns the response wrapper for fluent chaining.
/// Adds a pending client-side event and returns the response wrapper for fluent chaining.
/// </summary>
/// <param name="response">The response wrapper to update.</param>
/// <param name="events">The event names and their associated details.</param>
/// <param name="eventName">The event name.</param>
/// <param name="detail">The event detail.</param>
/// <param name="timing">The time at which to trigger the events.</param>
/// <returns>
/// The updated response wrapper.
/// </returns>
private static HtmxResponse AddEvents(HtmxResponse response, IReadOnlyDictionary<string, object> events, HtmxTriggerTiming timing)
private static HtmxResponse AddEvent(HtmxResponse response, string eventName, object detail, HtmxTriggerTiming timing)
{
PendingEvents.GetOrCreate(response._response).AddEvents(timing, events);
PendingEvents.GetOrCreate(response._response).AddEvent(timing, eventName, detail);
return response;
}

Expand Down
14 changes: 6 additions & 8 deletions src/Ramstack.HtmxToolkit/PendingEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,14 @@ private PendingEvents(HttpResponse response) =>
(_response, _version) = (response, GetTargetVersion(response));

/// <summary>
/// Adds the specified events to the pending set for <paramref name="timing" />.
/// Adds the specified event to the pending set for <paramref name="timing" />.
/// When an event name already exists, the duplicate is stored under the
/// <c>rs:events</c> key for client-side replay.
/// </summary>
/// <param name="timing">The time at which to trigger the events.</param>
/// <param name="events">The event names and their associated details.</param>
public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, object> events)
/// <param name="eventName">The event name.</param>
/// <param name="detail">The event detail.</param>
public void AddEvent(HtmxTriggerTiming timing, string eventName, object detail)
{
timing = NormalizeTiming(timing);

Expand All @@ -49,18 +50,15 @@ public void AddEvents(HtmxTriggerTiming timing, IReadOnlyDictionary<string, obje
_ => _afterSettle ??= new SmallDictionary<string, object>(StringComparer.Ordinal)
};

foreach (var (k, v) in events)
if (!current.TryAdd(eventName, detail))
{
if (current.TryAdd(k, v))
continue;

if (!current.TryGetValue(ProxyEventName, out var value) || value is not List<KeyValuePair<string, object>> collection)
{
collection = [];
current[ProxyEventName] = collection;
}

collection.Add(new KeyValuePair<string, object>(k, v));
collection.Add(new KeyValuePair<string, object>(eventName, detail));
}
}

Expand Down
14 changes: 0 additions & 14 deletions tests/Ramstack.HtmxToolkit.Tests/HtmxResponseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,20 +222,6 @@ public void TriggerEvent_WithComplexDetail_SerializesJson()
Assert.That(json["message"].GetProperty("text").GetString(), Is.EqualTo("hi"));
}

[Test]
public void TriggerEvents_SetsMultipleEvents()
{
var context = TestHelper.CreateHtmxRequestContext();
context.Response.Htmx(r => r.TriggerEvents(
new Dictionary<string, object> { ["a"] = 1, ["b"] = 2 },
HtmxTriggerTiming.AfterSwap));

var events = context.Response.GetHtmxHeaders().TriggerAfterSwap!;
Assert.That(events.Count, Is.EqualTo(2));
Assert.That(events["a"], Is.EqualTo(1));
Assert.That(events["b"], Is.EqualTo(2));
}

[Test]
public void TriggerEvent_Htmx4_AddsEveryTimingToReceiveTrigger()
{
Expand Down
62 changes: 26 additions & 36 deletions tests/Ramstack.HtmxToolkit.Tests/PendingEventsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@ namespace Ramstack.HtmxToolkit.Tests;
public class PendingEventsTests
{
[Test]
public void AddEvents_StoresDistinctKeys()
public void AddEvent_StoresDistinctKeys()
{
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(
HtmxTriggerTiming.Receive,
CreateDictionary(("a", 1), ("b", 2)));
pending.AddEvent(HtmxTriggerTiming.Receive, "a", 1);
pending.AddEvent(HtmxTriggerTiming.Receive, "b", 2);

var events = pending.GetEvents(HtmxTriggerTiming.Receive)!;

Expand All @@ -23,18 +22,13 @@ public void AddEvents_StoresDistinctKeys()
}

[Test]
public void AddEvents_AccumulatesDuplicateKeys_UnderProxy()
public void AddEvent_AccumulatesDuplicateKeys_UnderProxy()
{
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(
HtmxTriggerTiming.Receive,
CreateDictionary(("message", "first")));

pending.AddEvents(
HtmxTriggerTiming.Receive,
CreateDictionary(("message", "second")));
pending.AddEvent(HtmxTriggerTiming.Receive, "message", "first");
pending.AddEvent(HtmxTriggerTiming.Receive, "message", "second");

var events = pending.GetEvents(HtmxTriggerTiming.Receive)!;

Expand All @@ -44,14 +38,14 @@ public void AddEvents_AccumulatesDuplicateKeys_UnderProxy()
}

[Test]
public void AddEvents_TracksTimingsIndependently()
public void AddEvent_TracksTimingsIndependently()
{
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("r", 1)));
pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("t", 3)));
pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("s", 2)));
pending.AddEvent(HtmxTriggerTiming.Receive, "r", 1);
pending.AddEvent(HtmxTriggerTiming.AfterSettle, "t", 3);
pending.AddEvent(HtmxTriggerTiming.AfterSwap, "s", 2);

Assert.That(pending.GetEvents(HtmxTriggerTiming.Receive), Is.EqualTo(CreateDictionary(("r", 1))));
Assert.That(pending.GetEvents(HtmxTriggerTiming.AfterSettle), Is.EqualTo(CreateDictionary(("t", 3))));
Expand All @@ -60,14 +54,14 @@ public void AddEvents_TracksTimingsIndependently()

[TestCase(HtmxTargetVersion.V1)]
[TestCase(HtmxTargetVersion.V2)]
public void AddEvents_PriorVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVersion targetVersion)
public void AddEvent_PriorVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVersion targetVersion)
{
var context = TestHelper.CreateHtmxRequestContext(targetVersion);
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("received", 1)));
pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("swapped", 2)));
pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("settled", 3)));
pending.AddEvent(HtmxTriggerTiming.Receive, "received", 1);
pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", 2);
pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", 3);
pending.Flush();

Assert.Multiple(() =>
Expand All @@ -87,14 +81,14 @@ public void AddEvents_PriorVersions_WriteEachTimingToItsOwnHeader(HtmxTargetVers
}

[Test]
public void AddEvents_Htmx4_NormalizesAllTimingsToReceive()
public void AddEvent_Htmx4_NormalizesAllTimingsToReceive()
{
var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4);
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("received", 1)));
pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("swapped", 2)));
pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("settled", 3)));
pending.AddEvent(HtmxTriggerTiming.Receive, "received", 1);
pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", 2);
pending.AddEvent(HtmxTriggerTiming.AfterSettle, "settled", 3);

var events = pending.GetEvents(HtmxTriggerTiming.Receive)!;
pending.Flush();
Expand All @@ -115,14 +109,14 @@ public void AddEvents_Htmx4_NormalizesAllTimingsToReceive()
}

[Test]
public void AddEvents_Htmx4_PreservesDuplicatesAcrossRequestedTimings()
public void AddEvent_Htmx4_PreservesDuplicatesAcrossRequestedTimings()
{
var context = TestHelper.CreateHtmxRequestContext(HtmxTargetVersion.V4);
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("message", "first")));
pending.AddEvents(HtmxTriggerTiming.AfterSwap, CreateDictionary(("message", "second")));
pending.AddEvents(HtmxTriggerTiming.AfterSettle, CreateDictionary(("message", "third")));
pending.AddEvent(HtmxTriggerTiming.Receive, "message", "first");
pending.AddEvent(HtmxTriggerTiming.AfterSwap, "message", "second");
pending.AddEvent(HtmxTriggerTiming.AfterSettle, "message", "third");

var events = pending.GetEvents(HtmxTriggerTiming.Receive)!;

Expand Down Expand Up @@ -154,7 +148,7 @@ public void SetEvents_ReplacesExisting()
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("old", 1)));
pending.AddEvent(HtmxTriggerTiming.Receive, "old", 1);
pending.SetEvents(HtmxTriggerTiming.Receive, CreateDictionary(("new", 2)));

Assert.That(
Expand All @@ -168,9 +162,7 @@ public void Flush_WritesCamelCaseJsonToHeaders()
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(
HtmxTriggerTiming.Receive,
CreateDictionary(("message", "hello")));
pending.AddEvent(HtmxTriggerTiming.Receive, "message", "hello");

pending.Flush();

Expand All @@ -185,9 +177,7 @@ public void Flush_WritesOnlyRegisteredTimings()
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(
HtmxTriggerTiming.AfterSwap,
CreateDictionary(("swapped", true)));
pending.AddEvent(HtmxTriggerTiming.AfterSwap, "swapped", true);

pending.Flush();

Expand All @@ -202,7 +192,7 @@ public void Flush_SerializesNullEventDetail_AsNull()
var context = TestHelper.CreateHttpContext();
var pending = PendingEvents.GetOrCreate(context.Response);

pending.AddEvents(HtmxTriggerTiming.Receive, CreateDictionary(("e", null!)));
pending.AddEvent(HtmxTriggerTiming.Receive, "e", null!);
pending.Flush();

var header = context.Response.Headers[HtmxResponseHeaderNames.Trigger].ToString();
Expand Down