Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add trigger specification cache to Htmxor #44

Merged
merged 18 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
908774b
feat: Add trigger specification cache to Htmxor
tanczosm May 9, 2024
a015b17
feat: enhance trigger specification creation in Htmxor
tanczosm May 9, 2024
4b4270a
Merge remote-tracking branch 'origin/main' into feat-trigger-config
tanczosm May 9, 2024
52418e5
feat: Update trigger methods in Razor components
tanczosm May 10, 2024
1eed04a
fix: Update trigger event handling
tanczosm May 10, 2024
9ceda12
feat: Handle parsing of formatting of css selectors and css selectors
tanczosm May 10, 2024
1bbf7a9
refactor: Expanded Trigger functionality in Htmxor library
tanczosm May 10, 2024
f60b979
test: Add TriggerBuilder and TriggerSpecificationCache unit tests
tanczosm May 10, 2024
ae84ae2
feat: update trigger constants and usage
tanczosm May 12, 2024
a4f0f96
Merge remote-tracking branch 'origin/main' into feat-trigger-config
tanczosm May 12, 2024
803a848
style: reformat test files for readability
tanczosm May 12, 2024
bb39840
style: adjust code formatting and comments
tanczosm May 12, 2024
aa53ae0
Merge remote-tracking branch 'origin/main' into feat-trigger-config
tanczosm May 12, 2024
781ffdb
refactor: rename 'selector' to 'cssSelector'
tanczosm May 12, 2024
37989f3
docs: update parameter descriptions in SwapStyleBuilderExtension
tanczosm May 12, 2024
6c9b2c2
fix: make cssSelector parameter optional in SwapStyleBuilderExtension…
tanczosm May 12, 2024
728eca1
refactor: move test files into correct location
egil May 13, 2024
3710af2
fix: simplified creating trigger spec dictionary using collection exp…
egil May 13, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<td>@contact.Email</td>
</tr>
}
<tr hx-get="/examples/infinite-scroll?page=@(Page + 1)" hx-trigger="revealed" hx-swap="outerHTML">
<tr hx-get="/examples/infinite-scroll?page=@(Page + 1)" hx-trigger=@Trigger.Revealed() hx-swap="outerHTML">
<td colspan="4" class="text-center htmx-indicator">
Loading more data <span class="spinner-grow spinner-grow-sm" role="status"></span>
</td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</tr>
</thead>
<HtmxFragment Match=@(req => req.Target == "contacts-table")>
<tbody id="contacts-table" hx-get hx-trigger="newContact from:body" hx-swap=@SwapStyles.OuterHTML>
<tbody id="contacts-table" hx-get hx-trigger=@Trigger.OnEvent("newContact").From("body") hx-swap=@SwapStyles.OuterHTML>
@foreach (var contact in Contacts.Data.Values.OrderBy(x => x.Modified).Take(20))
{
<tr>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</tr>
</thead>
<HtmxFragment Match=@(req => req.Target == "contacts-table")>
<tbody id="contacts-table" hx-get hx-trigger="newContact from:body" hx-swap=@SwapStyles.OuterHTML>
<tbody id="contacts-table" hx-get hx-trigger=@Trigger.OnEvent("newContact").From("body") hx-swap=@SwapStyles.OuterHTML>
@foreach (var contact in Contacts.Data.Values.OrderBy(x => x.Modified).Take(20))
{
<tr>
Expand Down
13 changes: 13 additions & 0 deletions samples/HtmxorExamples/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Htmxor;
using HtmxorExamples.Components;
using HtmxorExamples.Components.Pages.Examples.OutOfBandOutlets;

Expand All @@ -9,6 +10,18 @@
{
// Enabled to support out of band updates
options.UseTemplateFragments = true;

// Enabled to show use of trigger specification cache
options.TriggerSpecsCache = [
Trigger.Revealed(), // Used in InfiniteScroll demo
Trigger.OnEvent("newContact").From("body"), // Used in TriggeringEvents demo
Trigger.OnEvent("keyup").Changed().Delay(TimeSpan.FromMilliseconds(500))
.Or()
.OnEvent("mouseenter").Once(), // Unused, demonstrates complex trigger
Trigger.Every(TimeSpan.FromSeconds(30)) // Unused, demonstrates use of Every
.Or()
.OnEvent("newContact").From("closest (form input)"),
];
});

var app = builder.Build();
Expand Down
22 changes: 22 additions & 0 deletions src/Htmxor/Constants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,29 @@ public static class Attributes
/// </summary>
public static class Triggers
{
public const string Every = "every";
public const string Intersect = "intersect";
public const string Load = "load";
public const string Revealed = "revealed";
public const string Sse = "sse";
}

/// <summary>
/// Htmx trigger modifier values for <c>hx-trigger</c>.
/// </summary>
public static class TriggerModifiers
{
public const string SseEvent = "sseEvent";
public const string Once = "once";
public const string Changed = "changed";
public const string Delay = "delay";
public const string Throttle = "throttle";
public const string From = "from";
public const string Target = "target";
public const string Consume = "consume";
public const string Queue = "queue";
public const string Root = "root";
public const string Threshold = "threshold";
}

/// <summary>
Expand Down
11 changes: 10 additions & 1 deletion src/Htmxor/HtmxConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ namespace Htmxor;
/// <summary>
/// Htmx configuration options.
/// </summary>
[SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "This is effectively a DTO.")]
[SuppressMessage("Performance", "CA1819:Properties should not return arrays", Justification = "This is a DTO.")]
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "This is a DTO.")]
public partial record class HtmxConfig
{
private SwapStyle? defaultSwapStyle;
Expand Down Expand Up @@ -216,6 +217,14 @@ public SwapStyle? DefaultSwapStyle
[JsonPropertyName("scrollIntoViewOnBoost")]
public bool? ScrollIntoViewOnBoost { get; set; }

/// <summary>
/// defaults to <see langword="null" />, the cache to store evaluated trigger specifications into, improving parsing
/// performance at the cost of more memory usage. You may define a simple object to use a never-clearing cache, or
/// implement your own system using a proxy object
/// </summary>
[JsonPropertyName("triggerSpecsCache")]
public TriggerSpecificationCache? TriggerSpecsCache { get; set; }

[JsonInclude, JsonPropertyName("antiforgery")]
internal HtmxorAntiforgeryOptions? Antiforgery { get; init; }
}
144 changes: 144 additions & 0 deletions src/Htmxor/HtmxTriggerSpecification.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using System.Text.Json.Serialization;

