diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index b78f820..599ad5b 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -23,7 +23,11 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 9.0.x + dotnet-version: | + 6.0.x + 7.0.x + 8.0.x + 9.0.x - name: Restore dependencies run: dotnet restore - name: Build diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index 9220dc6..5d00794 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -35,6 +35,12 @@ public static class Inertia public static AlwaysProp Always(Func> callback) => _factory.Always(callback); + public static MergeProp Merge(object? value) => _factory.Merge(value); + + public static MergeProp Merge(Func callback) => _factory.Merge(callback); + + public static MergeProp Merge(Func> callback) => _factory.Merge(callback); + public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index 47abba7..93ab0a2 100644 --- a/InertiaCore/Models/Page.cs +++ b/InertiaCore/Models/Page.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace InertiaCore.Models; internal class Page @@ -6,4 +8,7 @@ internal class Page public string Component { get; set; } = default!; public string? Version { get; set; } public string Url { get; set; } = default!; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? MergeProps { get; set; } } diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs new file mode 100644 index 0000000..6d41883 --- /dev/null +++ b/InertiaCore/Props/MergeProp.cs @@ -0,0 +1,25 @@ +using InertiaCore.Props; + +namespace InertiaCore.Utils; + +public class MergeProp : InvokableProp, Mergeable +{ + public bool merge { get; set; } = true; + + public MergeProp(object? value) : base(value) + { + merge = true; + } + + internal MergeProp(Func value) : base(value) + { + merge = true; + } + + internal MergeProp(Func> value) : base(value) + { + merge = true; + } +} + + diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index d052844..739d637 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -43,6 +43,8 @@ protected internal async Task ProcessResponse() .ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props))) }; + page.MergeProps = ResolveMergeProps(page.Props); + var shared = _context!.HttpContext.Features.Get(); if (shared != null) page.Props = shared.GetMerged(page.Props); @@ -59,6 +61,7 @@ protected internal async Task ProcessResponse() Func f => (pair.Key, f.Invoke()), LazyProp l => (pair.Key, await l.Invoke()), AlwaysProp l => (pair.Key, await l.Invoke()), + MergeProp m => (pair.Key, await m.Invoke()), _ => (pair.Key, pair.Value) }))).ToDictionary(pair => pair.Key, pair => pair.Item2); } @@ -155,4 +158,36 @@ public Response WithViewData(IDictionary viewData) .Where(kv => kv.Value is not AlwaysProp) .Concat(alwaysProps).ToDictionary(kv => kv.Key, kv => kv.Value); } + + private List? ResolveMergeProps(Dictionary props) + { + // Parse the "RESET" header into a collection of keys to reset + var resetProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.Reset] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()), + StringComparer.OrdinalIgnoreCase + ); + + var resolvedProps = props + .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + .ToList(); + + // Filter the props that are Mergeable and should be merged + var mergeProps = _props.GetType().GetProperties().ToDictionary(o => o.Name.ToCamelCase(), o => o.GetValue(_props)) + .Where(kv => kv.Value is Mergeable mergeable && mergeable.ShouldMerge()) // Check if value is Mergeable and should merge + .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Select(kv => kv.Key.ToCamelCase()) // Convert property name to camelCase + .Where(resolvedProps.Contains) // Filter only the props that are in the resolved props + .ToList(); + + if (mergeProps.Count == 0) + { + return null; + } + + // Return the result + return mergeProps; + } } diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index b5fbd21..0547d78 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -26,6 +26,9 @@ internal interface IResponseFactory public AlwaysProp Always(Func> callback); public LazyProp Lazy(Func callback); public LazyProp Lazy(Func> callback); + public MergeProp Merge(object? value); + public MergeProp Merge(Func callback); + public MergeProp Merge(Func> callback); } internal class ResponseFactory : IResponseFactory @@ -127,4 +130,7 @@ public void Share(IDictionary data) public AlwaysProp Always(object? value) => new AlwaysProp(value); public AlwaysProp Always(Func callback) => new AlwaysProp(callback); public AlwaysProp Always(Func> callback) => new AlwaysProp(callback); + public MergeProp Merge(object? value) => new MergeProp(value); + public MergeProp Merge(Func callback) => new MergeProp(callback); + public MergeProp Merge(Func> callback) => new MergeProp(callback); } diff --git a/InertiaCore/Utils/InertiaHeader.cs b/InertiaCore/Utils/InertiaHeader.cs index 80de7d8..b75e6cf 100644 --- a/InertiaCore/Utils/InertiaHeader.cs +++ b/InertiaCore/Utils/InertiaHeader.cs @@ -15,4 +15,6 @@ public static class InertiaHeader public const string PartialOnly = "X-Inertia-Partial-Data"; public const string PartialExcept = "X-Inertia-Partial-Except"; + + public const string Reset = "X-Inertia-Reset"; } diff --git a/InertiaCore/Utils/Mergeable.cs b/InertiaCore/Utils/Mergeable.cs new file mode 100644 index 0000000..b7e5b6e --- /dev/null +++ b/InertiaCore/Utils/Mergeable.cs @@ -0,0 +1,15 @@ +namespace InertiaCore.Utils; + +public interface Mergeable +{ + public bool merge { get; set; } + + public Mergeable Merge() + { + merge = true; + + return this; + } + + public bool ShouldMerge() => merge; +} diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs new file mode 100644 index 0000000..844aaeb --- /dev/null +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -0,0 +1,207 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the merge data is fetched properly.")] + public async Task TestMergeData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(() => + { + return "Merge"; + }) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testMerge", "Merge" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge data is fetched properly with specified partial props.")] + public async Task TestMergePartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(() => "Merge") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testMerge" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testMerge", "Merge" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly.")] + public async Task TestMergeAsyncData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(testFunction) + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "testMerge", "Merge Async" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly with specified partial props.")] + public async Task TestMergeAsyncPartialData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testMerge" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "testMerge", "Merge Async" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + } + + [Test] + [Description("Test if the merge async data is fetched properly without specified partial props.")] + public async Task TestMergeAsyncPartialDataOmitted() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestMerge = _factory.Merge(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc" }, + { "X-Inertia-Partial-Component", "Test/Page" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(null)); + } + + public async Task TestNoMergeProps() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + }); + + var context = PrepareContext(); + + response.SetContext(context); + await response.ProcessResponse(); + + var page = response.GetJson().Value as Page; + + Assert.That(page?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testFunc", "Func" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.MergeProps, Is.EqualTo(null)); + } + +} diff --git a/InertiaCoreTests/UnitTestResult.cs b/InertiaCoreTests/UnitTestResult.cs index a88e79d..36e4cc9 100644 --- a/InertiaCoreTests/UnitTestResult.cs +++ b/InertiaCoreTests/UnitTestResult.cs @@ -1,6 +1,7 @@ using InertiaCore.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using System.Text.Json; namespace InertiaCoreTests; @@ -40,6 +41,62 @@ public async Task TestJsonResult() { "test", "Test" }, { "errors", new Dictionary(0) } })); + + // Check the serialized JSON + var jsonString = JsonSerializer.Serialize(json); + var dictionary = JsonSerializer.Deserialize>(jsonString); + + Assert.That(dictionary, Is.Not.Null); + Assert.That(dictionary!.ContainsKey("MergeProps"), Is.False); + }); + } + + [Test] + [Description("Test if the JSON result with merged data is created correctly.")] + public async Task TestJsonMergedResult() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerged = _factory.Merge(() => "Merged") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia", "true" } + }; + + var context = PrepareContext(headers); + + response.SetContext(context); + await response.ProcessResponse(); + + var result = response.GetResult(); + + Assert.Multiple(() => + { + Assert.That(result, Is.InstanceOf(typeof(JsonResult))); + + var json = (result as JsonResult)?.Value; + Assert.That(json, Is.InstanceOf(typeof(Page))); + + Assert.That((json as Page)?.Component, Is.EqualTo("Test/Page")); + Assert.That((json as Page)?.Props, Is.EqualTo(new Dictionary + { + { "test", "Test" }, + { "testMerged", "Merged" }, + { "errors", new Dictionary(0) } + })); + Assert.That((json as Page)?.MergeProps, Is.EqualTo(new List { + "testMerged" + })); + + // Check the serialized JSON + var jsonString = JsonSerializer.Serialize(json); + var dictionary = JsonSerializer.Deserialize>(jsonString); + + Assert.That(dictionary, Is.Not.Null); + Assert.That(dictionary!.ContainsKey("MergeProps"), Is.True); }); }