diff --git a/InertiaCore/Inertia.cs b/InertiaCore/Inertia.cs index d13b932..8f6a4c9 100644 --- a/InertiaCore/Inertia.cs +++ b/InertiaCore/Inertia.cs @@ -40,4 +40,20 @@ public static class Inertia public static LazyProp Lazy(Func callback) => _factory.Lazy(callback); public static LazyProp Lazy(Func> callback) => _factory.Lazy(callback); + + public static OptionalProp Optional(Func callback) => _factory.Optional(callback); + + public static OptionalProp Optional(Func> callback) => _factory.Optional(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 DeepMergeProp DeepMerge(object? value) => _factory.DeepMerge(value); + + public static DeepMergeProp DeepMerge(Func callback) => _factory.DeepMerge(callback); + + public static DeepMergeProp DeepMerge(Func> callback) => _factory.DeepMerge(callback); } diff --git a/InertiaCore/Models/Page.cs b/InertiaCore/Models/Page.cs index 9df6d58..df7fbc4 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 @@ -8,4 +10,13 @@ internal class Page public string Url { get; set; } = default!; public bool EncryptHistory { get; set; } = false; public bool ClearHistory { get; set; } = false; + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? MergeProps { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Dictionary? MatchPropsOn { get; set; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? DeepMergeProps { get; set; } } diff --git a/InertiaCore/Props/DeepMergeProp.cs b/InertiaCore/Props/DeepMergeProp.cs new file mode 100644 index 0000000..f2a03b2 --- /dev/null +++ b/InertiaCore/Props/DeepMergeProp.cs @@ -0,0 +1,30 @@ +using InertiaCore.Utils; + +namespace InertiaCore.Props; + +public class DeepMergeProp : InvokableProp, Mergeable +{ + public bool merge { get; set; } = true; + public string[]? matchOn { get; set; } + public bool deepMerge { get; set; } = true; + + public DeepMergeProp(object? value) : base(value) + { + merge = true; + deepMerge = true; + } + + internal DeepMergeProp(Func value) : base(value) + { + merge = true; + deepMerge = true; + } + + internal DeepMergeProp(Func> value) : base(value) + { + merge = true; + deepMerge = true; + } + + public bool ShouldDeepMerge() => deepMerge; +} \ No newline at end of file diff --git a/InertiaCore/Props/LazyProp.cs b/InertiaCore/Props/LazyProp.cs index 6e3e958..11bad35 100644 --- a/InertiaCore/Props/LazyProp.cs +++ b/InertiaCore/Props/LazyProp.cs @@ -1,6 +1,8 @@ +using InertiaCore.Utils; + namespace InertiaCore.Props; -public class LazyProp : InvokableProp +public class LazyProp : InvokableProp, IIgnoresFirstLoad { internal LazyProp(Func value) : base(value) { diff --git a/InertiaCore/Props/MergeProp.cs b/InertiaCore/Props/MergeProp.cs new file mode 100644 index 0000000..b957d34 --- /dev/null +++ b/InertiaCore/Props/MergeProp.cs @@ -0,0 +1,27 @@ +using InertiaCore.Props; + +namespace InertiaCore.Utils; + +public class MergeProp : InvokableProp, Mergeable +{ + public bool merge { get; set; } = true; + public bool deepMerge { get; set; } = false; + public string[]? matchOn { get; set; } + + 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/Props/OptionalProp.cs b/InertiaCore/Props/OptionalProp.cs new file mode 100644 index 0000000..cf9c971 --- /dev/null +++ b/InertiaCore/Props/OptionalProp.cs @@ -0,0 +1,14 @@ +using InertiaCore.Utils; + +namespace InertiaCore.Props; + +public class OptionalProp : InvokableProp, IIgnoresFirstLoad +{ + internal OptionalProp(Func value) : base(value) + { + } + + internal OptionalProp(Func> value) : base(value) + { + } +} diff --git a/InertiaCore/Response.cs b/InertiaCore/Response.cs index 7bdd2cb..1a08dd2 100644 --- a/InertiaCore/Response.cs +++ b/InertiaCore/Response.cs @@ -47,6 +47,9 @@ protected internal async Task ProcessResponse() ClearHistory = _clearHistory, }; + page.MergeProps = ResolveMergeProps(props); + page.MatchPropsOn = ResolveMatchPropsOn(props); + page.DeepMergeProps = ResolveDeepMergeProps(props); page.Props["errors"] = GetErrors(); SetPage(page); @@ -88,7 +91,7 @@ protected internal async Task ProcessResponse() if (!isPartial) return props - .Where(kv => kv.Value is not LazyProp) + .Where(kv => kv.Value is not IIgnoresFirstLoad) .ToDictionary(kv => kv.Key, kv => kv.Value); props = props.ToDictionary(kv => kv.Key, kv => kv.Value); @@ -144,6 +147,167 @@ protected internal async Task ProcessResponse() .Concat(alwaysProps).ToDictionary(kv => kv.Key, kv => kv.Value); } + /// + /// Resolve `merge` properties that should be appended to the existing values by the front-end. + /// + 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 + ); + + // Parse the "PARTIAL_ONLY" header into a collection of keys to include + var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude + var exceptProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] + .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.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge()) // Check if value is Mergeable and should merge + .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any + .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified 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; + } + + /// + /// Resolve match props on for properties that should be matched on specific keys. + /// + private Dictionary? ResolveMatchPropsOn(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 + ); + + // Parse the "PARTIAL_ONLY" header into a collection of keys to include + var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude + var exceptProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] + .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 have match on keys + var matchPropsOn = _props.Where(o => o.Value is Mergeable mergeable && mergeable.ShouldMerge() && mergeable.GetMatchOn() != null) + .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any + .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified keys + .Where(kv => resolvedProps.Contains(kv.Key.ToCamelCase())) // Filter only the props that are in the resolved props + .ToDictionary( + kv => kv.Key.ToCamelCase(), // Convert property name to camelCase + kv => ((Mergeable)kv.Value!).GetMatchOn()! + ); + + if (matchPropsOn.Count == 0) + { + return null; + } + + // Return the result + return matchPropsOn; + } + + /// + /// Resolve deep merge properties that should be deeply merged with existing values by the front-end. + /// + private List? ResolveDeepMergeProps(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 + ); + + // Parse the "PARTIAL_ONLY" header into a collection of keys to include + var onlyProps = _context!.HttpContext.Request.Headers[InertiaHeader.PartialOnly] + .ToString() + .Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Parse the "PARTIAL_EXCEPT" header into a collection of keys to exclude + var exceptProps = new HashSet( + _context!.HttpContext.Request.Headers[InertiaHeader.PartialExcept] + .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 DeepMergeable and should be deeply merged + var deepMergeProps = _props.Where(o => o.Value is DeepMergeProp deepMergeable && deepMergeable.ShouldDeepMerge()) // Check if value is DeepMergeProp and should deep merge + .Where(kv => !resetProps.Contains(kv.Key)) // Exclude reset keys + .Where(kv => onlyProps.Count == 0 || onlyProps.Contains(kv.Key)) // Include only specified keys if any + .Where(kv => !exceptProps.Contains(kv.Key)) // Exclude specified 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 (deepMergeProps.Count == 0) + { + return null; + } + + // Return the result + return deepMergeProps; + } + /// /// Resolve all necessary class instances in the given props. /// diff --git a/InertiaCore/ResponseFactory.cs b/InertiaCore/ResponseFactory.cs index 534b7ba..870e667 100644 --- a/InertiaCore/ResponseFactory.cs +++ b/InertiaCore/ResponseFactory.cs @@ -29,6 +29,14 @@ 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); + public DeepMergeProp DeepMerge(object? value); + public DeepMergeProp DeepMerge(Func callback); + public DeepMergeProp DeepMerge(Func> callback); + public OptionalProp Optional(Func callback); + public OptionalProp Optional(Func> callback); } internal class ResponseFactory : IResponseFactory @@ -144,4 +152,12 @@ public void Share(IDictionary data) public AlwaysProp Always(object? value) => new(value); public AlwaysProp Always(Func callback) => new(callback); public AlwaysProp Always(Func> callback) => new(callback); + public MergeProp Merge(object? value) => new(value); + public MergeProp Merge(Func callback) => new(callback); + public MergeProp Merge(Func> callback) => new(callback); + public DeepMergeProp DeepMerge(object? value) => new(value); + public DeepMergeProp DeepMerge(Func callback) => new(callback); + public DeepMergeProp DeepMerge(Func> callback) => new(callback); + public OptionalProp Optional(Func callback) => new(callback); + public OptionalProp Optional(Func> callback) => new(callback); } diff --git a/InertiaCore/Utils/IIgnoresFirstLoad.cs b/InertiaCore/Utils/IIgnoresFirstLoad.cs new file mode 100644 index 0000000..10fc9ba --- /dev/null +++ b/InertiaCore/Utils/IIgnoresFirstLoad.cs @@ -0,0 +1,5 @@ +namespace InertiaCore.Utils; + +public interface IIgnoresFirstLoad +{ +} 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..8b982bf --- /dev/null +++ b/InertiaCore/Utils/Mergeable.cs @@ -0,0 +1,34 @@ +namespace InertiaCore.Utils; + +public interface Mergeable +{ + public bool merge { get; set; } + public bool deepMerge { get; set; } + public string[]? matchOn { get; set; } + + public Mergeable Merge() + { + merge = true; + + return this; + } + + public Mergeable DeepMerge() + { + deepMerge = true; + + merge = true; + + return this; + } + + public Mergeable MatchesOn(params string[] keys) + { + matchOn = keys; + return this; + } + + public bool ShouldMerge() => merge; + public bool ShouldDeepMerge() => deepMerge; + public string[]? GetMatchOn() => matchOn; +} diff --git a/InertiaCoreTests/UnitTestDeepMergeData.cs b/InertiaCoreTests/UnitTestDeepMergeData.cs new file mode 100644 index 0000000..0ae581f --- /dev/null +++ b/InertiaCoreTests/UnitTestDeepMergeData.cs @@ -0,0 +1,312 @@ +using InertiaCore.Models; +using InertiaCore.Utils; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the deep merge data is fetched properly.")] + public async Task TestDeepMergeData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(() => + { + return "Deep 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" }, + { "testDeepMerge", "Deep Merge" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge data is fetched properly with specified partial props.")] + public async Task TestDeepMergePartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(() => "Deep Merge") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testDeepMerge" }, + { "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" }, + { "testDeepMerge", "Deep Merge" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge async data is fetched properly.")] + public async Task TestDeepMergeAsyncData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Deep Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(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" }, + { "testDeepMerge", "Deep Merge Async" }, + { "errors", new Dictionary(0) } + })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } + + [Test] + [Description("Test if the deep merge data is fetched properly without specified partial props.")] + public async Task TestDeepMergePartialDataOmitted() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Deep Merge Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestDeepMerge = _factory.DeepMerge(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?.DeepMergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if there are no deep merge props when none are specified.")] + public async Task TestNoDeepMergeProps() + { + 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?.DeepMergeProps, Is.EqualTo(null)); + } + + [Test] + [Description("Test if deep merge props are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestDeepMergePropsWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = _factory.DeepMerge(() => "Deep Merge1"), + TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testDeepMerge1" }, + { "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 + { + { "test", "Test" }, + { "testDeepMerge2", "Deep Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testDeepMerge1 should be excluded from deep merge props due to PARTIAL_EXCEPT header + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge2" })); + } + + [Test] + [Description("Test if only specified deep merge props are included when using PARTIAL_ONLY header.")] + public async Task TestDeepMergePropsWithPartialOnly() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = _factory.DeepMerge(() => "Deep Merge1"), + TestDeepMerge2 = _factory.DeepMerge(() => "Deep Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testDeepMerge1,testNormal" }, + { "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 + { + { "testDeepMerge1", "Deep Merge1" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // Only testDeepMerge1 should be in deep merge props since testDeepMerge2 was not included in PARTIAL_ONLY + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1" })); + } + + [Test] + [Description("Test if deep merge props work with match on keys.")] + public async Task TestDeepMergeWithMatchOn() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestDeepMerge1 = ((Mergeable)_factory.DeepMerge("Deep Merge1")).MatchesOn("deep"), + TestDeepMerge2 = ((Mergeable)_factory.DeepMerge(() => "Deep Merge2")).MatchesOn("shallow", "replace"), + TestNormal = "Normal" + }); + + 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" }, + { "testDeepMerge1", "Deep Merge1" }, + { "testDeepMerge2", "Deep Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge1", "testDeepMerge2" })); + // Deep merge props should also appear in match props on since they inherit from Mergeable + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + { + { "testDeepMerge1", new[] { "deep" } }, + { "testDeepMerge2", new[] { "shallow", "replace" } } + })); + } + + [Test] + [Description("Test if regular merge and deep merge props coexist properly.")] + public async Task TestMergeAndDeepMergeCoexistence() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge = _factory.Merge(() => "Regular Merge"), + TestDeepMerge = _factory.DeepMerge(() => "Deep Merge"), + TestNormal = "Normal" + }); + + 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" }, + { "testMerge", "Regular Merge" }, + { "testDeepMerge", "Deep Merge" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge", "testDeepMerge" })); + Assert.That(page?.DeepMergeProps, Is.EqualTo(new List { "testDeepMerge" })); + } +} \ No newline at end of file diff --git a/InertiaCoreTests/UnitTestMergeData.cs b/InertiaCoreTests/UnitTestMergeData.cs new file mode 100644 index 0000000..2aec9cd --- /dev/null +++ b/InertiaCoreTests/UnitTestMergeData.cs @@ -0,0 +1,467 @@ +using InertiaCore.Models; +using InertiaCore.Utils; +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)); + } + + [Test] + [Description("Test if the merge async data is fetched properly without specified partial props.")] + 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)); + } + + [Test] + [Description("Test if merge props are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestMergePropsWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testMerge1" }, + { "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 + { + { "test", "Test" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testMerge1 should be excluded from merge props due to PARTIAL_EXCEPT header + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + } + + [Test] + [Description("Test if only specified merge props are included when using PARTIAL_ONLY header.")] + public async Task TestMergePropsWithPartialOnly() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testNormal" }, + { "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 + { + { "testMerge1", "Merge1" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // Only testMerge1 should be in merge props since testMerge2 was not included in PARTIAL_ONLY + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1" })); + } + + [Test] + [Description("Test if merge props respect both PARTIAL_ONLY and PARTIAL_EXCEPT headers.")] + public async Task TestMergePropsWithPartialOnlyAndExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = _factory.Merge(() => "Merge1"), + TestMerge2 = _factory.Merge(() => "Merge2"), + TestMerge3 = _factory.Merge(() => "Merge3"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testMerge2,testMerge3,testNormal" }, + { "X-Inertia-Partial-Except", "testMerge2" }, + { "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 + { + { "testMerge1", "Merge1" }, + { "testMerge3", "Merge3" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + // testMerge1 and testMerge3 should be in merge props (testMerge2 excluded by PARTIAL_EXCEPT) + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); + } + + [Test] + [Description("Test if match props on are resolved properly for merge props.")] + public async Task TestMatchPropsOn() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), + TestNormal = "Normal" + }); + + 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" }, + { "testMerge1", "Merge1" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge2" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + { + { "testMerge1", new[] { "deep" } }, + { "testMerge2", new[] { "shallow", "replace" } } + })); + } + + [Test] + [Description("Test if match props on are handled properly with partial props.")] + public async Task TestMatchPropsOnWithPartialProps() + { + var response = _factory.Render("Test/Page", new + { + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), + TestMerge3 = ((Mergeable)_factory.Merge("Merge3")).MatchesOn("custom") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testMerge1,testMerge3" }, + { "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 + { + { "testMerge1", "Merge1" }, + { "testMerge3", "Merge3" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge1", "testMerge3" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + { + { "testMerge1", new[] { "deep" } }, + { "testMerge3", new[] { "custom" } } + })); + } + + [Test] + [Description("Test if match props on are excluded when using PARTIAL_EXCEPT header.")] + public async Task TestMatchPropsOnWithPartialExcept() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge1 = ((Mergeable)_factory.Merge("Merge1")).MatchesOn("deep"), + TestMerge2 = ((Mergeable)_factory.Merge(() => "Merge2")).MatchesOn("shallow", "replace"), + TestNormal = "Normal" + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Except", "testMerge1" }, + { "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 + { + { "test", "Test" }, + { "testMerge2", "Merge2" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge2" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(new Dictionary + { + { "testMerge2", new[] { "shallow", "replace" } } + })); + } + + [Test] + [Description("Test if match props on are null when no merge props have match keys.")] + public async Task TestNoMatchPropsOn() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestMerge = _factory.Merge(() => "Merge"), // No strategies + TestNormal = "Normal" + }); + + 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" }, + { "testMerge", "Merge" }, + { "testNormal", "Normal" }, + { "errors", new Dictionary(0) } + })); + + Assert.That(page?.MergeProps, Is.EqualTo(new List { "testMerge" })); + Assert.That(page?.MatchPropsOn, Is.EqualTo(null)); + } + +} diff --git a/InertiaCoreTests/UnitTestOptionalData.cs b/InertiaCoreTests/UnitTestOptionalData.cs new file mode 100644 index 0000000..3d925ec --- /dev/null +++ b/InertiaCoreTests/UnitTestOptionalData.cs @@ -0,0 +1,139 @@ +using InertiaCore.Models; +using Microsoft.AspNetCore.Http; + +namespace InertiaCoreTests; + +public partial class Tests +{ + [Test] + [Description("Test if the optional data is fetched properly.")] + public async Task TestOptionalData() + { + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(() => + { + Assert.Fail(); + return "Optional"; + }) + }); + + 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) } + })); + } + + [Test] + [Description("Test if the optional data is fetched properly with specified partial props.")] + public async Task TestOptionalPartialData() + { + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(() => "Optional") + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testOptional" }, + { "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" }, + { "testOptional", "Optional" }, + { "errors", new Dictionary(0) } + })); + } + + + [Test] + [Description("Test if the optional async data is fetched properly.")] + public async Task TestOptionalAsyncData() + { + var testFunction = new Func>(async () => + { + Assert.Fail(); + await Task.Delay(100); + return "Optional Async"; + }); + + var response = _factory.Render("Test/Page", new + { + Test = "Test", + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(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" }, + { "errors", new Dictionary(0) } + })); + } + + [Test] + [Description("Test if the optional async data is fetched properly with specified partial props.")] + public async Task TestOptionalAsyncPartialData() + { + var testFunction = new Func>(async () => + { + await Task.Delay(100); + return "Optional Async"; + }); + + var response = _factory.Render("Test/Page", new + { + TestFunc = new Func(() => "Func"), + TestOptional = _factory.Optional(async () => await testFunction()) + }); + + var headers = new HeaderDictionary + { + { "X-Inertia-Partial-Data", "testFunc,testOptional" }, + { "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" }, + { "testOptional", "Optional Async" }, + { "errors", new Dictionary(0) } + })); + } +} 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); }); }