namespace Htmxor;

/// <summary>
/// Represents an htmx trigger definition
/// </summary>
public sealed record HtmxTriggerSpecification
{
[JsonPropertyName("trigger")]
public string Trigger { get; set; } = string.Empty;

[JsonPropertyName("sseEvent")]
public string? SseEvent { get; set; }

[JsonPropertyName("eventFilter")]
public string? EventFilter { get; set; }

[JsonPropertyName("changed")]
public bool? Changed { get; set; }

[JsonPropertyName("once")]
public bool? Once { get; set; }

[JsonPropertyName("consume")]
public bool? Consume { get; set; }

[JsonPropertyName("from")]
public string? From { get; set; }

[JsonPropertyName("target")]
public string? Target { get; set; }

[JsonPropertyName("throttle")]
public int? Throttle { get; set; }

[JsonPropertyName("queue")]
public string? Queue { get; set; }

[JsonPropertyName("root")]
public string? Root { get; set; }

[JsonPropertyName("threshold")]
public string? Threshold { get; set; }

[JsonPropertyName("delay")]
public int? Delay { get; set; }

[JsonPropertyName("pollInterval")]
public int? PollInterval { get; set; }

public override string ToString()
{
var parts = new List<string> { Trigger };

// every 2s
if (Trigger == Constants.Triggers.Every)
parts[0] += " " + FormatTimeSpan(TimeSpan.FromMilliseconds(PollInterval ?? 0));

if (!string.IsNullOrEmpty(EventFilter))
parts[0] += $"[{EventFilter}]";

if (!string.IsNullOrEmpty(SseEvent))
parts.Add($"{Constants.TriggerModifiers.SseEvent}:{SseEvent}");

if (Once == true)
parts.Add(Constants.TriggerModifiers.Once);

if (Changed == true)
parts.Add(Constants.TriggerModifiers.Changed);

if (Delay.HasValue)
parts.Add($"{Constants.TriggerModifiers.Delay}:{FormatTimeSpan(TimeSpan.FromMilliseconds(Delay.Value))}");

if (Throttle.HasValue)
parts.Add($"{Constants.TriggerModifiers.Throttle}:{FormatTimeSpan(TimeSpan.FromMilliseconds(Throttle.Value))}");

if (!string.IsNullOrEmpty(From))
parts.Add($"{Constants.TriggerModifiers.From}:{FormatExtendedCssSelector(From)}");

if (!string.IsNullOrEmpty(Target))
parts.Add($"{Constants.TriggerModifiers.Target}:{FormatCssSelector(Target)}");

if (Consume == true)
parts.Add(Constants.TriggerModifiers.Consume);

if (!string.IsNullOrEmpty(Queue))
parts.Add($"{Constants.TriggerModifiers.Queue}:{Queue}");

if (!string.IsNullOrEmpty(Root))
parts.Add($"{Constants.TriggerModifiers.Root}:{FormatCssSelector(Root)}");

if (!string.IsNullOrEmpty(Threshold))
parts.Add($"{Constants.TriggerModifiers.Threshold}:{Threshold}");

return string.Join(" ", parts);
}

private static string FormatTimeSpan(TimeSpan timing)
{
if (timing.TotalSeconds < 1)
{
return $"{timing.TotalMilliseconds}ms";
}

return $"{timing.TotalSeconds}s";
}

private static string FormatExtendedCssSelector(string cssSelector)
{
ReadOnlySpan<string> keywords = ["closest", "find", "next", "previous"];
cssSelector = cssSelector.TrimStart();

foreach (var keyword in keywords)
{
if (cssSelector.StartsWith(keyword + " ", StringComparison.InvariantCulture))
{
var selector = cssSelector.Substring(keyword.Length + 1);

return keyword + " " + FormatCssSelector(selector);
}
}

return FormatCssSelector(cssSelector);
}

private static string FormatCssSelector(string cssSelector)
{
cssSelector = cssSelector.Trim();

if ((cssSelector.StartsWith('{') && cssSelector.EndsWith('}')) ||
(cssSelector.StartsWith('(') && cssSelector.EndsWith(')')))
{
return cssSelector;

}
else if (cssSelector.Any(char.IsWhiteSpace))
{
return "{" + cssSelector + "}";
}

return cssSelector;
}
}
6 changes: 5 additions & 1 deletion src/Htmxor/Serialization/HtmxorJsonSerializerContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ namespace Htmxor.Serialization;
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
UseStringEnumConverter = true,
GenerationMode = JsonSourceGenerationMode.Default,
Converters = [typeof(TimespanMillisecondJsonConverter), typeof(JsonCamelCaseStringEnumConverter<SwapStyle>), typeof(JsonCamelCaseStringEnumConverter<ScrollBehavior>)])]
Converters = [
typeof(TimespanMillisecondJsonConverter),
typeof(JsonCamelCaseStringEnumConverter<SwapStyle>),
typeof(JsonCamelCaseStringEnumConverter<ScrollBehavior>),
])]
[JsonSerializable(typeof(HtmxConfig))]
[JsonSerializable(typeof(LocationTarget))]
[JsonSerializable(typeof(AjaxContext))]
Expand Down
60 changes: 30 additions & 30 deletions src/Htmxor/SwapStyleBuilder.cs
egil marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.Collections;
using System.Collections;
using System.Collections.Specialized;
using System.Diagnostics;

