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: 2 additions & 0 deletions docs/articles/migrate-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,8 @@ These changes affect fewer applications:
case-sensitively, and ignoring leading whitespace. A value such as `OUTERHTML` now returns `null`.
- `HtmxHeaderTagHelper.Headers` changed from `Dictionary<string, string>` to `IDictionary<string, string>`,
and header names are compared without case.
- `hx-header-*` and `hx-all-headers` throw `InvalidOperationException` when the element also has a hand-written
`hx-headers`. In 1.x, the generated attribute silently replaced it.
- `HtmxScrollBehavior` gained `Instant`.
- `Location`, `Redirect`, `Reswap(string)`, `Retarget`, and `Reselect` throw `ArgumentException` for a `null`
or empty value, and `Location(string, HtmxLocationOptions)` also throws for `null` options.
Expand Down
12 changes: 12 additions & 0 deletions docs/articles/tag-helpers.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ object entirely. Add `hx-vals-append="true"` to merge the child object instead:

The button emits `hx-vals:append='{"format":"summary"}'`.

An element with `hx-val-*`, `hx-all-vals`, `hx-vals-inherited`, or `hx-vals-append` cannot also have
a hand-written `hx-vals`, with or without modifiers. The Tag Helper throws `InvalidOperationException`
rather than replace it. To combine `hx-val-*` with values computed in JavaScript, put the hand-written
`hx-vals` on a parent element and rely on the inheritance rules above.

## Send custom headers

```razor
Expand All @@ -224,6 +229,9 @@ For HTMX 4.x, use the Toolkit input `hx-headers-inherited="true"` on a parent el
This generates `hx-headers:inherited`.
Use `hx-headers-append="true"` on a child declaration to merge its headers into the inherited object.

As with `hx-vals`, an element with these inputs cannot also have a hand-written `hx-headers`,
with or without modifiers; the Tag Helper throws `InvalidOperationException`.

Do not use custom client headers as proof of identity or authorization; clients can modify them.
Authenticate and authorize on the server.

Expand Down Expand Up @@ -266,6 +274,10 @@ The output depends on the configured HTMX target:
Unsupported properties are omitted from the generated JSON. In particular, `HtmxRequestCredentials.Omit`
cannot be represented by HTMX 1.9.x or 2.x and is omitted for those targets.

An element with `hx-request-*` attributes cannot also have a hand-written `hx-request` for HTMX 1.9.x and 2.x
or `hx-config` for HTMX 4.x, with or without modifiers. The Tag Helper throws `InvalidOperationException`
rather than replace it.

Use `HtmxRequestCache` and `HtmxRequestRedirect` for the HTMX 4.x Fetch options:

```razor
Expand Down
40 changes: 40 additions & 0 deletions src/Ramstack.HtmxToolkit/Internal/TagHelperOutputExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Diagnostics.CodeAnalysis;

using Microsoft.AspNetCore.Razor.TagHelpers;

namespace Ramstack.HtmxToolkit.Internal;

/// <summary>
/// Provides extension methods for <see cref="TagHelperOutput" />.
/// </summary>
internal static class TagHelperOutputExtensions
{
/// <summary>
/// Throws an exception if the element already has the attribute that the tag helper renders,
/// with or without HTMX 4.x modifiers, such as <c>hx-vals:inherited</c>.
/// </summary>
/// <param name="output">The tag helper output to inspect.</param>
/// <param name="attributeName">The name of the attribute that the tag helper renders, without modifiers.</param>
/// <param name="sourceAttributes">The tag helper attributes that render <paramref name="attributeName" />,
/// as listed in the exception message.</param>
public static void ThrowIfAttributeDeclared(this TagHelperOutput output, string attributeName, string sourceAttributes)
{
var attributes = output.Attributes;
var count = attributes.Count;

for (var i = 0; i < count; i++)
{
var name = attributes[i].Name;
if (name.StartsWith(attributeName, StringComparison.OrdinalIgnoreCase)
&& (name.Length == attributeName.Length || name[attributeName.Length] == ':'))
Error_AttributeDeclared(name, attributeName, sourceAttributes);
}
}

[DoesNotReturn]
private static void Error_AttributeDeclared(string declaredName, string attributeName, string sourceAttributes)
{
var message = $"The {declaredName} attribute cannot be combined with {sourceAttributes}, which render {attributeName}. Remove either the {declaredName} attribute or those attributes.";
throw new InvalidOperationException(message);
}
}
8 changes: 8 additions & 0 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxHeaderTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using Ramstack.HtmxToolkit.Collections;
using Ramstack.HtmxToolkit.Configuration;
using Ramstack.HtmxToolkit.Internal;
using Ramstack.HtmxToolkit.Serialization;

