diff --git a/src/Altinn.App.Core/Features/Pdf/DynamicsPdfFormatter.cs b/src/Altinn.App.Core/Features/Pdf/DynamicsPdfFormatter.cs index ff4cbc69e..6e33ee083 100644 --- a/src/Altinn.App.Core/Features/Pdf/DynamicsPdfFormatter.cs +++ b/src/Altinn.App.Core/Features/Pdf/DynamicsPdfFormatter.cs @@ -37,8 +37,7 @@ public async Task FormatPdf(LayoutSettings layoutSettings, objec var state = await _layoutStateInit.Init(instance, data, layoutSetId: layoutSet?.Id); foreach (var pageContext in state.GetComponentContexts()) { - var pageHidden = ExpressionEvaluator.EvaluateBooleanExpression(state, pageContext, "hidden", false); - if (pageHidden) + if (pageContext.IsHidden == true) { layoutSettings.Pages.ExcludeFromPdf.Add(pageContext.Component.Id); } @@ -47,8 +46,7 @@ public async Task FormatPdf(LayoutSettings layoutSettings, objec //TODO: figure out how pdf reacts to groups one level down. foreach (var componentContext in pageContext.ChildContexts) { - var componentHidden = ExpressionEvaluator.EvaluateBooleanExpression(state, componentContext, "hidden", false); - if (componentHidden) + if (componentContext.IsHidden == true) { layoutSettings.Components.ExcludeFromPdf.Add(componentContext.Component.Id); } @@ -57,4 +55,4 @@ public async Task FormatPdf(LayoutSettings layoutSettings, objec } return layoutSettings; } -} \ No newline at end of file +} diff --git a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs index dc1cbbc26..396bc4275 100644 --- a/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs +++ b/src/Altinn.App.Core/Helpers/DataModel/DataModel.cs @@ -104,7 +104,7 @@ public DataModel(object serviceModel) return null; } - private static readonly Regex KeyPartRegex = new Regex(@"^(\w+)\[(\d+)\]?$"); + private static readonly Regex KeyPartRegex = new Regex(@"^([^\s\[\]\.]+)\[(\d+)\]?$"); internal static (string key, int? index) ParseKeyPart(string keypart) { if (keypart.Length == 0) @@ -130,13 +130,15 @@ private static void AddIndiciesRecursive(List ret, Type currentModelType var prop = currentModelType.GetProperties().FirstOrDefault(p => IsPropertyWithJsonName(p, key)); if (prop is null) { - throw new DataModelException($"Unknown model property {key} in {fullKey}"); + throw new DataModelException($"Unknown model property {key} in {string.Join(".", ret)}.{key}"); } + var currentIndex = groupIndex ?? (indicies.Length > 0 ? indicies[0] : null); + var childType = prop.PropertyType; // Strings are enumerable in C# // Other enumerable types is treated as an collection - if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable))) + if (childType != typeof(string) && childType.IsAssignableTo(typeof(System.Collections.IEnumerable)) && currentIndex is not null) { // Hope the first generic argument is tied to the IEnumerable implementation var childTypeEnumerableParameter = childType.GetGenericArguments().FirstOrDefault(); @@ -146,23 +148,13 @@ private static void AddIndiciesRecursive(List ret, Type currentModelType throw new DataModelException("DataModels must have generic IEnumerable<> implementation for list"); } - if (groupIndex is null) + ret.Add($"{key}[{currentIndex}]"); + if (indicies.Length > 0) { - if (indicies.Length == 0) - { - throw new DataModelException($"Missmatch in indicies in {fullKey} on key {key} and [{string.Join(", ", originalIndicies.ToArray())}]"); - } - ret.Add($"{key}[{indicies[0]}]"); + indicies = indicies.Slice(1); } - else - { - ret.Add($"{key}[{groupIndex}]"); - // Ignore indexes after a literal index has been set - indicies = new int[] { groupIndex.Value }; - } - - AddIndiciesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), fullKey, indicies.Slice(1), originalIndicies); + AddIndiciesRecursive(ret, childTypeEnumerableParameter, keys.Slice(1), fullKey, indicies, originalIndicies); } else { @@ -215,18 +207,12 @@ private static bool IsPropertyWithJsonName(PropertyInfo propertyInfo, string key } /// - public void RemoveField(string key) + public void RemoveField(string key, bool deleteRows = false) { var keys_split = key.Split('.'); var keys = keys_split[0..^1]; var (lastKey, lastGroupIndex) = ParseKeyPart(keys_split[^1]); - if (lastGroupIndex is not null) - { - // TODO: Consider implementing. Would be required for rowHidden on groups - throw new NotImplementedException($"Deleting elements in List is not implemented {key}"); - } - var containingObject = GetModelDataRecursive(keys, 0, _serviceModel, default); if (containingObject is null) { @@ -234,6 +220,7 @@ public void RemoveField(string key) return; } + if (containingObject is System.Collections.IEnumerable) { throw new NotImplementedException($"Tried to remove field {key}, ended in an enumerable"); @@ -246,9 +233,33 @@ public void RemoveField(string key) return; } - var nullValue = property.PropertyType.GetTypeInfo().IsValueType ? Activator.CreateInstance(property.PropertyType) : null; + if (lastGroupIndex is not null) + { + // Remove row from list + var propertyValue = property.GetValue(containingObject); + if (propertyValue is not System.Collections.IList listValue) + { + throw new ArgumentException($"Tried to remove row {key}, ended in a non-list ({propertyValue?.GetType()})"); + } + + if (deleteRows) + { + listValue.RemoveAt(lastGroupIndex.Value); + } + else + { - property.SetValue(containingObject, nullValue); + var genericType = listValue.GetType().GetGenericArguments().FirstOrDefault(); + var nullValue = genericType?.IsValueType == true ? Activator.CreateInstance(genericType) : null; + listValue[lastGroupIndex.Value] = nullValue; + } + } + else + { + // Set property to null + var nullValue = property.PropertyType.GetTypeInfo().IsValueType ? Activator.CreateInstance(property.PropertyType) : null; + property.SetValue(containingObject, nullValue); + } } /// @@ -298,4 +309,4 @@ private bool VerifyKeyRecursive(string[] keys, int index, Type currentModel) return VerifyKeyRecursive(keys, index + 1, childType); } -} \ No newline at end of file +} diff --git a/src/Altinn.App.Core/Helpers/IDataModel.cs b/src/Altinn.App.Core/Helpers/IDataModel.cs index 3508cd32a..629390809 100644 --- a/src/Altinn.App.Core/Helpers/IDataModel.cs +++ b/src/Altinn.App.Core/Helpers/IDataModel.cs @@ -39,7 +39,7 @@ public interface IDataModelAccessor /// /// Remove a value from the wrapped datamodel /// - void RemoveField(string key); + void RemoveField(string key, bool deleteRows = false); /// /// Verify that a Key is a valid lookup for the datamodel diff --git a/src/Altinn.App.Core/Implementation/DefaultTaskEvents.cs b/src/Altinn.App.Core/Implementation/DefaultTaskEvents.cs index cc55b5c4f..0d4985c44 100644 --- a/src/Altinn.App.Core/Implementation/DefaultTaskEvents.cs +++ b/src/Altinn.App.Core/Implementation/DefaultTaskEvents.cs @@ -260,7 +260,7 @@ private async Task RemoveHiddenData(Instance instance, Guid instanceGuid, List public IEnumerable Decendants { - get + get { var stack = new Stack(ChildContexts); while (stack.Any()) @@ -79,4 +95,4 @@ public IEnumerable Decendants // // Ignore RowIndicies and ChildContexts // return HashCode.Combine(Component.PageId, Component.Id); // } -} \ No newline at end of file +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs index f6302b572..35add8b68 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs @@ -10,14 +10,20 @@ public class RepeatingGroupComponent : GroupComponent /// /// Constructor for RepeatingGroupComponent /// - public RepeatingGroupComponent(string id, string type, IReadOnlyDictionary? dataModelBindings, IEnumerable children, IEnumerable? childIDs, int maxCount, Expression? hidden, Expression? required, Expression? readOnly, IReadOnlyDictionary? additionalProperties) : + public RepeatingGroupComponent(string id, string type, IReadOnlyDictionary? dataModelBindings, IEnumerable children, IEnumerable? childIDs, int maxCount, Expression? hidden, Expression? hiddenRow, Expression? required, Expression? readOnly, IReadOnlyDictionary? additionalProperties) : base(id, type, dataModelBindings, children, childIDs, hidden, required, readOnly, additionalProperties) { MaxCount = maxCount; + HiddenRow = hiddenRow; } /// /// Maximum number of repeatitions of this repating group /// public int MaxCount { get; } + + /// + /// Layout Expression that can be evaluated to see if row should be hidden + /// + public Expression? HiddenRow { get; } } diff --git a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs index 10c7e13ce..607bdc391 100644 --- a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs +++ b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs @@ -226,6 +226,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt string? type = null; Dictionary? dataModelBindings = null; Expression? hidden = null; + Expression? hiddenRow = null; Expression? required = null; Expression? readOnly = null; // Custom properities for group @@ -280,6 +281,9 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt case "hidden": hidden = ExpressionConverter.ReadNotNull(ref reader, options); break; + case "hiddenrow": + hiddenRow = ExpressionConverter.ReadNotNull(ref reader, options); + break; case "required": required = ExpressionConverter.ReadNotNull(ref reader, options); break; @@ -327,7 +331,7 @@ private BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOpt throw new JsonException($"A group id:\"{id}\" with maxCount: {maxCount} does not have a \"group\" dataModelBinding"); } - var repComponent = new RepeatingGroupComponent(id, type, dataModelBindings, new List(), children, maxCount, hidden, required, readOnly, additionalProperties); + var repComponent = new RepeatingGroupComponent(id, type, dataModelBindings, new List(), children, maxCount, hidden, hiddenRow, required, readOnly, additionalProperties); return repComponent; } else diff --git a/test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs b/test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs index 623004596..2816a3237 100644 --- a/test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs +++ b/test/Altinn.App.Core.Tests/Helpers/JsonDataModel.cs @@ -143,7 +143,7 @@ public string AddIndicies(string key, ReadOnlySpan indicies = default) } /// - public void RemoveField(string key) + public void RemoveField(string key, bool deleteRows = false) { throw new NotImplementedException("Impossible to remove fields in a json model"); } @@ -153,4 +153,4 @@ public bool VerifyKey(string key) { throw new NotImplementedException("Impossible to verify keys in a json model"); } -} \ No newline at end of file +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index 4a484637b..f8842afc7 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -76,7 +76,7 @@ public class ComponentContextForTestSpec public ComponentContext ToContext(LayoutModel model) { - return new ComponentContext(model.GetComponent(CurrentPageName, ComponentId), RowIndices); + return new ComponentContext(model.GetComponent(CurrentPageName, ComponentId), RowIndices, null); } public static ComponentContextForTestSpec FromContext(ComponentContext context) @@ -89,4 +89,4 @@ public static ComponentContextForTestSpec FromContext(ComponentContext context) RowIndices = context.RowIndices }; } -} \ No newline at end of file +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index 1c47ac859..0169565c7 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -88,4 +88,4 @@ public override string ToString() { return Name ?? "Unknown invalid test case"; } -} \ No newline at end of file +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/FirstPage.json b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/FirstPage.json new file mode 100644 index 000000000..6e7a1d0fb --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/FirstPage.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "single", + "type": "Checkboxes", + "dataModelBindings": { + "simpleBinding": "some.notRepeating" + }, + "options": [ + { "label": "Vis gruppe", "value": "showGroup" } + ] + }, + { + "id": "options-with-source", + "type": "RadioButtons", + "source": { + "group": "some.data", + "label": "some.text.key", + "value": "some.data[{0}].binding" + } + }, + { + "id": "options-with-id", + "type": "RadioButtons", + "optionsId": "ASF_Land" + }, + { + "id": "comp", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "some.data.binding" + } + }, + { + "id": "gruppe1", + "type": "Group", + "children": ["comp","comp1", "comp2"], + "dataModelBindings": { + "group": "some.data" + }, + "maxCount": 5, + "hidden": ["notEquals", ["dataModel", "some.notRepeating"], "showGroup"], + "hiddenRow": ["equals", ["dataModel", "some.data.binding"], "hideRow"] + }, + { + "id": "comp1", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "some.data.binding2" + }, + "hidden": ["equals", ["component", "comp2"], "hidden"] + }, + { + "id": "comp2", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "some.data.binding3" + } + } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs new file mode 100644 index 000000000..43e3a233f --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/RunTest3.cs @@ -0,0 +1,157 @@ +using System.Text.Json.Serialization; +using Altinn.App.Core.Internal.Expressions; +using FluentAssertions; +using Xunit; + +namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests.Test3; + +public class RunTest3 +{ + [Fact] + public async Task ValidateDataModel() + { + var state = await LayoutTestUtils.GetLayoutModelTools(new DataModel(), "Test3"); + var errors = state.GetModelErrors(); + errors.Should().BeEmpty(); + } + + [Fact] + public async Task RemoveRowDataFromGroup() + { + var data = new DataModel() + { + Some = new() + { + NotRepeating = "showGroup", + Data = new() + { + new() + { + Binding = default, + Binding2 = default, + Binding3 = default + }, + new() + { + Binding = "binding", + Binding2 = 2, + Binding3 = default + }, + new() + { + Binding = "hideRow", + Binding2 = 3, + Binding3 = "text" + } + } + } + }; + var state = await LayoutTestUtils.GetLayoutModelTools(data, "Test3"); + var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + + // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists + hidden.Should().BeEquivalentTo(new List { "some.data[2]" }); + + // Verify before removing data + data.Some.Data.Should().HaveCount(3); + data.Some.Data[0].Binding.Should().BeNull(); + data.Some.Data[0].Binding2.Should().Be(0); // binding is not nullable, but will be reset to zero + data.Some.Data[1].Binding.Should().Be("binding"); + data.Some.Data[1].Binding2.Should().Be(2); + data.Some.Data[2].Binding.Should().Be("hideRow"); + data.Some.Data[2].Binding2.Should().Be(3); + data.Some.Data[2].Binding3.Should().Be("text"); + LayoutEvaluator.RemoveHiddenData(state); + + // Verify row not deleted but fields null + data.Some.Data.Should().HaveCount(3); + data.Some.Data[0].Binding.Should().BeNull(); + data.Some.Data[0].Binding2.Should().Be(0); // binding is not nullable, but will be reset to zero + data.Some.Data[1].Binding.Should().Be("binding"); + data.Some.Data[1].Binding2.Should().Be(2); + data.Some.Data[2].Should().BeNull(); + } + + [Fact] + public async Task RemoveRowFromGroup() + { + var data = new DataModel() + { + Some = new() + { + NotRepeating = "showGroup", + Data = new() + { + new() + { + Binding = default, + Binding2 = default, + Binding3 = default + }, + new() + { + Binding = "binding", + Binding2 = 2, + Binding3 = default + }, + new() + { + Binding = "hideRow", + Binding2 = 3, + Binding3 = "text" + } + } + } + }; + var state = await LayoutTestUtils.GetLayoutModelTools(data, "Test3"); + var hidden = LayoutEvaluator.GetHiddenFieldsForRemoval(state); + + // Should try to remove "some.data[0].binding2", because it is not nullable int and the parent object exists + hidden.Should().BeEquivalentTo(new List { "some.data[2]" }); + + // Verify before removing data + data.Some.Data.Should().HaveCount(3); + data.Some.Data[0].Binding.Should().BeNull(); + data.Some.Data[0].Binding2.Should().Be(0); // binding is not nullable, but will be reset to zero + data.Some.Data[1].Binding.Should().Be("binding"); + data.Some.Data[1].Binding2.Should().Be(2); + data.Some.Data[2].Binding.Should().Be("hideRow"); + data.Some.Data[2].Binding2.Should().Be(3); + data.Some.Data[2].Binding3.Should().Be("text"); + + // Verify rows deleted + LayoutEvaluator.RemoveHiddenData(state, true); + data.Some.Data.Should().HaveCount(2); + data.Some.Data[0].Binding.Should().BeNull(); + data.Some.Data[0].Binding2.Should().Be(0); // binding is not nullable, but will be reset to zero + data.Some.Data[1].Binding.Should().Be("binding"); + data.Some.Data[1].Binding2.Should().Be(2); + } +} + +public class DataModel +{ + [JsonPropertyName("some")] + public Some Some { get; set; } +} + +public class Some +{ + [JsonPropertyName("notRepeating")] + public string NotRepeating { get; set; } + + [JsonPropertyName("data")] + public List Data { get; set; } +} + +public class Data +{ + [JsonPropertyName("binding")] + public string Binding { get; set; } + + [JsonPropertyName("binding2")] + public int Binding2 { get; set; } + + [JsonPropertyName("binding3")] + public string Binding3 { get; set; } +} \ No newline at end of file diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/SecondPage.json b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/SecondPage.json new file mode 100644 index 000000000..4e6d4a07a --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/Test3/SecondPage.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "firstField", + "type": "Summary", + "componentRef": "gruppe1", + "pageRef": "FirstPage" + } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs index 0baa398d6..3a9afbf14 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestDataModel.cs @@ -245,6 +245,122 @@ public void TestRemoveFields() model.Friends.First().Friends.Should().BeNull(); } + [Fact] + public void TestRemoveRows() + { + var model = new Model() + { + Id = 2, + Name = new() + { + Value = "Per" + }, + Friends = new List + { + new() + { + Name = new() + { + Value = "Første venn" + }, + Age = 1235, + Friends = new List + { + new() + { + Name = new() + { + Value = "Første venn sin første venn", + }, + Age = 233 + }, + new() + { + Name = new() + { + Value = "Første venn sin andre venn", + }, + Age = 233 + }, + new() + { + Name = new() + { + Value = "Første venn sin tredje venn", + }, + Age = 233 + } + } + }, + new() + { + Name = new() + { + Value = "Andre venn" + }, + Age = 1235, + Friends = new List + { + new() + { + Name = new() + { + Value = "Andre venn sin venn", + }, + Age = 233 + } + } + }, + new() + { + Name = new() + { + Value = "Tredje venn" + }, + Age = 1235, + Friends = new List + { + new() + { + Name = new() + { + Value = "Tredje venn sin venn", + }, + Age = 233 + } + } + } + } + }; + var serializedModel = System.Text.Json.JsonSerializer.Serialize(model); + + // deleteRows = false + var model1 = System.Text.Json.JsonSerializer.Deserialize(serializedModel)!; + IDataModelAccessor modelHelper1 = new DataModel(model1); + + modelHelper1.RemoveField("friends[0].friends[0]"); + model1.Friends![0].Friends![0].Should().BeNull(); + model1.Friends![0].Friends!.Count.Should().Be(3); + model1.Friends[0].Friends![1].Name!.Value.Should().Be("Første venn sin andre venn"); + + modelHelper1.RemoveField("friends[1]"); + model1.Friends[1].Should().BeNull(); + model1.Friends.Count.Should().Be(3); + model1.Friends[2].Name!.Value.Should().Be("Tredje venn"); + + // deleteRows = true + var model2 = System.Text.Json.JsonSerializer.Deserialize(serializedModel)!; + IDataModelAccessor modelHelper2 = new DataModel(model2); + + modelHelper2.RemoveField("friends[0].friends[0]", true); + model2.Friends![0].Friends!.Count.Should().Be(2); + model2.Friends[0].Friends![0].Name!.Value.Should().Be("Første venn sin andre venn"); + + modelHelper2.RemoveField("friends[1]", true); + model2.Friends.Count.Should().Be(2); + model2.Friends[1].Name!.Value.Should().Be("Tredje venn"); + } + [Fact] public void TestErrorCases() { @@ -315,11 +431,9 @@ public void TestAddIndicies() // Don't add indicies if they are specified in input modelHelper.AddIndicies("friends[3]", new int[] { 0 }).Should().Be("friends[3]"); - // Seccond index is ignored if the first is explicit - modelHelper.Invoking(m => m.AddIndicies("friends[0].friends", new int[] { 0, 3 })) - .Should() - .Throw() - .WithMessage("Missmatch*"); + // First index is ignored if it is explicit + modelHelper.AddIndicies("friends[0].friends", new int[] { 2, 3 }).Should().Be("friends[0].friends[3]"); + } [Fact] @@ -376,7 +490,7 @@ public class Model [JsonProperty("friends")] [JsonPropertyName("friends")] - public IEnumerable? Friends { get; set; } + public IList? Friends { get; set; } } public class Name @@ -399,5 +513,5 @@ public class Friend // Infinite recursion. Simple way for testing [JsonProperty("friends")] [JsonPropertyName("friends")] - public IEnumerable? Friends { get; set; } -} \ No newline at end of file + public IList? Friends { get; set; } +}