Expand Down Expand Up @@ -66,20 +66,20 @@ public SwapStyleBuilder AfterSettleDelay(TimeSpan time)
/// <remarks>
/// Sets the swapped content scrollbar position after swapping immediately (without animation). For instance, using <see cref="ScrollDirection.Top"/>
/// will add the modifier <c>scroll:top</c> which sets the scrollbar position to the top of swap content after the swap.
/// If css <paramref name="selector"/> is present then the page is scrolled to the <paramref name="direction"/> of the content identified by the css selector.
/// If css <paramref name="cssSelector"/> is present then the page is scrolled to the <paramref name="direction"/> of the content identified by the css cssSelector.
/// </remarks>
/// <param name="direction">The scroll direction after the swap.</param>
/// <param name="selector">Optional CSS selector of the target element.</param>
/// <param name="cssSelector">Optional CSS cssSelector of the target element.</param>
/// <returns>This <see cref="SwapStyleBuilder"/> object instance.</returns>
public SwapStyleBuilder Scroll(ScrollDirection direction, string? selector = null)
public SwapStyleBuilder Scroll(ScrollDirection direction, string? cssSelector = null)
{
switch (direction)
{
case ScrollDirection.Top:
AddModifier("scroll", selector is null ? "top" : $"{selector}:top");
AddModifier("scroll", cssSelector is null ? "top" : $"{cssSelector}:top");
break;
case ScrollDirection.Bottom:
AddModifier("scroll", selector is null ? "bottom" : $"{selector}:bottom");
AddModifier("scroll", cssSelector is null ? "bottom" : $"{cssSelector}:bottom");
break;
}

Expand All @@ -91,24 +91,24 @@ public SwapStyleBuilder Scroll(ScrollDirection direction, string? selector = nul
/// </summary>
/// <remarks>
/// This method adds the modifier <c>scroll:top</c> to the swap commands, instructing the page to scroll to
/// the top of the content after content is swapped immediately and without animation. If css <paramref name="selector"/>
/// is present then the page is scrolled to the top of the content identified by the css selector.
/// the top of the content after content is swapped immediately and without animation. If css <paramref name="cssSelector"/>
/// is present then the page is scrolled to the top of the content identified by the css cssSelector.
/// </remarks>
/// <param name="selector">Optional CSS selector of the target element.</param>
/// <param name="cssSelector">Optional CSS cssSelector of the target element.</param>
/// <returns>This <see cref="SwapStyleBuilder"/> object instance.</returns>
public SwapStyleBuilder ScrollTop(string? selector = null) => Scroll(ScrollDirection.Top, selector);
public SwapStyleBuilder ScrollTop(string? cssSelector = null) => Scroll(ScrollDirection.Top, cssSelector);

/// <summary>
/// Sets the content scrollbar position to the bottom of the swapped content after a swap.
/// </summary>
/// <remarks>
/// This method adds the modifier <c>scroll:bottom</c> to the swap commands, instructing the page to scroll to
/// the bottom of the content after content is swapped immediately and without animation. If css <paramref name="selector"/>
/// is present then the page is scrolled to the bottom of the content identified by the css selector.
/// the bottom of the content after content is swapped immediately and without animation. If css <paramref name="cssSelector"/>
/// is present then the page is scrolled to the bottom of the content identified by the css cssSelector.
/// </remarks>
/// <param name="selector">Optional CSS selector of the target element.</param>
/// <param name="cssSelector">Optional CSS cssSelector of the target element.</param>
/// <returns>This <see cref="SwapStyleBuilder"/> object instance.</returns>
public SwapStyleBuilder ScrollBottom(string? selector = null) => Scroll(ScrollDirection.Bottom, selector);
public SwapStyleBuilder ScrollBottom(string? cssSelector = null) => Scroll(ScrollDirection.Bottom, cssSelector);

/// <summary>
/// Determines whether to ignore the document title in the swap response by appending the modifier
Expand Down Expand Up @@ -202,53 +202,53 @@ public SwapStyleBuilder ScrollFocus(bool scroll = true)
public SwapStyleBuilder PreserveFocus() => ScrollFocus(false);

/// <summary>
/// Specifies a CSS selector to target for the swap operation, smoothly animating the scrollbar position to either the
/// Specifies a CSS cssSelector to target for the swap operation, smoothly animating the scrollbar position to either the
/// top or the bottom of the target element after the swap.
/// </summary>
/// <remarks>
/// Adds a show modifier with the specified CSS selector and scroll direction. For example, if <paramref name="selector"/>
/// Adds a show modifier with the specified CSS cssSelector and scroll direction. For example, if <paramref name="cssSelector"/>
/// is ".item" and <paramref name="direction"/> is <see cref="ScrollDirection.Top"/>, the modifier <c>show:.item:top</c>
/// is added.
/// </remarks>
/// <param name="direction">The scroll direction after swap.</param>
/// <param name="selector">Optional CSS selector of the target element.</param>
/// <param name="cssSelector">Optional CSS cssSelector of the target element.</param>
/// <returns>This <see cref="SwapStyleBuilder"/> object instance.</returns>
public SwapStyleBuilder ShowOn(ScrollDirection direction, string? selector = null)
public SwapStyleBuilder ShowOn(ScrollDirection direction, string? cssSelector = null)
{
switch (direction)
{
case ScrollDirection.Top:
AddModifier("show", selector is null ? "top" : $"{selector}:top");
AddModifier("show", cssSelector is null ? "top" : $"{cssSelector}:top");
break;
case ScrollDirection.Bottom:
AddModifier("show", selector is null ? "bottom" : $"{selector}:bottom");
AddModifier("show", cssSelector is null ? "bottom" : $"{cssSelector}:bottom");
break;
}

return this;
}

/// <summary>
/// Specifies that the swap should show the top of the element matching the CSS selector.
/// Specifies that the swap should show the top of the element matching the CSS cssSelector.
/// </summary>
/// <remarks>
/// This method adds the modifier <c>show:<paramref name="selector"/>:top</c>, smoothly scrolling to the top of the element identified by
/// <paramref name="selector"/>.
/// This method adds the modifier <c>show:<paramref name="cssSelector"/>:top</c>, smoothly scrolling to the top of the element identified by
/// <paramref name="cssSelector"/>.
/// </remarks>
/// <param name="selector">Optional CSS selector of the target element.</param>
/// <param name="cssSelector">Optional CSS cssSelector of the target element.</param>
/// <returns>This <see cref="SwapStyleBuilder"/> object instance.</returns>
public SwapStyleBuilder ShowOnTop(string? selector = null) => ShowOn(ScrollDirection.Top, selector);
public SwapStyleBuilder ShowOnTop(string? cssSelector = null) => ShowOn(ScrollDirection.Top, cssSelector);

/// <summary>
/// Specifies that the swap should show the bottom of the element matching the CSS selector.
/// Specifies that the swap should show the bottom of the element matching the CSS cssSelector.
/// </summary>
/// <remarks>
/// This method adds the modifier <c>show:<paramref name="selector"/>:bottom</c>, smoothly scrolling to the bottom of the element identified by
/// <paramref name="selector"/>.
/// This method adds the modifier <c>show:<paramref name="cssSelector"/>:bottom</c>, smoothly scrolling to the bottom of the element identified by
/// <paramref name="cssSelector"/>.
/// </remarks>
/// <param name="selector">Optional CSS selector of the target element.</param>
/// <param name="cssSelector">Optional CSS cssSelector of the target element.</param>
/// <returns>This <see cref="SwapStyleBuilder"/> object instance.</returns>
public SwapStyleBuilder ShowOnBottom(string? selector = null) => ShowOn(ScrollDirection.Bottom, selector);
public SwapStyleBuilder ShowOnBottom(string? cssSelector = null) => ShowOn(ScrollDirection.Bottom, cssSelector);

/// <summary>
/// Specifies that the swap should show in the window by smoothly scrolling to either the top or bottom of the window.
Expand Down
Loading
Loading