namespace Ramstack.HtmxToolkit.TagHelpers;
Expand All @@ -23,6 +24,10 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </item>
/// <item>In HTMX 4.x, the <c>append</c> modifier merges a child declaration into inherited headers.</item>
/// </list>
/// <para>
/// An <see cref="InvalidOperationException" /> is thrown when the element also has an <c>hx-headers</c>
/// attribute, with or without HTMX 4.x modifiers, rather than replacing it.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
[HtmlTargetElement(Attributes = AppendAttributeName)]
Expand Down Expand Up @@ -81,6 +86,9 @@ public IDictionary<string, string> Headers
/// <inheritdoc />
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
const string SourceAttributes = $"{HeadersPrefix}*, {HeadersDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}";
output.ThrowIfAttributeDeclared("hx-headers", SourceAttributes);

if (Headers is { Count: > 0 })
{
var name = options.Value.TargetVersion switch
Expand Down
9 changes: 9 additions & 0 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxRequestTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Options;

using Ramstack.HtmxToolkit.Configuration;
using Ramstack.HtmxToolkit.Internal;
using Ramstack.HtmxToolkit.Serialization;

namespace Ramstack.HtmxToolkit.TagHelpers;
Expand All @@ -22,6 +23,11 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// Options that the selected HTMX version does not support are not rendered.
/// If no supported option is set, the tag helper renders no attribute.
/// </para>
/// <para>
/// An <see cref="InvalidOperationException" /> is thrown when the element also has the attribute
/// that the tag helper renders for the selected version, with or without HTMX 4.x modifiers,
/// rather than replacing it.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = RequestInheritedAttributeName)]
[HtmlTargetElement(Attributes = RequestAppendAttributeName)]
Expand Down Expand Up @@ -198,6 +204,9 @@ public bool? Validate
/// <inheritdoc />
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
var attributeName = options.Value.TargetVersion == HtmxTargetVersion.V4 ? "hx-config" : "hx-request";
output.ThrowIfAttributeDeclared(attributeName, "hx-request-* attributes");

var json = options.Value.TargetVersion == HtmxTargetVersion.V4
? JsonSerializer.Serialize(new HtmxRequestDataV4(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataV4)
: JsonSerializer.Serialize(new HtmxRequestDataPrior(_request), HtmxRequestJsonSerializerContext.Default.HtmxRequestDataPrior);
Expand Down
8 changes: 8 additions & 0 deletions src/Ramstack.HtmxToolkit/TagHelpers/HtmxValsTagHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

using Ramstack.HtmxToolkit.Collections;
using Ramstack.HtmxToolkit.Configuration;
using Ramstack.HtmxToolkit.Internal;
using Ramstack.HtmxToolkit.Serialization;

namespace Ramstack.HtmxToolkit.TagHelpers;
Expand All @@ -23,6 +24,10 @@ namespace Ramstack.HtmxToolkit.TagHelpers;
/// </item>
/// <item>In HTMX 4.x, the <c>append</c> modifier merges a child declaration into inherited values.</item>
/// </list>
/// <para>
/// An <see cref="InvalidOperationException" /> is thrown when the element also has an <c>hx-vals</c>
/// attribute, with or without HTMX 4.x modifiers, rather than replacing it.
/// </para>
/// </remarks>
[HtmlTargetElement(Attributes = InheritedAttributeName)]
[HtmlTargetElement(Attributes = AppendAttributeName)]
Expand Down Expand Up @@ -81,6 +86,9 @@ public IDictionary<string, string> Values
/// <inheritdoc />
public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
const string SourceAttributes = $"{ValuesPrefix}*, {ValuesDictionaryName}, {InheritedAttributeName}, or {AppendAttributeName}";
output.ThrowIfAttributeDeclared("hx-vals", SourceAttributes);

if (Values is { Count: > 0 } values)
{
var name = options.Value.TargetVersion switch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa
Assert.That(output.Attributes["hx-inherit"], Is.Null);
}

[TestCase("hx-headers")]
[TestCase("hx-headers:inherited:append")]
public void ProcessAsync_DeclaredHxHeaders_Throws(string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute(name, new HtmlString("{\"X-A\":\"1\"}"))
]);
var helper = CreateHelper(HtmxTargetVersion.V4);
helper.Headers["X-B"] = "2";

var exception = Assert.ThrowsAsync<InvalidOperationException>(
async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output));

Assert.That(exception!.Message, Does.StartWith($"The {name} attribute"));
}

private static HtmxHeaderTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2)
{
var options = new HtmxToolkitOptions();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,42 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa
Assert.That(output.Attributes["hx-inherit"], Is.Null);
}

[TestCase(HtmxTargetVersion.V1, "hx-request")]
[TestCase(HtmxTargetVersion.V2, "hx-request")]
[TestCase(HtmxTargetVersion.V4, "hx-config")]
[TestCase(HtmxTargetVersion.V4, "hx-config:inherited")]
public void ProcessAsync_DeclaredRenderedAttribute_Throws(HtmxTargetVersion version, string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute(name, new HtmlString("{\"timeout\":100}"))
]);
var helper = CreateHelper(version);
helper.Timeout = 200;

var exception = Assert.ThrowsAsync<InvalidOperationException>(
async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output));

Assert.That(exception!.Message, Does.StartWith($"The {name} attribute"));
}

[TestCase(HtmxTargetVersion.V2, "hx-config", "hx-request")]
[TestCase(HtmxTargetVersion.V4, "hx-request", "hx-config")]
public async Task ProcessAsync_AttributeOfOtherVersion_IsKept(HtmxTargetVersion version, string name, string rendered)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute(name, new HtmlString("{\"timeout\":100}"))
]);
var helper = CreateHelper(version);
helper.Timeout = 200;

await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);

Assert.That(output.Attributes[name]!.Value.ToString(), Is.EqualTo("{\"timeout\":100}"));
Assert.That(output.Attributes[rendered]!.Value.ToString(), Is.EqualTo("{\"timeout\":200}"));
}

private static HtmxRequestTagHelper CreateHelper(HtmxTargetVersion version, bool? implicitInheritance = null)
{
var options = new HtmxToolkitOptions();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,53 @@ public async Task ProcessAsync_Htmx1_Htmx2_Modifiers_UseOrdinaryAttribute(HtmxTa
Assert.That(output.Attributes["hx-inherit"], Is.Null);
}

[TestCase("hx-vals")]
[TestCase("HX-VALS")]
[TestCase("hx-vals:inherited")]
public void ProcessAsync_DeclaredHxVals_Throws(string name)
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute(name, new HtmlString("{\"a\":\"1\"}"))
]);
var helper = CreateHelper(HtmxTargetVersion.V4);
helper.Values["b"] = "2";

var exception = Assert.ThrowsAsync<InvalidOperationException>(
async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output));

Assert.That(exception!.Message, Does.StartWith($"The {name} attribute"));
}

[Test]
public void ProcessAsync_DeclaredHxVals_WithEmptyDictionary_Throws()
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute("hx-vals", new HtmlString("js:{time: Date.now()}"))
]);
var helper = CreateHelper();

Assert.ThrowsAsync<InvalidOperationException>(
async () => await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output));
}

[Test]
public async Task ProcessAsync_AttributeStartingWithHxVals_IsKept()
{
var output = TestHelper.CreateTagHelperOutput(attributes:
[
new TagHelperAttribute("hx-vals-extra", "x")
]);
var helper = CreateHelper();
helper.Values["b"] = "2";

await helper.ProcessAsync(TestHelper.CreateTagHelperContext(), output);

Assert.That(output.Attributes["hx-vals-extra"]!.Value, Is.EqualTo("x"));
Assert.That(output.Attributes["hx-vals"]!.Value.ToString(), Is.EqualTo("{\"b\":\"2\"}"));
}

private static HtmxValsTagHelper CreateHelper(HtmxTargetVersion version = HtmxTargetVersion.V2)
{
var options = new HtmxToolkitOptions();
Expand Down
Loading