diff --git a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs index ac997a47be..b56a437739 100644 --- a/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs +++ b/src/Altinn.App.Core/Features/Validation/Default/ExpressionValidator.cs @@ -143,6 +143,7 @@ internal async Task> ValidateFormData( continue; } var context = new ComponentContext( + evaluatorState, component: null, rowIndices: DataModel.GetRowIndices(resolvedField.Field), dataElementIdentifier: resolvedField.DataElementIdentifier diff --git a/src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs b/src/Altinn.App.Core/Features/Validation/Default/RequiredLayoutValidator.cs similarity index 100% rename from src/Altinn.App.Core/Features/Validation/Default/RequiredValidator.cs rename to src/Altinn.App.Core/Features/Validation/Default/RequiredLayoutValidator.cs diff --git a/src/Altinn.App.Core/Helpers/NaturalStringComparerPolyfill.cs b/src/Altinn.App.Core/Helpers/NaturalStringComparerPolyfill.cs new file mode 100644 index 0000000000..98fdb90e3b --- /dev/null +++ b/src/Altinn.App.Core/Helpers/NaturalStringComparerPolyfill.cs @@ -0,0 +1,99 @@ +#if !NET10_0_OR_GREATER +using System.Globalization; + +namespace Altinn.App.Core.Helpers; + +/// +/// Provides a custom string comparer that performs natural sorting, comparing numeric substrings as numbers rather than strings. +/// +/// +/// This comparer is used to ensure that strings containing numeric values are ordered logically (e.g., "item2" < "item10"). +/// +/// +/// This class is marked as obsolete and is planned for removal when upgrading to .NET 10, as it will include built-in support for natural string comparison. +/// +// [Obsolete("This utility can be removed in .NET 10")] +internal sealed class NaturalStringComparerPolyfill : IComparer +{ + private static readonly CompareInfo _compareInfo = CultureInfo.InvariantCulture.CompareInfo; + + internal static readonly NaturalStringComparerPolyfill Instance = new(); + + /// + public int Compare(string? x, string? y) + { + if (ReferenceEquals(x, y)) + return 0; + if (x is null) + return -1; + if (y is null) + return 1; + + int i = 0, + j = 0; + while (i < x.Length && j < y.Length) + { + char cx = x[i]; + char cy = y[j]; + + bool dx = IsAsciiDigit(cx); + bool dy = IsAsciiDigit(cy); + + if (dx && dy) + { + // Read full digit runs + int sx = i; + while (i < x.Length && IsAsciiDigit(x[i])) + i++; + int sy = j; + while (j < y.Length && IsAsciiDigit(y[j])) + j++; + + // Skip leading zeros + int zx = sx; + while (zx < i && x[zx] == '0') + zx++; + int zy = sy; + while (zy < j && y[zy] == '0') + zy++; + + int lenX = i - zx; + int lenY = j - zy; + + // Compare by numeric length (ignoring leading zeros) + if (lenX != lenY) + return lenX.CompareTo(lenY); + + // Same length: compare digit-by-digit + for (int k = 0; k < lenX; k++) + { + int cmp = x[zx + k].CompareTo(y[zy + k]); + if (cmp != 0) + return cmp; + } + + // Perfect numeric tie: shorter with more leading zeros comes first + int leadingZerosCmp = (i - sx).CompareTo(j - sy); + if (leadingZerosCmp != 0) + return leadingZerosCmp; + + // Otherwise continue + } + else + { + // ordnal for non digit chars + int cmp = _compareInfo.Compare(x, i, 1, y, j, 1, CompareOptions.Ordinal); + if (cmp != 0) + return cmp; + i++; + j++; + } + } + + // Shorter string first if all equal so far + return (x.Length - i).CompareTo(y.Length - j); + } + + private static bool IsAsciiDigit(char c) => c >= '0' && c <= '9'; +} +#endif diff --git a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs index ebff5cd586..5eb5335806 100644 --- a/src/Altinn.App.Core/Implementation/AppResourcesSI.cs +++ b/src/Altinn.App.Core/Implementation/AppResourcesSI.cs @@ -372,15 +372,14 @@ private LayoutSetComponent LoadLayout(LayoutSet layoutSet, List dataTy string folder = Path.Join(_settings.AppBasePath, _settings.UiFolder, layoutSet.Id, "layouts"); foreach (var page in order) { - var pageBytes = File.ReadAllBytes(Path.Join(folder, page + ".json")); - // Set the PageName using AsyncLocal before deserializing. - PageComponentConverter.SetAsyncLocalPageName(layoutSet.Id, page); - pages.Add( - System.Text.Json.JsonSerializer.Deserialize( - pageBytes.RemoveBom(), - _jsonSerializerOptions - ) ?? throw new InvalidDataException(page + ".json is \"null\"") + var pagePath = Path.Join(folder, page + ".json"); + PathHelper.EnsureLegalPath(folder, pagePath); + var pageBytes = File.ReadAllBytes(pagePath); + using var document = JsonDocument.Parse( + pageBytes, + new JsonDocumentOptions() { AllowTrailingCommas = true, CommentHandling = JsonCommentHandling.Skip } ); + pages.Add(new PageComponent(document.RootElement, page, layoutSet.Id)); } // First look at the specified data type, but diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs index d1d018822a..c0df1f91aa 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionEvaluator.cs @@ -4,7 +4,6 @@ using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; -using Altinn.App.Core.Models.Layout.Components; namespace Altinn.App.Core.Internal.Expressions; @@ -14,7 +13,7 @@ namespace Altinn.App.Core.Internal.Expressions; public static class ExpressionEvaluator { /// - /// Shortcut for evaluating a boolean expression on a given property on a + /// Shortcut for evaluating a boolean expression on a given property on a /// public static async Task EvaluateBooleanExpression( LayoutEvaluatorState state, @@ -40,6 +39,7 @@ bool defaultReturn JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => defaultReturn, + JsonValueKind.Undefined => defaultReturn, _ => throw new ExpressionEvaluatorTypeErrorException( $"Return was not boolean. Was {result} of type {result.ValueKind}" ), @@ -72,7 +72,7 @@ bool defaultReturn /// /// private implementation in order to change the types of positional arguments without breaking change. /// - private static async Task EvaluateExpression_internal( + internal static async Task EvaluateExpression_internal( LayoutEvaluatorState state, Expression expr, ComponentContext context, @@ -259,16 +259,16 @@ LayoutEvaluatorState state throw new ArgumentException($"Unable to find component with identifier {componentId}{rowIndexInfo}"); } - if (targetContext.Component is GroupComponent) + if (targetContext.HasChildContexts) { - throw new NotImplementedException("Component lookup for components in groups not implemented"); + throw new NotImplementedException("Component lookup for components that are groups is not implemented"); } if (targetContext.Component?.DataModelBindings.TryGetValue("simpleBinding", out var binding) != true) { throw new ArgumentException("component lookup requires the target component to have a simpleBinding"); } - if (await targetContext.IsHidden(state)) + if (await targetContext.IsHidden()) { return ExpressionValue.Null; } diff --git a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs index b6f126a02e..e6b2512c5a 100644 --- a/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs +++ b/src/Altinn.App.Core/Internal/Expressions/ExpressionValue.cs @@ -22,27 +22,35 @@ namespace Altinn.App.Core.Internal.Expressions; // private readonly ExpressionValue[]? _arrayValue = null; /// - /// Constructor for NULL value + /// Constructor for NULL value (structs require a public parameterless constructor) /// public ExpressionValue() + : this(JsonValueKind.Null) { } + + private ExpressionValue(JsonValueKind valueKind) { - _valueKind = JsonValueKind.Null; + _valueKind = valueKind; } /// /// Convenient accessor for NULL value /// - public static ExpressionValue Null => new(); + public static ExpressionValue Null => new(JsonValueKind.Null); /// /// Convenient accessor for true value /// - public static ExpressionValue True => new(true); + public static ExpressionValue True => new(JsonValueKind.True); /// /// Convenient accessor for false value /// - public static ExpressionValue False => new(false); + public static ExpressionValue False => new(JsonValueKind.False); + + /// + /// Convenient accessor for undefined value + /// + public static ExpressionValue Undefined => new(JsonValueKind.Undefined); private ExpressionValue(bool? value) { @@ -261,7 +269,7 @@ public override string ToString() => JsonValueKind.Number => Number.ToString(CultureInfo.InvariantCulture), // JsonValueKind.Object => JsonSerializer.Serialize(Object), // JsonValueKind.Array => JsonSerializer.Serialize(Array), - _ => throw new InvalidOperationException("Invalid value kind"), + _ => throw new InvalidOperationException($"Invalid value kind {ValueKind}"), }; /// @@ -275,6 +283,7 @@ public override string ToString() => ValueKind switch { JsonValueKind.Null => null, + JsonValueKind.Undefined => null, JsonValueKind.True => "true", JsonValueKind.False => "false", JsonValueKind.String => String switch @@ -396,6 +405,7 @@ public override void Write(Utf8JsonWriter writer, ExpressionValue value, JsonSer switch (value.ValueKind) { case JsonValueKind.Null: + case JsonValueKind.Undefined: writer.WriteNullValue(); break; case JsonValueKind.True: diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs index 79f53dea73..7f467662d4 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluator.cs @@ -1,7 +1,6 @@ using Altinn.App.Core.Helpers; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; -using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Validation; namespace Altinn.App.Core.Internal.Expressions; @@ -22,18 +21,20 @@ public static async Task> GetHiddenFieldsForRemoval(LayoutEv var pageContexts = await state.GetComponentContexts(); foreach (var pageContext in pageContexts) { - await HiddenFieldsForRemovalRecurs(state, hiddenModelBindings, nonHiddenModelBindings, pageContext); + await HiddenFieldsForRemovalRecurs(state, hiddenModelBindings, nonHiddenModelBindings, pageContext, []); } - var forRemoval = hiddenModelBindings.Except(nonHiddenModelBindings); - return forRemoval.ToList(); + var forRemoval = hiddenModelBindings.Except(nonHiddenModelBindings).ToList(); + + return forRemoval; } private static async Task HiddenFieldsForRemovalRecurs( LayoutEvaluatorState state, HashSet hiddenModelBindings, HashSet nonHiddenModelBindings, - ComponentContext context + ComponentContext context, + IReadOnlyList ignoredPrefixes ) { if (context.Component is null) @@ -44,44 +45,41 @@ ComponentContext context ); } - var isHidden = await context.IsHidden(state); - if (context.Component is RepeatingGroupRowComponent or RepeatingGroupComponent) - { - if (context.Component.DataModelBindings.TryGetValue("group", out var groupBinding)) - { - var indexedBinding = await state.AddInidicies(groupBinding, context); - (isHidden ? hiddenModelBindings : nonHiddenModelBindings).Add(indexedBinding); - } + var isHidden = await context.IsHidden(); - if (isHidden) - return; - } + List childIgnoredPrefixes = [.. ignoredPrefixes]; - // Recurse children - foreach (var childContext in context.ChildContexts) - { - await HiddenFieldsForRemovalRecurs(state, hiddenModelBindings, nonHiddenModelBindings, childContext); - } - - // Remove data if hidden - foreach (var (bindingName, binding) in context.Component.DataModelBindings) + // Schedule fields for removal + foreach (var (_, binding) in context.Component.DataModelBindings) { - if (bindingName == "group") + var indexedBinding = await state.AddInidicies(binding, context); + if (ignoredPrefixes.Any(prefix => indexedBinding.StartsWith(prefix))) { - continue; + continue; // Skip fields with ignored prefixes } - var indexedBinding = await state.AddInidicies(binding, context); - if (isHidden) { hiddenModelBindings.Add(indexedBinding); + childIgnoredPrefixes.Add(indexedBinding); } else { nonHiddenModelBindings.Add(indexedBinding); } } + + // Recurse children + foreach (var childContext in context.ChildContexts) + { + await HiddenFieldsForRemovalRecurs( + state, + hiddenModelBindings, + nonHiddenModelBindings, + childContext, + childIgnoredPrefixes + ); + } } /// @@ -99,7 +97,9 @@ public static void RemoveHiddenData(LayoutEvaluatorState state, RowRemovalOption public static async Task RemoveHiddenDataAsync(LayoutEvaluatorState state, RowRemovalOption rowRemovalOption) { var fields = await GetHiddenFieldsForRemoval(state); - foreach (var dataReference in fields) + + // Ensure fields with higher row numbers are removed before fields with lower row numbers. + foreach (var dataReference in OrderByListIndexReverse(fields)) { await state.RemoveDataField(dataReference, rowRemovalOption); } @@ -127,7 +127,7 @@ ComponentContext context ) { ArgumentNullException.ThrowIfNull(context.Component); - var hidden = await context.IsHidden(state); + var hidden = await context.IsHidden(); if (!hidden) { foreach (var childContext in context.ChildContexts) @@ -141,7 +141,11 @@ ComponentContext context foreach (var (bindingName, binding) in context.Component.DataModelBindings) { var value = await state.GetModelData(binding, context.DataElementIdentifier, context.RowIndices); - if (value is null) + if ( + (value is null) + || (value is string s && string.IsNullOrWhiteSpace(s)) + || value is System.Collections.ICollection { Count: 0 } + ) { var field = await state.AddInidicies(binding, context); validationIssues.Add( @@ -160,4 +164,20 @@ ComponentContext context } } } + +#if NET10_0_OR_GREATER + private static readonly IComparer _naturalStringComparer = StringComparer.Create( + CultureInfo.InvariantCulture, + CompareOptions.NumericOrdering + ); +#else + private static readonly IComparer _naturalStringComparer = NaturalStringComparerPolyfill.Instance; +#endif + + internal static IEnumerable OrderByListIndexReverse(List fields) + { + return fields + .OrderByDescending(f => f.DataElementIdentifier.Guid) + .ThenByDescending(f => f.Field, _naturalStringComparer); + } } diff --git a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs index 5fb781977b..2f476eda3c 100644 --- a/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs +++ b/src/Altinn.App.Core/Internal/Expressions/LayoutEvaluatorState.cs @@ -6,7 +6,6 @@ using Altinn.App.Core.Models; using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; -using Altinn.App.Core.Models.Layout.Components; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Internal.Expressions; @@ -20,11 +19,10 @@ public class LayoutEvaluatorState private readonly LayoutModel? _componentModel; private readonly ITranslationService _translationService; private readonly FrontEndSettings _frontEndSettings; - private readonly Instance _instanceContext; private readonly string? _gatewayAction; private readonly string? _language; private readonly TimeZoneInfo? _timeZone; - private readonly Lazy>?> _rootContext; + private List? _rootContext; /// /// Constructor for LayoutEvaluatorState. Usually called via that can be fetched from dependency injection. @@ -50,23 +48,31 @@ public LayoutEvaluatorState( _componentModel = componentModel; _translationService = translationService; _frontEndSettings = frontEndSettings; - _instanceContext = dataAccessor.Instance; + Instance = dataAccessor.Instance; _gatewayAction = gatewayAction; _language = language; _timeZone = timeZone; - _rootContext = new(() => _componentModel?.GenerateComponentContexts(_instanceContext, _dataModel)); } + /// + /// Get the Instance object that is referenced for evaluation. + /// + public Instance Instance { get; } + /// /// Get a hierarchy of the different contexts in the component model (remember to iterate ) /// public async Task> GetComponentContexts() { - if (_rootContext.Value is null) + if (_rootContext is null) { - throw new InvalidOperationException("Component model not loaded"); + if (_componentModel is null) + { + throw new InvalidOperationException("Component model not loaded"); + } + _rootContext = await _componentModel.GenerateComponentContexts(this); } - return (await _rootContext.Value); + return _rootContext; } /// @@ -90,14 +96,14 @@ public async Task> GetComponentContexts() /// /// Get component from componentModel /// - public BaseComponent GetComponent(string pageName, string componentId) + [Obsolete("You need to get a context, not a component", true)] + public void GetComponent(string pageName, string componentId) { - return _componentModel?.GetComponent(pageName, componentId) - ?? throw new InvalidOperationException("Component model not loaded"); + throw new NotSupportedException("GetComponent is not supported, use GetComponentContext instead."); } /// - /// Get a specific component context based on + /// Get a specific component context from the state /// public async Task GetComponentContext( string pageName, @@ -115,7 +121,7 @@ public BaseComponent GetComponent(string pageName, string componentId) // Filter out all contexts that have the wrong Id contexts = contexts.Where(c => c.Component?.Id == componentId); // Filter out contexts that does not have a prefix matching - var filteredContexts = contexts.Where(c => CompareRowIndexes(c.RowIndices, rowIndexes)).ToArray(); + var filteredContexts = contexts.Where(c => RowIndexMatch(rowIndexes, c.RowIndices)).ToArray(); if (filteredContexts.Length == 0) { return null; // No context found @@ -136,19 +142,25 @@ public BaseComponent GetComponent(string pageName, string componentId) ); } - private static bool CompareRowIndexes(int[]? targetRowIndexes, int[]? sourceRowIndexes) + private static bool RowIndexMatch(int[]? searchRowIndexes, int[]? componentRowIndexes) { - if (targetRowIndexes is null) + if (componentRowIndexes is null) { return true; } - if (sourceRowIndexes is null) + if (searchRowIndexes is null) + { + return false; + } + + if (searchRowIndexes.Length < componentRowIndexes.Length) { return false; } - for (int i = 0; i < targetRowIndexes.Length; i++) + + for (int i = 0; i < componentRowIndexes.Length; i++) { - if (targetRowIndexes[i] != sourceRowIndexes[i]) + if (searchRowIndexes[i] != componentRowIndexes[i]) { return false; } @@ -192,13 +204,14 @@ public string GetInstanceContext(string key) // Instance context only supports a small subset of variables from the instance return key switch { - "instanceOwnerPartyId" => _instanceContext.InstanceOwner.PartyId, - "appId" => _instanceContext.AppId, - "instanceId" => _instanceContext.Id, + "instanceOwnerPartyId" => Instance.InstanceOwner?.PartyId + ?? throw new InvalidOperationException("InstanceOwner or PartyId is null"), + "appId" => Instance.AppId ?? throw new InvalidOperationException("AppId is null"), + "instanceId" => Instance.Id ?? throw new InvalidOperationException("InstanceId is null"), "instanceOwnerPartyType" => ( - !string.IsNullOrWhiteSpace(_instanceContext.InstanceOwner.OrganisationNumber) ? "org" - : !string.IsNullOrWhiteSpace(_instanceContext.InstanceOwner.PersonNumber) ? "person" - : !string.IsNullOrWhiteSpace(_instanceContext.InstanceOwner.Username) ? "selfIdentified" + !string.IsNullOrWhiteSpace(Instance.InstanceOwner?.OrganisationNumber) ? "org" + : !string.IsNullOrWhiteSpace(Instance.InstanceOwner?.PersonNumber) ? "person" + : !string.IsNullOrWhiteSpace(Instance.InstanceOwner?.Username) ? "selfIdentified" : "unknown" ), _ => throw new ExpressionEvaluatorTypeErrorException($"Unknown Instance context property {key}"), @@ -210,7 +223,7 @@ public string GetInstanceContext(string key) /// public int CountDataElements(string dataTypeId) { - return _instanceContext.Data.Count(d => d.DataType == dataTypeId); + return Instance.Data?.Count(d => d.DataType == dataTypeId) ?? 0; } /// @@ -252,7 +265,7 @@ public async Task AddInidicies( /// internal DataElementIdentifier GetDefaultDataElementId() { - return _componentModel?.GetDefaultDataElementId(_instanceContext) + return _componentModel?.GetDefaultDataElementId(Instance) ?? throw new InvalidOperationException("Component model not loaded"); } @@ -266,6 +279,15 @@ public async Task TranslateText(string textKey, ComponentContext context return await _translationService.TranslateTextKey(textKey, this, context) ?? textKey; } + internal async Task GetModelDataCount( + ModelBinding groupBinding, + DataElementIdentifier defaultDataElementIdentifier, + int[]? indexes + ) + { + return await _dataModel.GetModelDataCount(groupBinding, defaultDataElementIdentifier, indexes); + } + // /// // /// Verify all components that dataModel references are correct // /// diff --git a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs index 63bf425357..f23fdbdece 100644 --- a/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs +++ b/src/Altinn.App.Core/Internal/Process/ExpressionsExclusiveGateway.cs @@ -91,7 +91,12 @@ ProcessGatewayInformation processGatewayInformation DataElementIdentifier dataElement = instance.Data.Find(d => d.DataType == dataTypeId) ?? new DataElementIdentifier(); - var componentContext = new ComponentContext(component: null, rowIndices: null, dataElement); + var componentContext = new ComponentContext( + state, + component: null, + rowIndices: null, + dataElementIdentifier: dataElement + ); var result = await ExpressionEvaluator.EvaluateExpression(state, expression, componentContext); if (result is true) { diff --git a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs index 93520d26cb..b0d829faf2 100644 --- a/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs +++ b/src/Altinn.App.Core/Models/Expressions/ComponentContext.cs @@ -1,7 +1,7 @@ using System.Diagnostics; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Layout; -using Altinn.App.Core.Models.Layout.Components; +using Altinn.App.Core.Models.Layout.Components.Base; namespace Altinn.App.Core.Models.Expressions; @@ -16,19 +16,30 @@ public sealed class ComponentContext /// Constructor for ComponentContext /// public ComponentContext( + LayoutEvaluatorState state, BaseComponent? component, int[]? rowIndices, DataElementIdentifier dataElementIdentifier, List? childContexts = null ) { + State = state; DataElementIdentifier = dataElementIdentifier; Component = component; RowIndices = rowIndices; - ChildContexts = childContexts ?? []; - foreach (var child in ChildContexts) + if (childContexts is null) { - child.Parent = this; + HasChildContexts = false; + ChildContexts = []; + } + else + { + HasChildContexts = true; + ChildContexts = childContexts; + foreach (var child in ChildContexts) + { + child.Parent = this; + } } } @@ -47,22 +58,45 @@ public ComponentContext( /// /// Memoized way to check if the component is hidden /// - public async Task IsHidden(LayoutEvaluatorState state) + public async Task IsHidden() { if (_isHidden.HasValue) { return _isHidden.Value; } - if (Parent is not null && await Parent.IsHidden(state)) + if (Parent is not null && await Parent.IsHidden()) { _isHidden = true; return _isHidden.Value; } - _isHidden = await ExpressionEvaluator.EvaluateBooleanExpression(state, this, "hidden", false); + // If the data has already been cleaned, rows are set to null for validation + // but if the "hiddenRow" expression hides rows with specific values, the expression + // might not hide the row when the data is null. + // + // The only reason for a row to be null is that the data has been cleaned and the row was considered hidden + // so we just check the data and assume it was hidden. + if (Component is RepeatingGroupRowComponent rgc && rgc.DataModelBindings.TryGetValue("group", out var binding)) + { + var data = await State.GetModelData(binding, DataElementIdentifier, RowIndices); + if (data is null) + { + _isHidden = true; + return _isHidden.Value; + } + } + + var hidden = await ExpressionEvaluator.EvaluateBooleanExpression(State, this, "hidden", false); + + _isHidden = hidden; return _isHidden.Value; } + /// + /// Indicates whether this context was initialized with child contexts + /// + public bool HasChildContexts { get; } + /// /// Contexts that logically belongs under this context (eg cell => row => group=> page) /// @@ -73,6 +107,11 @@ public async Task IsHidden(LayoutEvaluatorState state) /// public ComponentContext? Parent { get; private set; } + /// + /// The LayoutEvaluatorState that this context is part of + /// + public LayoutEvaluatorState State { get; } + /// /// The Id of the default data element in this context /// @@ -99,22 +138,51 @@ public IEnumerable Descendants private string _debuggerName => $"{Component?.Type}" + (RowIndices is not null ? $"[{string.Join(',', RowIndices)}]" : ""); private string _debuggerDisplay => - $"id:\"{Component?.Id}\"" + (ChildContexts.Count > 0 ? $" ({ChildContexts.Count} children)" : ""); + $"id:\"{Component?.Id}\"" + (HasChildContexts ? $" ({ChildContexts.Count} children)" : ""); private class DebuggerProxy { private readonly ComponentContext _context; + public DebuggerProxy(ComponentContext context) + { + _context = context; + } + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] public List ChildContexts => _context.ChildContexts; public BaseComponent? Component => _context.Component; public ComponentContext? Parent => _context.Parent; public bool? IsHidden => _context._isHidden; public Guid DataElementId => _context.DataElementIdentifier.Guid; + public int[]? RowIndices => _context.RowIndices; - public DebuggerProxy(ComponentContext context) + public DebuggerEvaluatedExpression HiddenExpression => + new(_context.Component?.Hidden ?? new Expression("COMPONENT WAS NULL"), _context); + + public class DebuggerEvaluatedExpression { - _context = context; + private readonly ComponentContext _context; + private readonly Expression _expression; + + public DebuggerEvaluatedExpression(Expression expression, ComponentContext context) + { + _context = context; + _expression = expression; + } + + public ExpressionFunction Function => _expression.Function; + public IEnumerable? Args => + _expression.Args?.Select(e => new DebuggerEvaluatedExpression(e, _context)); + public Task EvaluationResult => + _expression.IsFunctionExpression + ? ExpressionEvaluator.EvaluateExpression_internal(_context.State, _expression, _context, null) + : Task.FromResult(_expression.ValueUnion); + + public override string ToString() + { + return _expression.ToString(); + } } } } diff --git a/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs b/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs index b5b2c5cc47..97539fcd4d 100644 --- a/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs +++ b/src/Altinn.App.Core/Models/Expressions/ExpressionConverter.cs @@ -18,6 +18,22 @@ public override Expression Read(ref Utf8JsonReader reader, Type typeToConvert, J return ReadStatic(ref reader, options); } + /// + /// Reads a JSON element and converts it to an . + /// + public static Expression ReadStatic(JsonElement element) => + element.ValueKind switch + { + JsonValueKind.True => new Expression(true), + JsonValueKind.False => new Expression(false), + JsonValueKind.String => new Expression(element.GetString()), + JsonValueKind.Number => new Expression(element.GetDouble()), + JsonValueKind.Null => new Expression(ExpressionValue.Null), + JsonValueKind.Array => ReadArray(element), + JsonValueKind.Object => throw new JsonException("Invalid type \"object\""), + _ => throw new JsonException(), + }; + /// /// Same as , but without the nullable return type required by the interface. Throw an exeption instead. /// @@ -36,6 +52,35 @@ public static Expression ReadStatic(ref Utf8JsonReader reader, JsonSerializerOpt }; } + private static Expression ReadArray(JsonElement element) + { + if (element.GetArrayLength() == 0) + { + throw new JsonException("Missing function name in expression"); + } + + using var enumerator = element.EnumerateArray(); + if (!enumerator.MoveNext()) + { + throw new JsonException("Missing function name in expression"); + } + + if (enumerator.Current.ValueKind != JsonValueKind.String) + { + throw new JsonException("Function name in expression must be string"); + } + + var functionEnum = ExpressionFunction(enumerator.Current.GetString()); + + var args = new List(); + while (enumerator.MoveNext()) + { + args.Add(ReadStatic(enumerator.Current)); + } + + return new Expression(functionEnum, args); + } + private static Expression ReadArray(ref Utf8JsonReader reader, JsonSerializerOptions options) { reader.Read(); @@ -49,11 +94,7 @@ private static Expression ReadArray(ref Utf8JsonReader reader, JsonSerializerOpt throw new JsonException("Function name in expression should be string"); } - var stringFunction = reader.GetString(); - if (!Enum.TryParse(stringFunction, ignoreCase: false, out var functionEnum)) - { - throw new JsonException($"Function \"{stringFunction}\" not implemented"); - } + var functionEnum = ExpressionFunction(reader.GetString()); var args = new List(); @@ -65,6 +106,16 @@ private static Expression ReadArray(ref Utf8JsonReader reader, JsonSerializerOpt return new Expression(functionEnum, args); } + private static ExpressionFunction ExpressionFunction(string? stringFunction) + { + if (!Enum.TryParse(stringFunction, ignoreCase: false, out var functionEnum)) + { + throw new JsonException($"Function \"{stringFunction}\" not implemented"); + } + + return functionEnum; + } + /// public override void Write(Utf8JsonWriter writer, Expression value, JsonSerializerOptions options) { diff --git a/src/Altinn.App.Core/Models/Layout/Components/Base/BaseComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/Base/BaseComponent.cs new file mode 100644 index 0000000000..963cee1704 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/Base/BaseComponent.cs @@ -0,0 +1,255 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text.Json; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; + +namespace Altinn.App.Core.Models.Layout.Components.Base; + +/// +/// Abstract base class for all components in a layout. +/// This class provides common properties and methods that all components should implement. +/// +public abstract class BaseComponent +{ + /// + /// Constructor that initializes from a JsonElement + /// + protected BaseComponent(JsonElement componentElement, string pageId, string layoutId) + { + if ( + !componentElement.TryGetProperty("id", out JsonElement idElement) + || idElement.ValueKind != JsonValueKind.String + ) + { + throw new JsonException($"Component must have a string 'id' property. Was {idElement.ValueKind}"); + } + + Id = idElement.GetString() ?? throw new UnreachableException(); + + if ( + !componentElement.TryGetProperty("type", out JsonElement typeElement) + || typeElement.ValueKind != JsonValueKind.String + ) + { + throw new JsonException($"Component must have a string 'type' property. Was {typeElement.ValueKind}"); + } + + Type = typeElement.GetString() ?? throw new UnreachableException(); + + DataModelBindings = DeserializeModelBindings(componentElement); + + TextResourceBindings = DeserializeTextResourceBindings(componentElement); + + Hidden = ParseExpression(componentElement, "hidden"); + Required = ParseExpression(componentElement, "required"); + ReadOnly = ParseExpression(componentElement, "readOnly"); + RemoveWhenHidden = ParseExpression(componentElement, "removeWhenHidden"); + + PageId = pageId; + LayoutId = layoutId; + } + + /// + /// Explicit constructor for BaseComponent, used by derived classes that do not need to parse from JsonElement. + /// + protected BaseComponent( + string id, + string pageId, + string layoutId, + string type, + Expression required, + Expression readOnly, + Expression hidden, + Expression removeWhenHidden, + IReadOnlyDictionary dataModelBindings, + IReadOnlyDictionary textResourceBindings + ) + { + Id = id; + PageId = pageId; + LayoutId = layoutId; + Type = type; + DataModelBindings = dataModelBindings; + TextResourceBindings = textResourceBindings; + Required = required; + ReadOnly = readOnly; + Hidden = hidden; + RemoveWhenHidden = removeWhenHidden; + } + + /// + /// ID of the component (or pageName for pages) + /// + public string Id { get; } + + /// + /// The name of the page for the component + /// + public string PageId { get; } + + /// + /// Name of the layout that this component is part of + /// + public string LayoutId { get; } + + /// + /// Component type as written in the json file + /// + public string Type { get; } + + /// + /// Layout Expression that can be evaluated to see if the component should be hidden + /// + public Expression Hidden { get; } + + /// + /// Signal whether the data referenced by this component should be removed at the end of the task. + /// + public Expression RemoveWhenHidden { get; } + + /// + /// Layout Expression that can be evaluated to see if the component should be required + /// + public Expression Required { get; } + + /// + /// Layout Expression that can be evaluated to see if the component should be readOnly + /// + public Expression ReadOnly { get; } + + /// + /// Data model bindings for the component or group + /// + public IReadOnlyDictionary DataModelBindings { get; } + + /// + /// The text resource bindings for the component. + /// + public IReadOnlyDictionary TextResourceBindings { get; } + + /// + /// Creates a context for the component based on the provided parameters. + /// + /// A instance representing the current context of the component. + public abstract Task GetContext( + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ); + + /// + /// Claims child components based on the provided references and updates the lookup dictionaries. + /// + /// + /// A dictionary of unclaimed components, where keys are component IDs and values are the corresponding component instances. + /// + /// + /// A dictionary to track claimed components, where the keys are component IDs and values are the IDs of the components that claimed them. + /// + public abstract void ClaimChildren( + Dictionary unclaimedComponents, + Dictionary claimedComponents + ); + + /// + /// Helper method to parse an expression from a JSON element. + /// + protected static Expression ParseExpression(JsonElement componentElement, string property) + { + if (componentElement.TryGetProperty(property, out var expressionElement)) + { + return ExpressionConverter.ReadStatic(expressionElement); + } + + return new Expression(ExpressionValue.Undefined); + } + + private static IReadOnlyDictionary DeserializeModelBindings(JsonElement element) + { + if ( + !element.TryGetProperty("dataModelBindings", out JsonElement dataModelBindingsElement) + || dataModelBindingsElement.ValueKind == JsonValueKind.Null + ) + { + // If the property is not present or is null, return an empty dictionary + return ImmutableDictionary.Empty; + } + + var modelBindings = new Dictionary(); + if (dataModelBindingsElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + $"Unexpected JSON token type '{dataModelBindingsElement.ValueKind}' for \"dataModelBindings\", expected '{nameof(JsonValueKind.Object)}'" + ); + } + + foreach (var property in dataModelBindingsElement.EnumerateObject()) + { + modelBindings[property.Name] = property.Value.ValueKind switch + { + JsonValueKind.String => new ModelBinding + { + Field = property.Value.GetString() ?? throw new UnreachableException(), + }, + JsonValueKind.Object => property.Value.Deserialize(), + _ => throw new JsonException("dataModelBindings must be a string or an object"), + }; + } + + return modelBindings; + } + + private static Dictionary DeserializeTextResourceBindings(JsonElement element) + { + if ( + !element.TryGetProperty("textResourceBindings", out JsonElement textResourceBindingsElement) + || textResourceBindingsElement.ValueKind == JsonValueKind.Null + ) + { + return []; + } + if (textResourceBindingsElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException( + $"Unexpected JSON token type '{textResourceBindingsElement.ValueKind}' for \"textResourceBindings\", expected '{nameof(JsonValueKind.Object)}'" + ); + } + + return textResourceBindingsElement.Deserialize>() ?? []; + } + + /// + /// Extracts the child component IDs from a JSON element, stripping any multipage group index. + /// + protected List GetChildrenWithoutMultipageGroupIndex(JsonElement component, string property) + { + if (!component.TryGetProperty(property, out JsonElement children) || children.ValueKind != JsonValueKind.Array) + { + throw new JsonException($"Component of {Type} must have a \"{property}\" property of type array."); + } + + return children.EnumerateArray().Select(StripPageIndexForGroupChild).ToList(); + } + + private static string StripPageIndexForGroupChild(JsonElement child) + { + // Group children on multipage groups have the format "pageNumber:childId" (eg "1:child1") + // These numbers can just be ignored for backend processing, so we strip them out. + if (child.ValueKind != JsonValueKind.String) + { + throw new JsonException("Each child in the \"children\" array must be a string."); + } + // ! we just checked the value kind, so we can safely use GetString() + var childId = child.GetString()!; + var index = childId.IndexOf(':'); + if (index != -1 && childId[..index].All(c => c is >= '0' and <= '9')) + { + // Strip the index if everything before the colon is a number + return childId[(index + 1)..]; + } + + return childId; + } +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/Base/NoReferenceComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/Base/NoReferenceComponent.cs new file mode 100644 index 0000000000..19355aaee7 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/Base/NoReferenceComponent.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; + +namespace Altinn.App.Core.Models.Layout.Components.Base; + +/// +/// Simple base component that does not have any references to other components. +/// +public abstract class NoReferenceComponent : BaseComponent +{ + /// + protected NoReferenceComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) { } + + /// + /// No children to claim for NoReferenceComponent + /// + public override void ClaimChildren( + Dictionary unclaimedComponents, + Dictionary claimedComponents + ) { } + + /// + /// No child contexts to return for NoReferenceComponent + /// + public override Task GetContext( + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ) => Task.FromResult(new ComponentContext(state, this, rowIndexes, defaultDataElementIdentifier)); +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/Base/RepeatingReferenceComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/Base/RepeatingReferenceComponent.cs new file mode 100644 index 0000000000..f2f723a717 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/Base/RepeatingReferenceComponent.cs @@ -0,0 +1,256 @@ +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text.Json; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; + +namespace Altinn.App.Core.Models.Layout.Components.Base; + +/// +/// Represents a repeating reference component in a layout. +/// This component type manages references to its child components, allowing dynamic structure in layouts. +/// +public abstract class RepeatingReferenceComponent : BaseComponent +{ + /// + /// Initializes a new instance of the class + /// + protected RepeatingReferenceComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) { } + + /// + /// Model binding for the group that defines the number of repetitions of the repeating group. + /// + public abstract ModelBinding GroupModelBinding { get; } + + /// + /// The expression that determines if the row is hidden. + /// + public abstract Expression HiddenRow { get; } + + /// + /// List of references to child components that are repeated for each row in the repeating group. + /// + public abstract IReadOnlyList RepeatingChildReferences { get; } + + /// + /// References to child components that are not repeated and comes before the repeating group + /// + public abstract IReadOnlyList BeforeChildReferences { get; } + + /// + /// References to child components that are not repeated and comes after the repeating group + /// + public abstract IReadOnlyList AfterChildReferences { get; } + + // References to the components that are used for the child contexts of this component + private Dictionary? _claimedChildrenLookup; + + // used for some tests to ensure hierarchy is correct + internal IEnumerable? AllChildren => _claimedChildrenLookup?.Values; + + /// + public override void ClaimChildren( + Dictionary unclaimedComponents, + Dictionary claimedComponents + ) + { + if ( + GroupModelBinding.Field is null + || RepeatingChildReferences is null + || BeforeChildReferences is null + || AfterChildReferences is null + ) + { + throw new UnreachableException( + $"{GetType().Name} inherits from {nameof(RepeatingReferenceComponent)} and must initialize {nameof(GroupModelBinding)}, {nameof(RepeatingChildReferences)}, {nameof(HiddenRow)}, {nameof(BeforeChildReferences)} and {nameof(AfterChildReferences)} in its constructor." + ); + } + + var components = new Dictionary(); + foreach (var componentId in BeforeChildReferences.Concat(RepeatingChildReferences).Concat(AfterChildReferences)) + { + if (unclaimedComponents.Remove(componentId, out var component)) + { + claimedComponents[componentId] = Id; + } + else + { + // Invalid reference. Throw the appropriate exception. + if (claimedComponents.TryGetValue(componentId, out var claimedComponent)) + { + throw new ArgumentException( + $"Attempted to claim child with id {componentId} to component {Id}, but it has already been claimed by {claimedComponent}." + ); + } + throw new ArgumentException( + $"Attempted to claim child with id {componentId} to component {Id}, but the componentId does not exist" + ); + } + + if (!components.TryAdd(component.Id, component)) + { + throw new ArgumentException($"Component with id {component.Id} is claimed twice by {Id}."); + } + } + + _claimedChildrenLookup = components; + } + + /// + public override async Task GetContext( + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ) + { + if ( + _claimedChildrenLookup is null + || RepeatingChildReferences is null + || BeforeChildReferences is null + || GroupModelBinding.Field is null + || AfterChildReferences is null + ) + { + throw new InvalidOperationException( + $"{GetType().Name} must call {nameof(ClaimChildren)} before calling {nameof(GetContext)}." + ); + } + + var childContexts = new List(); + + foreach (var componentId in BeforeChildReferences) + { + childContexts.Add( + await GetChildContext(componentId, state, defaultDataElementIdentifier, rowIndexes, layoutsLookup) + ); + } + + var rowCount = await state.GetModelDataCount(GroupModelBinding, defaultDataElementIdentifier, rowIndexes) ?? 0; + + for (int i = 0; i < rowCount; i++) + { + var subRowIndexes = GetSubRowIndexes(rowIndexes, i); + var rowComponent = new RepeatingGroupRowComponent( + $"{Id}__group_row_{i}", + PageId, + LayoutId, + DataModelBindings, + HiddenRow, + RemoveWhenHidden + ); + List rowChildren = []; + foreach (var componentId in RepeatingChildReferences) + { + rowChildren.Add( + await GetChildContext( + componentId, + state, + defaultDataElementIdentifier, + subRowIndexes, + layoutsLookup + ) + ); + } + + childContexts.Add( + new ComponentContext(state, rowComponent, subRowIndexes, defaultDataElementIdentifier, rowChildren) + ); + } + + foreach (var componentId in AfterChildReferences) + { + childContexts.Add( + await GetChildContext(componentId, state, defaultDataElementIdentifier, rowIndexes, layoutsLookup) + ); + } + + return new ComponentContext(state, this, rowIndexes, defaultDataElementIdentifier, childContexts); + } + + private async Task GetChildContext( + string componentId, + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ) + { + Debug.Assert(_claimedChildrenLookup is not null, "Must call ClaimChildren before GetContext"); + if (_claimedChildrenLookup.TryGetValue(componentId, out var childComponent)) + { + return await childComponent.GetContext(state, defaultDataElementIdentifier, rowIndexes, layoutsLookup); + } + else + { + throw new ArgumentException($"Child component with id {componentId} not found in claimed children."); + } + } + + private static int[] GetSubRowIndexes(int[]? baseIndexes, int index) + { + if (baseIndexes is null || baseIndexes.Length == 0) + { + return new[] { index }; + } + var result = new int[baseIndexes.Length + 1]; + Array.Copy(baseIndexes, result, baseIndexes.Length); + result[^1] = index; + return result; + } +} + +/// +/// Component for each row (not read from JSON layout, but created when generating contexts for repeating groups). +/// +public class RepeatingGroupRowComponent : BaseComponent +{ + /// + /// Represents a dynamically created component for each row in a repeating group. + /// These components are not directly defined in the JSON layout but are generated + /// during runtime to manage the context of repeating group rows. + /// + public RepeatingGroupRowComponent( + string id, + string pageId, + string layoutId, + IReadOnlyDictionary dataModelBindings, + Expression hiddenRow, + Expression removeWhenHidden + ) + : base( + id, + pageId, + layoutId, + "repeatingGroupRow", + required: Expression.False, + readOnly: Expression.False, + hidden: hiddenRow, + removeWhenHidden, + dataModelBindings, + ImmutableDictionary.Empty + ) { } + + /// + public override Task GetContext( + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ) + { + // This component is not part of the layout structure, so it never creates a context. + return Task.FromException(new NotImplementedException()); + } + + /// + public override void ClaimChildren( + Dictionary unclaimedComponents, + Dictionary claimedComponents + ) + { + // This component does not claim children from the layout. + throw new NotImplementedException(); + } +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/Base/SimpleReferenceComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/Base/SimpleReferenceComponent.cs new file mode 100644 index 0000000000..ac04ca688c --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/Base/SimpleReferenceComponent.cs @@ -0,0 +1,112 @@ +using System.Text.Json; +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models.Expressions; + +namespace Altinn.App.Core.Models.Layout.Components.Base; + +/// +/// Utility class for components that references other components without any repeating logic, such as Grid, Accordion, or Tabs. +/// +public abstract class SimpleReferenceComponent : BaseComponent +{ + /// + protected SimpleReferenceComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) { } + + /// + /// Collection of IDs for children that should be claimed. + /// + public abstract IReadOnlyCollection ChildReferences { get; } + + private IReadOnlyDictionary? _claimedChildrenLookup; + + // used for some tests to ensure hierarchy is correct + internal IEnumerable? AllChildren => _claimedChildrenLookup?.Values; + + /// + public override void ClaimChildren( + Dictionary unclaimedComponents, + Dictionary claimedComponents + ) + { + if (ChildReferences is null) + { + throw new InvalidOperationException( + $"{GetType().Name} inherits from {nameof(SimpleReferenceComponent)} and must initialize {nameof(ChildReferences)} in its constructor." + ); + } + + var components = new Dictionary(); + foreach (var componentId in ChildReferences) + { + if (unclaimedComponents.Remove(componentId, out var component)) + { + claimedComponents[componentId] = Id; + } + else + { + // Invalid reference. Throw the appropriate exception. + if (claimedComponents.TryGetValue(componentId, out var claimedComponent)) + { + throw new ArgumentException( + $"Attempted to claim child with id {componentId} to component {Id}, but it has already been claimed by {claimedComponent}." + ); + } + throw new ArgumentException( + $"Attempted to claim child with id {componentId} to component {Id}, but the componentId does not exist" + ); + } + + if (!components.TryAdd(component.Id, component)) + { + throw new ArgumentException($"Component with id {component.Id} is claimed twice by {Id}."); + } + } + + _claimedChildrenLookup = components; + } + + /// + /// Gets the collection of child components that have been claimed as children by this component. + /// + /// Will be populated after the method is called. + /// + public IReadOnlyDictionary ClaimedChildrenLookup => + _claimedChildrenLookup + ?? throw new InvalidOperationException( + "ClaimChildren has not been called. This is a bug in the component initialization process." + ); + + /// + public override async Task GetContext( + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ) + { + if (ChildReferences is null) + { + throw new InvalidOperationException( + $"{GetType().Name} inherits from {nameof(SimpleReferenceComponent)} and must initialize {nameof(ChildReferences)} in its constructor." + ); + } + + List childContexts = []; + foreach (var childId in ChildReferences) + { + if (ClaimedChildrenLookup.TryGetValue(childId, out var childComponent)) + { + childContexts.Add( + await childComponent.GetContext(state, defaultDataElementIdentifier, rowIndexes, layoutsLookup) + ); + } + else + { + throw new ArgumentException($"Child component with ID '{childId}' is not claimed."); + } + } + + return new ComponentContext(state, this, rowIndexes, defaultDataElementIdentifier, childContexts); + } +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs deleted file mode 100644 index f84b3053f4..0000000000 --- a/src/Altinn.App.Core/Models/Layout/Components/BaseComponent.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Collections.Immutable; -using Altinn.App.Core.Models.Expressions; - -namespace Altinn.App.Core.Models.Layout.Components; - -/// -/// Interface to be able to handle all most components same way. -/// -/// -/// See for any components that handle children. -/// Includes that will be initialized to an empty dictionary -/// for components that don't have them. -/// -public record BaseComponent -{ - /// - /// Constructor for - /// - public BaseComponent( - string id, - string type, - IReadOnlyDictionary? dataModelBindings, - Expression hidden, - Expression required, - Expression readOnly, - IReadOnlyDictionary? additionalProperties - ) - { - Id = id; - Type = type; - DataModelBindings = dataModelBindings ?? ImmutableDictionary.Empty; - Hidden = hidden; - Required = required; - ReadOnly = readOnly; - AdditionalDebugProperties = additionalProperties; - } - - /// - /// ID of the component (or pageName for pages) - /// - public string Id { get; } - - /// - /// Get the page for the component - /// - public virtual string PageId => - Parent?.PageId ?? throw new InvalidOperationException("Component is not part of a page"); - - /// - /// Get the layout - /// - /// - public virtual string LayoutId => - Parent?.LayoutId ?? throw new InvalidOperationException("Component is not part of a layout"); - - /// - /// Component type as written in the json file - /// - public string Type { get; } - - /// - /// Layout Expression that can be evaluated to see if component should be hidden - /// - public Expression Hidden { get; } - - /// - /// Layout Expression that can be evaluated to see if component should be required - /// - public Expression Required { get; } - - /// - /// Layout Expression that can be evaluated to see if component should be read only - /// - public Expression ReadOnly { get; } - - /// - /// Data model bindings for the component or group - /// - public IReadOnlyDictionary DataModelBindings { get; } - - /// - /// The group or page that this component is part of. NULL for page components - /// - public BaseComponent? Parent { get; internal set; } - - /// - /// Extra properties that are not modelled explicitly as a class that inhertits from . - /// value is a JSON serialized string. It is intended for debugging - /// - public IReadOnlyDictionary? AdditionalDebugProperties { get; } -} diff --git a/src/Altinn.App.Core/Models/Layout/Components/CardsComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/CardsComponent.cs new file mode 100644 index 0000000000..bbee50b2af --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/CardsComponent.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Models.Layout.Components.Base; + +namespace Altinn.App.Core.Models.Layout.Components; + +/// +/// This class represents a component that references other components and displays them in a card-like format. +/// +public sealed class CardsComponent : SimpleReferenceComponent +{ + /// + /// Constructor for CardsComponent + /// + public CardsComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) + { + if (!componentElement.TryGetProperty("cards", out JsonElement cardsElement)) + { + throw new JsonException($"{Type} component must have a \"cards\" property."); + } + + Cards = + cardsElement.Deserialize>() + ?? throw new JsonException("Failed to deserialize cards in CardsComponent."); + + ChildReferences = Cards.SelectMany(t => t.Children ?? []).ToList(); + } + + /// + public override IReadOnlyCollection ChildReferences { get; } + + /// + /// Configuration for the cards in the CardsComponent. + /// + public IReadOnlyCollection Cards { get; } +} + +/// +/// Configuration for a single tab in a CardsComponent. +/// +public sealed class CardsConfig +{ + /// + /// List of child component IDs that belong to this tab. + /// + [JsonPropertyName("children")] + public List? Children { get; init; } +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs index bbe3c02fd8..d7788e5174 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/GridComponent.cs @@ -1,89 +1,68 @@ using System.Text.Json; using System.Text.Json.Serialization; -using Altinn.App.Core.Models.Expressions; +using Altinn.App.Core.Models.Layout.Components.Base; namespace Altinn.App.Core.Models.Layout.Components; /// -/// Component specialisation for repeating groups with maxCount > 1 +/// Tag component to signify that this is a grid component /// -public record GridComponent : GroupComponent +public sealed class GridComponent : SimpleReferenceComponent { /// - /// Constructor for RepeatingGroupComponent + /// Constructor for GridComponent /// - public GridComponent( - string id, - string type, - IReadOnlyDictionary? dataModelBindings, - IReadOnlyCollection children, - IReadOnlyCollection? childIDs, - Expression hidden, - Expression required, - Expression readOnly, - IReadOnlyDictionary? additionalProperties - ) - : base(id, type, dataModelBindings, children, childIDs, hidden, required, readOnly, additionalProperties) { } -} - -/// -/// Class for parsing a Grid component's rows and cells and extracting the child component IDs -/// -public record GridConfig -{ - /// - /// Reads the Grid component's rows and returns the child component IDs - /// - /// - /// - /// - public static List ReadGridChildren(ref Utf8JsonReader reader, JsonSerializerOptions options) + public GridComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) { - var rows = JsonSerializer.Deserialize(ref reader, options); - var gridConfig = new GridConfig { Rows = rows }; - return gridConfig.Children(); + if (!componentElement.TryGetProperty("rows", out JsonElement rowsElement)) + { + throw new JsonException("GridComponent must have a \"rows\" property."); + } + + Rows = + rowsElement.Deserialize>() + ?? throw new JsonException("Failed to deserialize rows in GridComponent."); + + // Extract component IDs from the Components in the grid so that they can be claimed and used + // when getting contexts. + // cells might not reference components, so we filter out nulls with OfType() + ChildReferences = Rows.SelectMany(r => r.Cells).Select(c => c?.ComponentId).OfType().ToList(); } /// - /// Rows in the grid + /// Content from the "rows" property in the JSON representation of the grid component. /// - [JsonPropertyName("rows")] - public GridRow[]? Rows { get; set; } + public List Rows { get; } + + /// + public override IReadOnlyCollection ChildReferences { get; } /// - /// Defines a row in a grid + /// Class for parsing a Grid component's rows and cells and extracting the child component IDs /// - public record GridRow + public class GridRowConfig { /// - /// Cells in the row + /// List of cells in the grid row, each cell can optionally refer to a component ID (or static headers we don't care about in backend) /// [JsonPropertyName("cells")] - public GridCell?[]? Cells { get; set; } - - /// - /// Defines a cell in a grid - /// - public record GridCell - { - /// - /// The component ID of the cell - /// - [JsonPropertyName("component")] - public string? ComponentId { get; set; } - } + public required List Cells { get; set; } } /// - /// Returns the child component IDs + /// Config for a cell in a grid row that refers to a component ID /// - /// - public List Children() + /// + /// The JSON schema allows different types of cells (polymorphism), but they are only important visually + /// and can be ignored for backend processing. + /// + public class GridCellConfig { - return this.Rows?.Where(r => r.Cells is not null) - .SelectMany(r => r.Cells ?? []) - .Select(c => c?.ComponentId) - .WhereNotNull() - .ToList() ?? []; + /// + /// The component ID of the cell + /// + [JsonPropertyName("component")] + public string? ComponentId { get; set; } } } diff --git a/src/Altinn.App.Core/Models/Layout/Components/GroupComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/GroupComponent.cs deleted file mode 100644 index 2983e8ac70..0000000000 --- a/src/Altinn.App.Core/Models/Layout/Components/GroupComponent.cs +++ /dev/null @@ -1,64 +0,0 @@ -using Altinn.App.Core.Models.Expressions; - -namespace Altinn.App.Core.Models.Layout.Components; - -/// -/// Tag component to signify that this is a group component -/// -public record GroupComponent : BaseComponent -{ - /// - /// Constructor for GroupComponent - /// - public GroupComponent( - string id, - string type, - IReadOnlyDictionary? dataModelBindings, - IReadOnlyCollection children, - IReadOnlyCollection? childIDs, - Expression hidden, - Expression required, - Expression readOnly, - IReadOnlyDictionary? additionalProperties - ) - : base(id, type, dataModelBindings, hidden, required, readOnly, additionalProperties) - { - Children = children; - ChildIDs = childIDs ?? children.Select(c => c.Id); - foreach (var child in Children) - { - child.Parent = this; - } - } - - /// - /// The children in this group/page - /// - public IEnumerable Children { get; private set; } - - /// - /// The child IDs in this group/page - /// - public IEnumerable ChildIDs { get; private set; } - - /// - /// Adds a child component which is already defined in its child IDs - /// - public virtual void AddChild(BaseComponent child) - { - if (!this.ChildIDs.Contains(child.Id)) - { - throw new ArgumentException( - $"Attempted to add child with id {child.Id} to group {this.Id}, but this child is not included in its list of child IDs" - ); - } - if (this.Children.FirstOrDefault(c => c.Id == child.Id) != null) - { - throw new ArgumentException( - $"Attempted to add child with id {child.Id} to group {this.Id}, but a child with this id has already been added" - ); - } - child.Parent = this; - this.Children = this.Children.Append(child); - } -} diff --git a/src/Altinn.App.Core/Models/Layout/Components/NonRepeatingGroupComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/NonRepeatingGroupComponent.cs new file mode 100644 index 0000000000..64e7330ff0 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/NonRepeatingGroupComponent.cs @@ -0,0 +1,27 @@ +using System.Text.Json; +using Altinn.App.Core.Models.Layout.Components.Base; + +namespace Altinn.App.Core.Models.Layout.Components; + +/// +/// This class represents multiple component types with children as List[string] that are not repeating. +/// +public sealed class NonRepeatingGroupComponent : SimpleReferenceComponent +{ + /// + /// Constructor for NonRepeatingGroupComponent + /// + public NonRepeatingGroupComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) + { + if (!componentElement.TryGetProperty("children", out JsonElement childIdsElement)) + { + throw new JsonException($"{Type} component must have a \"children\" property."); + } + + ChildReferences = GetChildrenWithoutMultipageGroupIndex(componentElement, "children"); + } + + /// + public override IReadOnlyCollection ChildReferences { get; } +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs index eedaa80a79..9a640befb1 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/OptionsComponent.cs @@ -1,24 +1,24 @@ -using Altinn.App.Core.Models.Expressions; +using System.Text.Json; namespace Altinn.App.Core.Models.Layout.Components; /// /// Custom component for handeling the special fields that represents an option. /// -public record OptionsComponent : BaseComponent +public sealed class OptionsComponent : Base.NoReferenceComponent { /// /// The ID that references and /// - public string? OptionId { get; } + public string? OptionsId { get; } /// - /// Alternaltive to where the options are listed inline instead of referencing an external generator + /// Alternaltive to where the options are listed inline instead of referencing an external generator /// public List? Options { get; } /// - /// Alternaltive to where the options are sourced from a repeating group in the datamodel + /// Alternaltive to where the options are sourced from a repeating group in the datamodel /// public OptionsSource? OptionsSource { get; } @@ -30,25 +30,60 @@ public record OptionsComponent : BaseComponent /// /// Constructor /// - public OptionsComponent( - string id, - string type, - IReadOnlyDictionary? dataModelBindings, - Expression hidden, - Expression required, - Expression readOnly, - string? optionId, - List? options, - OptionsSource? optionsSource, - bool secure, - IReadOnlyDictionary? additionalProperties - ) - : base(id, type, dataModelBindings, hidden, required, readOnly, additionalProperties) + public OptionsComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) { - OptionId = optionId; - Options = options; - OptionsSource = optionsSource; - Secure = secure; + if (componentElement.TryGetProperty("optionsId", out JsonElement optionsIdElement)) + { + OptionsId = optionsIdElement.GetString(); + } + + if (componentElement.TryGetProperty("options", out JsonElement optionsElement)) + { + Options = + optionsElement.Deserialize>() + ?? throw new JsonException("Failed to deserialize options in OptionsComponent."); + } + + if (componentElement.TryGetProperty("source", out JsonElement optionsSourceElement)) + { + OptionsSource = + optionsSourceElement.Deserialize() + ?? throw new JsonException("Failed to deserialize optionsSource in OptionsComponent."); + } + + Secure = + componentElement.TryGetProperty("secure", out JsonElement secureElement) + && secureElement.ValueKind == JsonValueKind.True; + + if (OptionsId is null && Options is null && OptionsSource is null) + { + throw new JsonException( + $"\"optionsId\" or \"options\" or \"source\" is required on checkboxes, radiobuttons and dropdowns in component {pageId}.{Id}" + ); + } + if (OptionsId is not null && Options is not null) + { + throw new JsonException("\"optionsId\" and \"options\" can't both be specified"); + } + if (OptionsId is not null && OptionsSource is not null) + { + throw new JsonException("\"optionsId\" and \"source\" can't both be specified"); + } + if (OptionsSource is not null && Options is not null) + { + throw new JsonException("\"source\" and \"options\" can't both be specified"); + } + if (Options is not null && Secure) + { + throw new JsonException("\"secure\": true is invalid for components with literal \"options\""); + } + if (OptionsSource is not null && Secure) + { + throw new JsonException( + "\"secure\": true is invalid for components that reference a repeating group \"source\"" + ); + } } } diff --git a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs index a090ca51fb..e6f5b3de84 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/PageComponent.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Text.Json; +using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Expressions; namespace Altinn.App.Core.Models.Layout.Components; @@ -6,44 +9,160 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Component like object to add Page as a group like object /// -[JsonConverter(typeof(PageComponentConverter))] -public record PageComponent : GroupComponent +public sealed class PageComponent : Base.BaseComponent { - private readonly string _layoutId; - /// /// Constructor for PageComponent /// - public PageComponent( - string id, - string layoutId, - List children, - Dictionary componentLookup, - Expression hidden, - Expression required, - Expression readOnly, - IReadOnlyDictionary? extra - ) - : base(id, "page", null, children, null, hidden, required, readOnly, extra) + public PageComponent(JsonElement outerElement, string pageId, string layoutId) + : base( + pageId, + pageId, + layoutId, + "page", + required: Expression.False, + readOnly: Expression.False, + hidden: ParseHiddenExpressionAndValidateJsonElement(outerElement, out JsonElement dataElement), + removeWhenHidden: Expression.Null, + dataModelBindings: ImmutableDictionary.Empty, + textResourceBindings: ImmutableDictionary.Empty + ) { - _layoutId = layoutId; - ComponentLookup = componentLookup; + if ( + !dataElement.TryGetProperty("layout", out JsonElement componentsElement) + || componentsElement.ValueKind != JsonValueKind.Array + ) + { + throw new JsonException("PageComponent must have a \"layout\" property of type array."); + } + + List componentList = []; + + foreach (var componentElement in componentsElement.EnumerateArray()) + { + if (componentElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException("Each component in the \"layout\" array must be an object."); + } + + if ( + !componentElement.TryGetProperty("type", out JsonElement typeElement) + || typeElement.ValueKind != JsonValueKind.String + ) + { + throw new JsonException( + "Each component in the \"layout\" must have a \"type\" property of type string." + ); + } + + var type = typeElement.GetString() ?? throw new UnreachableException(); + + var maxCount = + componentElement.TryGetProperty("maxCount", out JsonElement maxCountElement) + && maxCountElement.ValueKind == JsonValueKind.Number + ? maxCountElement.GetInt32() + : 1; + // ensure maxCount is positive + if (maxCount < 0) + { + throw new JsonException( + $"Component {layoutId}.{pageId}.{Id} has invalid maxCount={maxCount}, must be positive." + ); + } + + Base.BaseComponent component = type.ToLowerInvariant() switch + { + "group" when maxCount == 1 => new NonRepeatingGroupComponent(componentElement, pageId, layoutId), + "group" => new RepeatingGroupComponent(componentElement, pageId, layoutId, maxCount), + "repeatinggroup" => new RepeatingGroupComponent(componentElement, pageId, layoutId, maxCount), + "accordion" => new NonRepeatingGroupComponent(componentElement, pageId, layoutId), + "grid" => new GridComponent(componentElement, pageId, layoutId), + "subform" => new SubFormComponent(componentElement, pageId, layoutId), + "tabs" => new TabsComponent(componentElement, pageId, layoutId), + "cards" => new CardsComponent(componentElement, pageId, layoutId), + "checkboxes" => new OptionsComponent(componentElement, pageId, layoutId), + "radiobuttons" => new OptionsComponent(componentElement, pageId, layoutId), + "dropdown" => new OptionsComponent(componentElement, pageId, layoutId), + "multipleselect" => new OptionsComponent(componentElement, pageId, layoutId), + _ => new UnknownComponent(componentElement, pageId, layoutId), + }; + + componentList.Add(component); + } + + var pageComponentLookup = new Dictionary(StringComparer.Ordinal); + foreach (var c in componentList) + { + if (!pageComponentLookup.TryAdd(c.Id, c)) + { + throw new JsonException($"Duplicate component id '{c.Id}' on page '{pageId}' in layout '{layoutId}'."); + } + } + + Dictionary claimedComponentIds = []; // Keep track of claimed components + + // Let all components on the page claim their children + foreach (var component in componentList) + { + component.ClaimChildren(pageComponentLookup, claimedComponentIds); + } + + // Preserve order but remove components that have been claimed + Components = componentList.Where(c => !claimedComponentIds.ContainsKey(c.Id)).ToList(); } - /// - public override string PageId => Id; + // Silly way to run code before the base constructor is called. + private static Expression ParseHiddenExpressionAndValidateJsonElement( + JsonElement outerElement, + out JsonElement dataElement + ) + { + if (outerElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException("Layout file must be an object."); + } - /// - // ReSharper disable once ConvertToAutoProperty (can't set the virtual auto property in constructor (as per sonar cloud)) - public override string LayoutId => _layoutId; + if (!outerElement.TryGetProperty("data", out dataElement) || dataElement.ValueKind != JsonValueKind.Object) + { + throw new JsonException("Layout file must have a \"data\" property of type object."); + } + + return ParseExpression(dataElement, "hidden"); + } /// - /// Helper dictionary to find components without traversing children. + /// List of the components that are part of this page. /// - public Dictionary ComponentLookup { get; } + public IReadOnlyList Components { get; private init; } + + /// + public override async Task GetContext( + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ) + { + List childContexts = []; + foreach (var component in Components) + { + childContexts.Add( + await component.GetContext(state, defaultDataElementIdentifier, rowIndexes, layoutsLookup) + ); + } + + return new ComponentContext(state, this, rowIndexes, defaultDataElementIdentifier, childContexts); + } /// - /// AddChild is not needed for PageComponent, and the base implementation would not work as intended. + /// For PageComponent, you need to call RunClaimChidren to claim children for all components on the page. /// - public override void AddChild(BaseComponent child) { } + /// + public override void ClaimChildren( + Dictionary unclaimedComponents, + Dictionary claimedComponents + ) + { + throw new NotImplementedException(); + } } diff --git a/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs index 5f84b96c80..5dfe4b9d60 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/RepeatingGroupComponent.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Altinn.App.Core.Models.Expressions; namespace Altinn.App.Core.Models.Layout.Components; @@ -5,28 +6,75 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Component specialisation for repeating groups with maxCount > 1 /// -public record RepeatingGroupComponent : GroupComponent +public sealed class RepeatingGroupComponent : Base.RepeatingReferenceComponent { /// /// Constructor for RepeatingGroupComponent /// - public RepeatingGroupComponent( - string id, - string type, - IReadOnlyDictionary? dataModelBindings, - IReadOnlyCollection children, - IReadOnlyCollection? childIDs, - int maxCount, - Expression hidden, - Expression hiddenRow, - Expression required, - Expression readOnly, - IReadOnlyDictionary? additionalProperties - ) - : base(id, type, dataModelBindings, children, childIDs, hidden, required, readOnly, additionalProperties) + public RepeatingGroupComponent(JsonElement componentElement, string pageId, string layoutId, int maxCount) + : base(componentElement, pageId, layoutId) { MaxCount = maxCount; - HiddenRow = hiddenRow; + if ( + !componentElement.TryGetProperty("children", out JsonElement childIdsElement) + || childIdsElement.ValueKind != JsonValueKind.Array + ) + { + throw new JsonException($"{Type} must have a \"children\" property that contains a list of strings."); + } + + if (!DataModelBindings.TryGetValue("group", out var groupBinding)) + { + throw new JsonException($"{Type} must have a 'group' data model binding."); + } + + if (string.IsNullOrWhiteSpace(groupBinding.Field)) + { + throw new JsonException( + $"Component {layoutId}.{pageId}.{Id} must have 'dataModelBindings.group' which is a non-empty string or object with a non-empty 'field'." + ); + } + + GroupModelBinding = groupBinding; + + RepeatingChildReferences = GetChildrenWithoutMultipageGroupIndex(componentElement, "children"); + + HiddenRow = componentElement.TryGetProperty("hiddenRow", out JsonElement hiddenRowElement) + ? ExpressionConverter.ReadStatic(hiddenRowElement) + : Expression.False; + RowsBefore = ParseGridConfig("rowsBefore", componentElement); + + RowsAfter = ParseGridConfig("rowsAfter", componentElement); + + BeforeChildReferences = RowsBefore + .SelectMany(row => row.Cells?.Select(cell => cell?.ComponentId) ?? []) + .OfType() + .ToList(); + + AfterChildReferences = RowsAfter + .SelectMany(row => row.Cells?.Select(cell => cell?.ComponentId) ?? []) + .OfType() + .ToList(); + } + + private static List ParseGridConfig(string propertyName, JsonElement componentElement) + { + if ( + componentElement.TryGetProperty(propertyName, out JsonElement gridConfigElement) + && gridConfigElement.ValueKind != JsonValueKind.Null + ) + { + if (gridConfigElement.ValueKind != JsonValueKind.Array) + { + throw new JsonException( + $"If present, RepeatingGroupComponent \"{propertyName}\" property must be an array." + ); + } + return gridConfigElement.Deserialize>() + ?? throw new JsonException($"Failed to deserialize {propertyName} in RepeatingGroupComponent."); + } + + return []; } /// @@ -35,35 +83,27 @@ public RepeatingGroupComponent( public int MaxCount { get; } /// - /// Layout Expression that can be evaluated to see if row should be hidden + /// List of rows before the repeating group, used to associate components that are not repeated to the repeating group for layout purposes /// - public Expression HiddenRow { get; } -} + public IReadOnlyList RowsBefore { get; } -/// -/// Component (currently only used for contexts to have something to point to) for a row in a repeating group -/// -public record RepeatingGroupRowComponent : BaseComponent -{ /// - /// Constructor for RepeatingGroupRowComponent + /// List of rows after the repeating group, used to associate components that are not repeated to the repeating group for layout purposes /// - public RepeatingGroupRowComponent( - string id, - IReadOnlyDictionary dataModelBindings, - Expression hiddenRow, - BaseComponent parent - ) - : base( - id, - "groupRow", - dataModelBindings, - hiddenRow, - required: Expression.False, - readOnly: Expression.False, - null - ) - { - Parent = parent; - } + public IReadOnlyList RowsAfter { get; } + + /// + public override ModelBinding GroupModelBinding { get; } + + /// + public override Expression HiddenRow { get; } + + /// + public override IReadOnlyList RepeatingChildReferences { get; } + + /// + public override IReadOnlyList BeforeChildReferences { get; } + + /// + public override IReadOnlyList AfterChildReferences { get; } } diff --git a/src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs index 720d085144..061de615b8 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/Components/SubFormComponent.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using System.Text.Json; +using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Expressions; namespace Altinn.App.Core.Models.Layout.Components; @@ -5,31 +8,23 @@ namespace Altinn.App.Core.Models.Layout.Components; /// /// Component for handling subforms /// -public record SubFormComponent : BaseComponent +public sealed class SubFormComponent : Base.BaseComponent { /// Constructor /// /// Note that some properties are commented out, as they are currently not used, and might allow expressions in the future /// - public SubFormComponent( - string id, - string type, - IReadOnlyDictionary? dataModelBindings, - string layoutSetId, - // List tableColumns, - // bool showAddButton, - // bool showDeleteButton, - Expression hidden, - Expression required, - Expression readOnly, - IReadOnlyDictionary? additionalProperties - ) - : base(id, type, dataModelBindings, hidden, required, readOnly, additionalProperties) + public SubFormComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) { - LayoutSetId = layoutSetId; - // TableColumns = tableColumns; - // ShowAddButton = showAddButton; - // ShowDeleteButton = showDeleteButton; + if ( + !componentElement.TryGetProperty("layoutSet", out JsonElement layoutSetIdElement) + || layoutSetIdElement.ValueKind != JsonValueKind.String + ) + { + throw new ArgumentException("SubFormComponent must have a string 'layoutSet' property."); + } + LayoutSetId = layoutSetIdElement.GetString() ?? throw new UnreachableException(); } /// @@ -37,30 +32,45 @@ public SubFormComponent( /// public string LayoutSetId { get; } - // /// - // /// Specification for preview of subForms in main form - // /// - // public List TableColumns { get; } - // /// - // /// Show button to add a new row - // /// - // public bool ShowAddButton { get; } - // /// - // /// Show button to remove a row - // /// - // public bool ShowDeleteButton { get; } + /// + public override async Task GetContext( + LayoutEvaluatorState state, + DataElementIdentifier defaultDataElementIdentifier, + int[]? rowIndexes, + Dictionary layoutsLookup + ) + { + if (!layoutsLookup.TryGetValue(LayoutSetId, out var layoutSet)) + { + throw new ArgumentException( + $"SubFormComponent {Id} is configured to use Layout set {LayoutSetId}, but it was not found." + ); + } - /// - /// Specification for preview of subForms in main form - /// - /// The header value to display. May contain a text resource bindings, but no data model lookups. - /// - public record TableColumn(string HeaderContent, CellContent CellContent); + List childContexts = []; + foreach ( + DataElementIdentifier dataElement in state.Instance.Data.Where(d => + d.DataType == layoutSet.DefaultDataType.Id + ) + ) + { + // Currently, we just add all pages flat into the subform component. + // We don't have any need for a "SubFormRow" context. + foreach (var subformPage in layoutSet.Pages) + { + childContexts.Add(await subformPage.GetContext(state, dataElement, null, layoutsLookup)); + } + } - /// - /// How to select the content of a cell in a subform preview - /// - /// The cell value to display from a data model lookup (dot notation). - /// The cell value to display if `query` returns no result. - public record CellContent(string Query, string DefaultContent); + return new ComponentContext(state, this, rowIndexes, defaultDataElementIdentifier, childContexts); + } + + /// + public override void ClaimChildren( + Dictionary unclaimedComponents, + Dictionary claimedComponents + ) + { + // Sub form does not claim children from the same layout + } } diff --git a/src/Altinn.App.Core/Models/Layout/Components/SummaryComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/SummaryComponent.cs deleted file mode 100644 index 53ba0a7f60..0000000000 --- a/src/Altinn.App.Core/Models/Layout/Components/SummaryComponent.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Altinn.App.Core.Models.Expressions; - -namespace Altinn.App.Core.Models.Layout.Components; - -/// -/// Custom component for handeling the special fields in "type" = "Summary" -/// -public record SummaryComponent : BaseComponent -{ - /// - /// of the component this summary references - /// - public string ComponentRef { get; } - - /// - /// Constructor - /// - public SummaryComponent( - string id, - string type, - Expression hidden, - string componentRef, - IReadOnlyDictionary? additionalProperties - ) - : base(id, type, null, hidden, Expression.False, Expression.False, additionalProperties) - { - ComponentRef = componentRef; - } -} diff --git a/src/Altinn.App.Core/Models/Layout/Components/TabsComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/TabsComponent.cs new file mode 100644 index 0000000000..2fd19d60b1 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/TabsComponent.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Altinn.App.Core.Models.Layout.Components.Base; + +namespace Altinn.App.Core.Models.Layout.Components; + +/// +/// This class represents multiple component types +/// +public sealed class TabsComponent : SimpleReferenceComponent +{ + /// + /// Constructor for TabsComponent + /// + public TabsComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) + { + if (!componentElement.TryGetProperty("tabs", out JsonElement tabsElement)) + { + throw new JsonException($"{Type} component must have a \"tabs\" property."); + } + + Tabs = + tabsElement.Deserialize>() + ?? throw new JsonException("Failed to deserialize tabs in TabsComponent."); + + ChildReferences = Tabs.SelectMany(t => t.Children ?? []).ToList(); + } + + /// + public override IReadOnlyCollection ChildReferences { get; } + + /// + /// Configuration for the tabs in the TabsComponent. + /// + public IReadOnlyCollection Tabs { get; } +} + +/// +/// Configuration for a single tab in a TabsComponent. +/// +public sealed class TabsConfig +{ + /// + /// List of child component IDs that belong to this tab. + /// + [JsonPropertyName("children")] + public List? Children { get; init; } +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/UnknownComponent.cs b/src/Altinn.App.Core/Models/Layout/Components/UnknownComponent.cs new file mode 100644 index 0000000000..e052b61f47 --- /dev/null +++ b/src/Altinn.App.Core/Models/Layout/Components/UnknownComponent.cs @@ -0,0 +1,16 @@ +using System.Text.Json; + +namespace Altinn.App.Core.Models.Layout.Components; + +/// +/// Represents an unknown or unrecognized component in a layout. UnknownComponent serves as a placeholder +/// for components that do not match any predefined or supported type. +/// +internal sealed class UnknownComponent : Base.NoReferenceComponent +{ + /// + /// Constructor for UnknownComponent + /// + public UnknownComponent(JsonElement componentElement, string pageId, string layoutId) + : base(componentElement, pageId, layoutId) { } +} diff --git a/src/Altinn.App.Core/Models/Layout/Components/_readme.md b/src/Altinn.App.Core/Models/Layout/Components/_readme.md index 67070bea13..b36eda3b16 100644 --- a/src/Altinn.App.Core/Models/Layout/Components/_readme.md +++ b/src/Altinn.App.Core/Models/Layout/Components/_readme.md @@ -1,3 +1,11 @@ -Most components are handled in C# with the generic `Altinn.App.Core.Models.Layout.Components.BaseComponent` -that includes the most common fields in components. Some component types have other fields that have meaning in backend -(or is used in backend validation) thus a subclass of `BaseComponent` is used. \ No newline at end of file +## Working with layouts in C# + +To represent layout components in Altinn Studio, we have defined a set of classes in the `Altinn.App.Core.Models.Layout.Components` namespace. +The main classes are: +* `LayoutModel`: This is the full class that stores all layouts for an application. You get it from `IAppResources`. + * `GenerateComponentContexts` method generates a list of `ComponentContext` objects for all components in the layout. (note that these contexts are recursive) +* `LayoutSetComponent`: Has a collection of `PageComponent` and represents a layout set. +* `PageComponent`: Represents a page in the application and contains a collection of `BaseComponent`. +* `BaseComponent`: This is the base class for all layout components. It contains common properties such as `Id`, `Type`, `DataModelBindings`, and `TextResourceBindings`. + +The `BaseComponent` class has several derived classes that represent specific types of components. diff --git a/src/Altinn.App.Core/Models/Layout/DataReference.cs b/src/Altinn.App.Core/Models/Layout/DataReference.cs index de901c8736..f5911c0921 100644 --- a/src/Altinn.App.Core/Models/Layout/DataReference.cs +++ b/src/Altinn.App.Core/Models/Layout/DataReference.cs @@ -14,4 +14,22 @@ public record struct DataReference /// The Id of the data element that the field is referencing /// public required DataElementIdentifier DataElementIdentifier { get; init; } + + /// + /// Determines if the current instance is a prefix of the specified + /// based on both the DataElementIdentifier and Field properties. + /// + /// The to compare with the current instance. + /// Returns true if the current instance is a prefix of the specified ; + /// otherwise, returns false. + public bool StartsWith(DataReference prefix) + { + return DataElementIdentifier.Guid == prefix.DataElementIdentifier.Guid + && Field.StartsWith(prefix.Field, StringComparison.Ordinal) + && ( + Field.Length == prefix.Field.Length + || Field[prefix.Field.Length] == '.' + || Field[prefix.Field.Length] == '[' + ); + } } diff --git a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs index e0aa80ff79..c42e2a81c9 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutModel.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutModel.cs @@ -1,6 +1,5 @@ -using Altinn.App.Core.Helpers.DataModel; +using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Expressions; -using Altinn.App.Core.Models.Layout.Components; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Models.Layout; @@ -8,7 +7,7 @@ namespace Altinn.App.Core.Models.Layout; /// /// Class for handling a full layout/layoutset /// -public class LayoutModel +public sealed class LayoutModel { private readonly Dictionary _layoutsLookup; private readonly LayoutSetComponent _defaultLayoutSet; @@ -30,181 +29,26 @@ public LayoutModel(List layouts, LayoutSet? defaultLayout) public DataType DefaultDataType => _defaultLayoutSet.DefaultDataType; /// - /// Get a specific component on a specific page. + /// Generate a list of for all components in the layout model + /// taking repeating groups into account. /// - public BaseComponent GetComponent(string pageName, string componentId) + public async Task> GenerateComponentContexts(LayoutEvaluatorState state) { - var page = _defaultLayoutSet.GetPage(pageName); - - if (!page.ComponentLookup.TryGetValue(componentId, out var component)) + var defaultElementId = _defaultLayoutSet.GetDefaultDataElementId(state.Instance); + if (defaultElementId is null) { - throw new ArgumentException($"Unknown component {componentId} on {pageName}"); + return []; } - return component; - } - /// - /// Generate a list of for all components in the layout model - /// taking repeating groups into account. - /// - /// The instance with data element information - /// The data model to use for repeating groups - public async Task> GenerateComponentContexts(Instance instance, DataModel dataModel) - { var pageContexts = new List(); foreach (var page in _defaultLayoutSet.Pages) { - var defaultElementId = _defaultLayoutSet.GetDefaultDataElementId(instance); - if (defaultElementId is not null) - { - pageContexts.Add(await GenerateComponentContextsRecurs(page, dataModel, defaultElementId.Value, [])); - } + pageContexts.Add(await page.GetContext(state, defaultElementId.Value, null, _layoutsLookup)); } return pageContexts; } - private async Task GenerateComponentContextsRecurs( - BaseComponent component, - DataModel dataModel, - DataElementIdentifier defaultDataElementIdentifier, - int[]? indexes - ) - { - return component switch - { - SubFormComponent subFormComponent => await GenerateContextForSubComponent( - dataModel, - subFormComponent, - defaultDataElementIdentifier - ), - - RepeatingGroupComponent repeatingGroupComponent => await GenerateContextForRepeatingGroup( - dataModel, - repeatingGroupComponent, - defaultDataElementIdentifier, - indexes - ), - GroupComponent groupComponent => await GenerateContextForGroup( - dataModel, - groupComponent, - defaultDataElementIdentifier, - indexes - ), - _ => new ComponentContext( - component, - indexes?.Length > 0 ? indexes : null, - defaultDataElementIdentifier, - [] - ), - }; - } - - private async Task GenerateContextForGroup( - DataModel dataModel, - GroupComponent groupComponent, - DataElementIdentifier defaultDataElementIdentifier, - int[]? indexes - ) - { - List children = []; - foreach (var child in groupComponent.Children) - { - children.Add( - await GenerateComponentContextsRecurs(child, dataModel, defaultDataElementIdentifier, indexes) - ); - } - - return new ComponentContext( - groupComponent, - indexes?.Length > 0 ? indexes : null, - defaultDataElementIdentifier, - children - ); - } - - private async Task GenerateContextForRepeatingGroup( - DataModel dataModel, - RepeatingGroupComponent repeatingGroupComponent, - DataElementIdentifier defaultDataElementIdentifier, - int[]? indexes - ) - { - var children = new List(); - if (repeatingGroupComponent.DataModelBindings.TryGetValue("group", out var groupBinding)) - { - var repeatingGroupRowComponent = new RepeatingGroupRowComponent( - $"{repeatingGroupComponent.Id}_{Guid.NewGuid()}", // Ensure globally unique id (consider using altinnRowId) - repeatingGroupComponent.DataModelBindings, - repeatingGroupComponent.HiddenRow, - repeatingGroupComponent - ); - var rowLength = await dataModel.GetModelDataCount(groupBinding, defaultDataElementIdentifier, indexes) ?? 0; - // We add rows in reverse order, so that we can remove them without affecting the indexes of the remaining rows - foreach (var index in Enumerable.Range(0, rowLength).Reverse()) - { - // concatenate [...indexes, index] - var subIndexes = new int[(indexes?.Length ?? 0) + 1]; - indexes?.CopyTo(subIndexes.AsSpan()); - subIndexes[^1] = index; - - var rowChildren = new List(); - foreach (var child in repeatingGroupComponent.Children) - { - rowChildren.Add( - await GenerateComponentContextsRecurs( - child, - dataModel, - defaultDataElementIdentifier, - subIndexes - ) - ); - } - - children.Add( - new ComponentContext( - repeatingGroupRowComponent, - subIndexes, - defaultDataElementIdentifier, - rowChildren - ) - ); - } - } - - return new ComponentContext( - repeatingGroupComponent, - indexes?.Length > 0 ? indexes : null, - defaultDataElementIdentifier, - children - ); - } - - private async Task GenerateContextForSubComponent( - DataModel dataModel, - SubFormComponent subFormComponent, - DataElementIdentifier defaultDataElementIdentifier - ) - { - List children = []; - var layoutSetId = subFormComponent.LayoutSetId; - var layout = _layoutsLookup[layoutSetId]; - var dataElementsForSubForm = dataModel.Instance.Data.Where(d => d.DataType == layout.DefaultDataType.Id); - foreach (var dataElement in dataElementsForSubForm) - { - List subForms = []; - - foreach (var page in layout.Pages) - { - subForms.Add(await GenerateComponentContextsRecurs(page, dataModel, dataElement, indexes: null)); - } - - children.Add(new ComponentContext(subFormComponent, null, dataElement, subForms)); - } - - return new ComponentContext(subFormComponent, null, defaultDataElementIdentifier, children); - } - internal DataElementIdentifier? GetDefaultDataElementId(Instance instance) { return _defaultLayoutSet.GetDefaultDataElementId(instance); diff --git a/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs index 18075088ec..a2ba49380a 100644 --- a/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs +++ b/src/Altinn.App.Core/Models/Layout/LayoutSetComponent.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using Altinn.App.Core.Models.Layout.Components; using Altinn.Platform.Storage.Interface.Models; @@ -6,7 +7,7 @@ namespace Altinn.App.Core.Models.Layout; /// /// Wrapper class for a single layout set /// -public record LayoutSetComponent +public sealed class LayoutSetComponent { private readonly Dictionary _pagesLookup; private readonly List _pages; @@ -22,6 +23,18 @@ public LayoutSetComponent(List pages, string id, DataType default DefaultDataType = defaultDataType; } + internal LayoutSetComponent( + IReadOnlyDictionary layouts, + string layoutSetId, + DataType defaultDataType + ) + { + _pages = layouts.Select(kvp => new PageComponent(kvp.Value, kvp.Key, layoutSetId)).ToList(); + _pagesLookup = _pages.ToDictionary(p => p.PageId); + Id = layoutSetId; + DefaultDataType = defaultDataType; + } + /// /// Name of the layout set /// diff --git a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs b/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs deleted file mode 100644 index bb40442027..0000000000 --- a/src/Altinn.App.Core/Models/Layout/PageComponentConverter.cs +++ /dev/null @@ -1,639 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Runtime.CompilerServices; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using Altinn.App.Core.Helpers.Extensions; -using Altinn.App.Core.Models.Expressions; -using Altinn.App.Core.Models.Layout.Components; - -namespace Altinn.App.Core.Models.Layout; - -/// -/// Custom converter for parsing Layout files in json format to -/// -/// -/// The layout files in json format contains lots of polymorphism witch is hard for the -/// standard json parser to convert to an object graph. Using -/// directly I can convert to a more suitable C# representation directly -/// -public class PageComponentConverter : JsonConverter -{ - private static readonly AsyncLocal<(string layoutId, string pageName)?> _asyncLocal = new(); - - /// - /// Store pageName to be used for deserialization - /// - /// - /// JsonSerializer does not support passing additional arguments for deserialization, and we - /// need the pageName to be part of the PageCompoenent at construction time to ensure nullability rules - /// - /// This uses a AsyncLocal to pass the pageName as an additional parameter - /// - public static void SetAsyncLocalPageName(string layoutId, string pageName) - { - _asyncLocal.Value = (layoutId, pageName); - } - - /// - public override PageComponent Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) - { - // Try to get pagename from metadata in this.AddPageName - var pageName = _asyncLocal.Value?.pageName ?? "UnknownPageName"; - var layoutId = _asyncLocal.Value?.layoutId ?? "UnknownLayoutSetId"; - - _asyncLocal.Value = null; - - return ReadNotNull(ref reader, pageName, layoutId, options); - } - - /// - /// Similar to read, but not nullable, and no pageName hack. - /// - public static PageComponent ReadNotNull( - ref Utf8JsonReader reader, - string pageName, - string layoutId, - JsonSerializerOptions options - ) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException( - $"Unexpected JSON token type '{reader.TokenType}', expected '{nameof(JsonTokenType.StartObject)}'" - ); - } - PageComponent? page = null; - - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - if (reader.TokenType != JsonTokenType.PropertyName) - { - // Think this is impossible. After a JsonTokenType.StartObject, everything should be JsonTokenType.PropertyName - throw new JsonException( - $"Unexpected JSON token type after StartObject: '{reader.TokenType}', expected '{nameof(JsonTokenType.PropertyName)}'" - ); - } - - var propertyName = reader.GetString(); - reader.Read(); - if (propertyName == "data") - { - page = ReadData(ref reader, pageName, layoutId, options); - } - else - { - // Ignore other properties than "data" - reader.Skip(); - } - } - if (page is null) - { - throw new JsonException("Missing property \"data\" on layout page"); - } - return page; - } - - private static PageComponent ReadData( - ref Utf8JsonReader reader, - string pageName, - string layoutId, - JsonSerializerOptions options - ) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException( - $"Unexpected JSON token type '{reader.TokenType}', expected '{nameof(JsonTokenType.StartObject)}'" - ); - } - - List? componentListFlat = null; - Dictionary? componentLookup = null; - Dictionary? childToGroupMapping = null; - - // Hidden is the only property that cascades. - Expression? hidden = null; - Expression? required = null; - Expression? readOnly = null; - - // extra properties that are not stored in a specific class. - Dictionary additionalProperties = new(); - - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - if (reader.TokenType != JsonTokenType.PropertyName) - { - // Think this is impossible. After a JsonTokenType.StartObject, everything should be JsonTokenType.PropertyName - throw new JsonException( - $"Unexpected JSON token type after StartObject: '{reader.TokenType}', expected '{nameof(JsonTokenType.PropertyName)}'" - ); - } - - var propertyName = - reader.GetString() - ?? throw new JsonException( - $"Could not read property name from JSON token with type '{nameof(JsonTokenType.PropertyName)}'" - ); - - reader.Read(); - switch (propertyName.ToLowerInvariant()) - { - case "layout": - (componentListFlat, componentLookup, childToGroupMapping) = ReadLayout(ref reader, options); - break; - case "hidden": - hidden = ExpressionConverter.ReadStatic(ref reader, options); - break; - case "required": - required = ExpressionConverter.ReadStatic(ref reader, options); - break; - case "readonly": - readOnly = ExpressionConverter.ReadStatic(ref reader, options); - break; - default: - // read extra properties - additionalProperties[propertyName] = reader.SkipReturnString(); - break; - } - } - - if (componentListFlat is null || componentLookup is null || childToGroupMapping is null) - { - throw new JsonException("Missing property \"layout\" on layout page"); - } - - var layout = ProcessLayout(componentListFlat, componentLookup, childToGroupMapping); - - return new PageComponent( - pageName, - layoutId, - layout, - componentLookup, - hidden ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - additionalProperties - ); - } - - private static ( - List, - Dictionary, - Dictionary - ) ReadLayout(ref Utf8JsonReader reader, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartArray) - { - throw new JsonException(); - } - - var componentListFlat = new List(); - var componentLookup = new Dictionary(); - var childToGroupMapping = new Dictionary(); - - while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) - { - var component = ReadComponent(ref reader, options); - - // Add component to the collections - componentListFlat.Add(component); - AddToComponentLookup(component, componentLookup); - if (component is GroupComponent groupComponent) - { - AddChildrenToMapping(groupComponent, groupComponent.ChildIDs, childToGroupMapping); - } - } - - return (componentListFlat, componentLookup, childToGroupMapping); - } - - private static List ProcessLayout( - List componentListFlat, - Dictionary componentLookup, - Dictionary childToGroupMapping - ) - { - var layout = new List(); - foreach (var component in componentListFlat) - { - if (component is GroupComponent groupComponent) - { - foreach (var childID in groupComponent.ChildIDs) - { - if (!componentLookup.TryGetValue(childID, out var child)) - { - throw new InvalidOperationException( - $"""Group "{component.Id}" references a child with id \"{childID}\" which was not found in layout""" - ); - } - - groupComponent.AddChild(child); - } - } - - if (!childToGroupMapping.ContainsKey(component.Id)) - { - layout.Add(component); - } - } - return layout; - } - - private static void AddToComponentLookup(BaseComponent component, Dictionary componentLookup) - { - if (!componentLookup.TryAdd(component.Id, component)) - { - throw new JsonException($"Duplicate key \"{component.Id}\" detected"); - } - } - - private static readonly Regex _multiPageIndexRegex = new Regex( - @"^(\d+:)?([^\s:]+)$", - RegexOptions.None, - TimeSpan.FromSeconds(1) - ); - - private static string GetIdWithoutMultiPageIndex(string id) - { - var match = _multiPageIndexRegex.Match(id); - return match.Groups[2].Value; - } - - private static void AddChildrenToMapping( - GroupComponent component, - IEnumerable children, - Dictionary childToGroupMapping - ) - { - foreach (var childId in children) - { - if (childToGroupMapping.TryGetValue(childId, out var existingMapping)) - { - throw new JsonException( - $"Component \"{component.Id}\" tried to claim \"{childId}\" as a child, but that child is already claimed by \"{existingMapping.Id}\"" - ); - } - childToGroupMapping[childId] = component; - } - } - - private static BaseComponent ReadComponent(ref Utf8JsonReader reader, JsonSerializerOptions options) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException( - $"Unexpected JSON token type '{reader.TokenType}', expected '{nameof(JsonTokenType.StartObject)}'" - ); - } - string? id = null; - string? type = null; - Dictionary? dataModelBindings = null; - Expression? hidden = null; - Expression? hiddenRow = null; - Expression? required = null; - Expression? readOnly = null; - // Custom properities for group - List? childIDs = null; - int maxCount = 1; // > 1 is repeating, but might not be specified for non-repeating groups - // Custom properties for Summary - string? componentRef = null; - // Custom properties for components with optionId or literal options - string? optionId = null; - List? literalOptions = null; - OptionsSource? optionsSource = null; - bool secure = false; - // Custom properties for subform - string? layoutSetId = null; - // List? tableColumns = null; - // bool showAddButton = true; - // bool showDeleteButton = true; - - // extra properties that are not stored in a specific class. - Dictionary additionalProperties = new(); - - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - if (reader.TokenType != JsonTokenType.PropertyName) - { - // Think this is impossible. After a JsonTokenType.StartObject, everything should be JsonTokenType.PropertyName - throw new JsonException( - $"Unexpected JSON token type after StartObject: '{reader.TokenType}', expected '{nameof(JsonTokenType.PropertyName)}'" - ); - } - - var propertyName = - reader.GetString() - ?? throw new JsonException( - $"Could not read property name from JSON token with type '{nameof(JsonTokenType.PropertyName)}'" - ); - - reader.Read(); - switch (propertyName.ToLowerInvariant()) - { - case "id": - id = reader.GetString(); - break; - case "type": - type = reader.GetString(); - break; - case "datamodelbindings": - dataModelBindings = DeserializeModelBindings(ref reader, options); - break; - // case "textresourcebindings": - // break; - case "children": - childIDs = JsonSerializer - .Deserialize>(ref reader, options) - ?.Select(GetIdWithoutMultiPageIndex) - .ToList(); - break; - case "rows": - childIDs = GridConfig.ReadGridChildren(ref reader, options); - break; - case "maxcount": - maxCount = reader.GetInt32(); - break; - case "hidden": - hidden = ExpressionConverter.ReadStatic(ref reader, options); - break; - case "hiddenrow": - hiddenRow = ExpressionConverter.ReadStatic(ref reader, options); - break; - case "required": - required = ExpressionConverter.ReadStatic(ref reader, options); - break; - case "readonly": - readOnly = ExpressionConverter.ReadStatic(ref reader, options); - break; - // summary - case "componentref": - componentRef = reader.GetString(); - break; - // option - case "optionsid": - optionId = reader.GetString(); - break; - case "options": - literalOptions = JsonSerializer.Deserialize>(ref reader, options); - break; - case "source": - optionsSource = JsonSerializer.Deserialize(ref reader, options); - break; - case "secure": - secure = reader.TokenType == JsonTokenType.True; - break; - // subform - case "layoutset": - layoutSetId = reader.GetString(); - break; - // case "tablecolumns": - // tableColumns = JsonSerializer.Deserialize>(ref reader, options); - // break; - // case "showaddbutton": - // showAddButton = reader.TokenType != JsonTokenType.False; - // break; - // case "showdeletebutton": - // showDeleteButton = reader.TokenType != JsonTokenType.False; - // break; - default: - // store extra fields as json - additionalProperties[propertyName] = reader.SkipReturnString(); - break; - } - } - ThrowJsonExceptionIfNull(id); - ThrowJsonExceptionIfNull(type); - - switch (type.ToLowerInvariant()) - { - case "repeatinggroup": - if (!(dataModelBindings?.ContainsKey("group") ?? false)) - { - throw new JsonException( - $"A repeating group id:\"{id}\" does not have a \"group\" dataModelBinding" - ); - } - - var directRepComponent = new RepeatingGroupComponent( - id, - type, - dataModelBindings, - new List(), - childIDs - ?? throw new JsonException( - "Component with \"type\": \"Group\" requires a \"children\" property" - ), - maxCount, - hidden ?? Expression.False, - hiddenRow ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - additionalProperties - ); - return directRepComponent; - - case "group": - ThrowJsonExceptionIfNull( - childIDs, - "Component with \"type\": \"Group\" requires a \"children\" property" - ); - - if (maxCount > 1) - { - if (!(dataModelBindings?.ContainsKey("group") ?? false)) - { - 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(), - childIDs, - maxCount, - hidden ?? Expression.False, - hiddenRow ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - additionalProperties - ); - return repComponent; - } - else - { - var groupComponent = new GroupComponent( - id, - type, - dataModelBindings, - new List(), - childIDs, - hidden ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - additionalProperties - ); - return groupComponent; - } - case "grid": - var gridComponent = new GridComponent( - id, - type, - dataModelBindings, - new List(), - childIDs, - hidden ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - additionalProperties - ); - return gridComponent; - case "summary": - return new SummaryComponent( - id, - type, - hidden ?? Expression.False, - componentRef - ?? throw new JsonException( - "Component with \"type\": \"Summary\" requires the \"componentRef\" property" - ), - additionalProperties - ); - case "checkboxes": - case "radiobuttons": - case "dropdown": - ValidateOptions(optionId, literalOptions, optionsSource, secure); - return new OptionsComponent( - id, - type, - dataModelBindings, - hidden ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - optionId, - literalOptions, - optionsSource, - secure, - additionalProperties - ); - case "subform": - return new SubFormComponent( - id, - type, - dataModelBindings, - layoutSetId ?? throw new JsonException("Subform requires a layoutSetId"), - // tableColumns ?? new(), - // showAddButton, - // showDeleteButton, - hidden ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - additionalProperties - ); - } - - // Most components are handled as BaseComponent - return new BaseComponent( - id, - type, - dataModelBindings, - hidden ?? Expression.False, - required ?? Expression.False, - readOnly ?? Expression.False, - additionalProperties - ); - } - - private static Dictionary DeserializeModelBindings( - ref Utf8JsonReader reader, - JsonSerializerOptions options - ) - { - var modelBindings = new Dictionary(); - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException("Expected StartObject token for \"dataModelBindings\""); - } - - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - if (reader.TokenType != JsonTokenType.PropertyName) - { - throw new JsonException(); - } - - // ! Token type is PropertyName so value is a string - var propertyName = reader.GetString()!; - reader.Read(); - modelBindings[propertyName] = reader.TokenType switch - { - JsonTokenType.String => new ModelBinding { Field = reader.GetString() ?? throw new JsonException() }, - JsonTokenType.StartObject => JsonSerializer.Deserialize(ref reader, options), - _ => throw new JsonException(), - }; - } - - return modelBindings; - } - - private static void ValidateOptions( - string? optionId, - List? literalOptions, - OptionsSource? optionsSource, - bool secure - ) - { - if (optionId is null && literalOptions is null && optionsSource is null) - { - throw new JsonException( - "\"optionId\" or \"options\" or \"source\" is required on checkboxes, radiobuttons and dropdowns" - ); - } - if (optionId is not null && literalOptions is not null) - { - throw new JsonException("\"optionId\" and \"options\" can't both be specified"); - } - if (optionId is not null && optionsSource is not null) - { - throw new JsonException("\"optionId\" and \"source\" can't both be specified"); - } - if (optionsSource is not null && literalOptions is not null) - { - throw new JsonException("\"source\" and \"options\" can't both be specified"); - } - if (literalOptions is not null && secure) - { - throw new JsonException("\"secure\": true is invalid for components with literal \"options\""); - } - if (optionsSource is not null && secure) - { - throw new JsonException( - "\"secure\": true is invalid for components that reference a repeating group \"source\"" - ); - } - } - - /// - /// Utility method to recduce so called Coginitve Complexity by writing if in the meth - /// - private static void ThrowJsonExceptionIfNull( - [NotNull] object? obj, - string? message = null, - [CallerArgumentExpression("obj")] string? propertyName = null - ) - { - if (obj is null) - { - throw new JsonException(message ?? $"\"{propertyName}\" property of component should not be null"); - } - } - - /// - public override void Write(Utf8JsonWriter writer, PageComponent value, JsonSerializerOptions options) - { - throw new NotImplementedException(); - } -} diff --git a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs index 6b6bbc831f..3680a8055a 100644 --- a/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Validators/Default/ExpressionValidatorTests.cs @@ -11,7 +11,6 @@ using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Models.Validation; -using Altinn.App.Core.Tests.LayoutExpressions.CommonTests; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using Altinn.Platform.Storage.Interface.Models; @@ -92,7 +91,7 @@ private async Task RunExpressionValidationTest(string fileName, string folder) var dataModel = DynamicClassBuilder.DataAccessorFromJsonDocument(instance, testCase.FormData, dataElement); - var layout = new LayoutSetComponent(testCase.Layouts.Values.ToList(), "layout", dataType); + var layout = new LayoutSetComponent(testCase.Layouts, "layout", dataType); var componentModel = new LayoutModel([layout], null); var translationService = new TranslationService( new AppIdentifier("org", "app"), @@ -162,8 +161,7 @@ public record ExpressionValidationTestModel public required JsonElement FormData { get; set; } [JsonPropertyName("layouts")] - [JsonConverter(typeof(LayoutModelConverterFromObject))] - public required IReadOnlyDictionary Layouts { get; set; } + public required IReadOnlyDictionary Layouts { get; set; } [JsonPropertyName("textResources")] public List? TextResources { get; set; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs index df4ea2b7d3..ce760cf8bb 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ContextListRoot.cs @@ -25,8 +25,7 @@ public class ContextListRoot public string Name { get; set; } = default!; [JsonPropertyName("layouts")] - [JsonConverter(typeof(LayoutModelConverterFromObject))] - public IReadOnlyDictionary Layouts { get; set; } = default!; + public IReadOnlyDictionary Layouts { get; set; } = default!; [JsonPropertyName("dataModel")] public JsonElement? DataModel { get; set; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs index ee2c6e88ee..57640cccd6 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/ExpressionTestCaseRoot.cs @@ -1,11 +1,9 @@ using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.RegularExpressions; using Altinn.App.Core.Configuration; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Models.Expressions; -using Altinn.App.Core.Models.Layout; -using Altinn.App.Core.Models.Layout.Components; +using Altinn.App.Core.Models.Layout.Components.Base; using Altinn.Platform.Storage.Interface.Models; namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; @@ -58,8 +56,7 @@ public class TestCaseItem public List? TestCases { get; set; } [JsonPropertyName("layouts")] - [JsonConverter(typeof(LayoutModelConverterFromObject))] - public IReadOnlyDictionary? Layouts { get; set; } + public IReadOnlyDictionary? Layouts { get; set; } [JsonPropertyName("dataModel")] public JsonElement? DataModel { get; set; } @@ -89,6 +86,23 @@ public override string ToString() { return $"{Filename}: {Name}"; } + + public async Task GetContextOrNull(LayoutEvaluatorState state) + { + ComponentContext? context = null; + if (Context is not null) + { + //! Some tests do not need context, but it is not nullable in expression evaluator + context = await state.GetComponentContext( + Context.CurrentPageName, + Context.ComponentId, + Context.RowIndices + )!; + } + + //! Some tests do not need context, but it is not nullable in expression evaluator + return context!; + } } public class DataModelAndElement @@ -109,29 +123,16 @@ public class ProfileSettings public class ComponentContextForTestSpec { [JsonPropertyName("component")] - public string ComponentId { get; set; } = default!; + public required string ComponentId { get; init; } [JsonPropertyName("rowIndices")] - public int[]? RowIndices { get; set; } + public int[]? RowIndices { get; init; } [JsonPropertyName("currentLayout")] - public string CurrentPageName { get; set; } = default!; + public required string CurrentPageName { get; init; } [JsonPropertyName("children")] - public IEnumerable ChildContexts { get; set; } = - Enumerable.Empty(); - - public ComponentContext ToContext(LayoutModel? model, LayoutEvaluatorState state) - { - var component = model?.GetComponent(CurrentPageName, ComponentId); - return new ComponentContext( - component, - RowIndices, - // TODO: get from data model, but currently not important for tests - state.GetDefaultDataElementId(), - ChildContexts.Select(c => c.ToContext(model, state)).ToList() - ); - } + public List ChildContexts { get; init; } = []; public static ComponentContextForTestSpec FromContext(ComponentContext context) { @@ -140,17 +141,16 @@ public static ComponentContextForTestSpec FromContext(ComponentContext context) ArgumentNullException.ThrowIfNull(context.Component.PageId); var id = context.Component.Id; - // Remove guid from the end of the id - if (Regex.IsMatch(id, @".*_[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}")) - { - id = id.Substring(0, id.LastIndexOf('_')); - } + List childContexts = context + .ChildContexts.SelectMany(c => c.Component is RepeatingGroupRowComponent ? c.ChildContexts : [c]) // Flatten out the synthetic Row components that are not used in test spec + .Select(FromContext) + .ToList(); return new ComponentContextForTestSpec { ComponentId = id, CurrentPageName = context.Component.PageId, - ChildContexts = context.ChildContexts?.Select(FromContext) ?? [], + ChildContexts = childContexts, RowIndices = context.RowIndices, }; } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs deleted file mode 100644 index cf8c1e6dd4..0000000000 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/LayoutModelConverterFromObject.cs +++ /dev/null @@ -1,67 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; -using Altinn.App.Core.Models.Layout; -using Altinn.App.Core.Models.Layout.Components; - -namespace Altinn.App.Core.Tests.LayoutExpressions.CommonTests; - -/// -/// Custom converter for parsing Layout files in json format to -/// -/// -/// The layout files in json format contains lots of polymorphism witch is hard for the -/// standard json parser to convert to an object graph. Using -/// directly I can convert to a more suitable C# representation directly -/// -public class LayoutModelConverterFromObject : JsonConverter> -{ - /// - public override IReadOnlyDictionary? Read( - ref Utf8JsonReader reader, - Type typeToConvert, - JsonSerializerOptions options - ) - { - if (reader.TokenType != JsonTokenType.StartObject) - { - throw new JsonException( - $"Unexpected JSON token type '{reader.TokenType}', expected '{nameof(JsonTokenType.StartObject)}'" - ); - } - - var pages = new Dictionary(); - - // Read dictionary of pages - while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) - { - if (reader.TokenType != JsonTokenType.PropertyName) - { - // Think this is impossible. After a JsonTokenType.StartObject, everything should be JsonTokenType.PropertyName - throw new JsonException( - $"Unexpected JSON token type after StartObject: '{reader.TokenType}', expected '{nameof(JsonTokenType.PropertyName)}'" - ); - } - - var pageName = - reader.GetString() - ?? throw new JsonException( - $"Could not read property name from JSON token with type '{nameof(JsonTokenType.PropertyName)}'" - ); - reader.Read(); - - pages[pageName] = PageComponentConverter.ReadNotNull(ref reader, pageName, "test-layout", options); - } - - return pages; - } - - /// - public override void Write( - Utf8JsonWriter writer, - IReadOnlyDictionary value, - JsonSerializerOptions options - ) - { - throw new NotImplementedException(); - } -} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs index b58ac4b36f..7d2ddbfd8f 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestBackendExclusiveFunctions.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -65,7 +65,7 @@ private async Task RunTestCase(string testName, string folder) _output.WriteLine(test.RawJson); _output.WriteLine(test.FullPath); var dataType = new DataType() { Id = "default" }; - var layout = new LayoutSetComponent(test.Layouts!.Values.ToList(), "layout", dataType); + var layout = new LayoutSetComponent(test.Layouts!, "layout", dataType); var componentModel = new LayoutModel([layout], null); var state = new LayoutEvaluatorState( DynamicClassBuilder.DataAccessorFromJsonDocument( @@ -92,7 +92,7 @@ private async Task RunTestCase(string testName, string folder) await ExpressionEvaluator.EvaluateExpression( state, test.Expression, - test.Context?.ToContext(componentModel, state)! + await test.GetContextOrNull(state) ); }; (await act.Should().ThrowAsync()).WithMessage(test.ExpectsFailure); @@ -106,7 +106,7 @@ await ExpressionEvaluator.EvaluateExpression( var result = await ExpressionEvaluator.EvaluateExpression( state, test.Expression, - test.Context?.ToContext(componentModel, state)! + await test.GetContextOrNull(state)! ); switch (test.Expects.ValueKind) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs index e1053ade09..dea88d6c93 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestContextList.cs @@ -1,8 +1,8 @@ using System.Text.Json; using System.Text.Json.Serialization; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Models; using Altinn.App.Core.Models.Layout; +using Altinn.App.Core.Models.Layout.Components; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; using Altinn.Platform.Storage.Interface.Models; @@ -71,7 +71,8 @@ private async Task RunTestCase(string filename, string folder) var instance = new Instance() { Data = [] }; var dataType = new DataType() { Id = "default" }; - var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataType); + var layout = new LayoutSetComponent(test.Layouts, "layout", dataType); + var componentModel = new LayoutModel([layout], null); var state = new LayoutEvaluatorState( DynamicClassBuilder.DataAccessorFromJsonDocument( @@ -85,14 +86,19 @@ private async Task RunTestCase(string filename, string folder) test.ParsingException.Should().BeNull("Loading of test failed"); - var results = (await state.GetComponentContexts()) - .Select(c => ComponentContextForTestSpec.FromContext(c)) - .ToList(); + var results = (await state.GetComponentContexts()).Select(ComponentContextForTestSpec.FromContext).ToList(); _output.WriteLine(JsonSerializer.Serialize(new { resultContexts = results }, _jsonSerializerOptions)); foreach (var (result, expected, index) in results.Zip(test.Expected, Enumerable.Range(0, int.MaxValue))) { - result.Should().BeEquivalentTo(expected, "component context at {0} should match", index); + result + .Should() + .BeEquivalentTo( + expected, + opt => opt.WithStrictOrdering(), + "component context at {0} should match", + index + ); } results.Count.Should().Be(test.Expected.Count); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs index 444eee364c..a0f338b7d1 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestFunctions.cs @@ -1,7 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Configuration; using Altinn.App.Core.Features; -using Altinn.App.Core.Helpers.DataModel; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.Expressions; using Altinn.App.Core.Internal.Texts; @@ -285,41 +284,33 @@ private async Task RunTestCase(string testName, string folder) LayoutModel? componentModel = null; if (test.Layouts is not null) { - var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataTypes[0]); + var layout = new LayoutSetComponent(test.Layouts, "layout", dataTypes[0]); componentModel = new LayoutModel([layout], null); } else if (test.Layouts is null && dataTypes.Count > 0) { // Create a working dummy layout to avoid null reference exceptions and make dataModel lookups work. - var componentLookup = new Dictionary(); - componentLookup.Add( - "myParagraph", - new BaseComponent( - "myParagraph", - "Paragraph", - null, - new Expression(), - new Expression(), - new Expression(), - null - ) + using var document = JsonDocument.Parse( + """ + { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout":[ + { + "id": "myParagraph", + "type": "paragraph" + } + ] + } + } + """ ); - var componentList = new List(); - componentList.Add(componentLookup["myParagraph"]); + var pageId = "page"; + var layoutId = "layout"; + var layout = new LayoutSetComponent( - [ - new PageComponent( - "formLayout", - "formLayout", - componentList, - componentLookup, - new Expression(), - new Expression(), - new Expression(), - null - ), - ], - "layout", + [new PageComponent(document.RootElement, pageId, layoutId)], + layoutId, dataTypes[0] ); componentModel = new LayoutModel([layout], null); @@ -351,13 +342,11 @@ private async Task RunTestCase(string testName, string folder) ComponentContext? context = null; if (test.Context is not null) { - context = test.Context.ToContext(componentModel, state); + context = await test.GetContextOrNull(state); } else if (componentModel is not null) { - context = ( - await componentModel.GenerateComponentContexts(test.Instance, new DataModel(dataAccessor)) - ).First(); + context = (await componentModel.GenerateComponentContexts(state)).First(); } if (test.ExpectsFailure is not null && test.ParsingException is not null) diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs index 3f7b6bf6b3..576576f9f3 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/TestInvalid.cs @@ -1,6 +1,6 @@ using System.Text.Json; using Altinn.App.Core.Internal.Expressions; -using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Expressions; using Altinn.App.Core.Models.Layout; using Altinn.App.Core.Tests.LayoutExpressions.TestUtilities; using Altinn.App.Core.Tests.TestUtils; @@ -40,7 +40,7 @@ public async Task Simple_Theory(string testName, string folder) LayoutModel? componentModel = null; if (test.Layouts is not null) { - var layout = new LayoutSetComponent(test.Layouts.Values.ToList(), "layout", dataType); + var layout = new LayoutSetComponent(test.Layouts, "layout", dataType); componentModel = new LayoutModel([layout], null); } @@ -53,11 +53,8 @@ public async Task Simple_Theory(string testName, string folder) null!, test.FrontEndSettings ?? new() ); - await ExpressionEvaluator.EvaluateExpression( - state, - test.Expression, - test.Context?.ToContext(componentModel, state) ?? null! - ); + + await ExpressionEvaluator.EvaluateExpression(state, test.Expression, await test.GetContextOrNull(state)); }; (await act.Should().ThrowAsync()).WithMessage(testCase.ExpectsFailure); } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/oneRow.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/oneRow.json index e8a54ba62b..93f6f659ab 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/oneRow.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/oneRow.json @@ -68,21 +68,14 @@ "currentLayout": "Page1", "children": [ { - "component":"group1", - "currentLayout":"Page1", - "rowIndices":[0], - "children": [ - { - "component": "comp3", - "rowIndices": [0], - "currentLayout": "Page1" - }, - { - "component": "comp4", - "rowIndices": [0], - "currentLayout": "Page1" - } - ] + "component": "comp3", + "rowIndices": [0], + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [0], + "currentLayout": "Page1" } ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/rows-before-after.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/rows-before-after.json new file mode 100644 index 0000000000..8d9e2e3e7c --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/rows-before-after.json @@ -0,0 +1,163 @@ +{ + "name": "Group Layout with two rows", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "group1", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "gruppe1" + }, + "children": ["comp3", "comp4"], + "rowsAfter": [ + { + "cells": + [ + { + "text": "Group row 1" + }, + { + "component": "after" + } + ] + } + ], + "rowsBefore": [ + { + "cells": + [ + { + "text": "Group row 0" + }, + { + "component": "before" + } + ] + } + ] + }, + { + "id": "comp3", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + }, + { + "id": "before", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "beforebinding" + } + }, + { + "id": "after", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "afterbinding" + } + } + ] + } + }, + "Page2": { + "data": { + "layout": [ + { + "id": "comp5", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "asdf" + } + }, + { + "id": "comp6", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "asdf" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "group1", + "currentLayout": "Page1", + "children": [ + { + "component": "before", + "currentLayout": "Page1" + }, + { + "component": "comp3", + "rowIndices": [0], + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [0], + "currentLayout": "Page1" + }, + { + "component": "comp3", + "rowIndices": [1], + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [1], + "currentLayout": "Page1" + }, + { + "component": "after", + "currentLayout": "Page1" + } + ] + } + ] + }, + { + "component": "Page2", + "currentLayout": "Page2", + "children": [ + { + "component": "comp5", + "currentLayout": "Page2" + }, + { + "component": "comp6", + "currentLayout": "Page2" + } + ] + } + ], + "dataModel": { + "gruppe1": [ + { "altinnRowId": "row1", "comp3binding": "123", "comp4binding": "dadda" }, + { "altinnRowId": "row2", "comp3binding": "456", "comp4binding": "dodo" } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/twoRows.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/twoRows.json index 2520e64e64..78620152b8 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/twoRows.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/groups/twoRows.json @@ -68,39 +68,25 @@ "currentLayout": "Page1", "children": [ { - "component": "group1", - "currentLayout": "Page1", + "component": "comp3", "rowIndices": [0], - "children": [ - { - "component": "comp3", - "rowIndices": [0], - "currentLayout": "Page1" - }, - { - "component": "comp4", - "rowIndices": [0], - "currentLayout": "Page1" - } - ] + "currentLayout": "Page1" }, { - "component": "group1", - "currentLayout": "Page1", + "component": "comp4", + "rowIndices": [0], + "currentLayout": "Page1" + }, + { + "component": "comp3", "rowIndices": [1], - "children": [ - { - "component": "comp3", - "rowIndices": [1], - "currentLayout": "Page1" - }, - { - "component": "comp4", - "rowIndices": [1], - "currentLayout": "Page1" - } - ] - } + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [1], + "currentLayout": "Page1" + } ] } ] diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/accordion-in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/accordion-in-group.json new file mode 100644 index 0000000000..8b77d43f9f --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/accordion-in-group.json @@ -0,0 +1,86 @@ +{ + "name": "Accordion component in repeating group with two rows", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "group1", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "gruppe1" + }, + "children": ["comp3"] + }, + { + "id": "comp3", + "type": "Accordion", + "children": ["comp4"], + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "group1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp3", + "rowIndices": [0], + "currentLayout": "Page1", + "children":[ + { + "component": "comp4", + "rowIndices": [0], + "currentLayout": "Page1" + } + ] + }, + { + "component": "comp3", + "rowIndices": [1], + "currentLayout": "Page1", + "children":[ + { + "component": "comp4", + "rowIndices": [1], + "currentLayout": "Page1" + } + ] + } + ] + } + ] + } + ], + "dataModel": { + "gruppe1": [ + { "altinnRowId": "row1", "comp3binding": "123", "comp4binding": "dadda" }, + { "altinnRowId": "row2", "comp3binding": "456", "comp4binding": "dodo" } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/accordion.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/accordion.json new file mode 100644 index 0000000000..e9724de544 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/accordion.json @@ -0,0 +1,60 @@ +{ + "name": "Accordion component with two children", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "accordion1", + "type": "Accordion", + "children": ["comp3","comp4"] + }, + { + "id": "comp3", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "accordion1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp3", + "currentLayout": "Page1" + }, + { + "component": "comp4", + "currentLayout": "Page1" + } + ] + } + ] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/cards-in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/cards-in-group.json new file mode 100644 index 0000000000..ec948370f0 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/cards-in-group.json @@ -0,0 +1,108 @@ +{ + "name": "Simple cards layout with two components in a group", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "group1", + "type": "Group", + "dataModelBindings": { + "group": "gruppe1" + }, + "maxCount": 0, + "children": ["cards1"] + }, + { + "id": "cards1", + "type": "Cards", + "cards": [ + { + "children": ["comp3"] + }, + { + "children": ["comp4"] + } + ] + }, + { + "id": "comp3", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "dataModel": { + "gruppe1": [ + { "altinnRowId": "row1", "comp3binding": "123", "comp4binding": "dadda" }, + { "altinnRowId": "row2", "comp3binding": "456", "comp4binding": "dodo" } + ] + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "group1", + "currentLayout": "Page1", + "children": [ + { + "component": "cards1", + "rowIndices": [0], + "currentLayout": "Page1", + "children":[ + { + "component": "comp3", + "rowIndices": [0], + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [0], + "currentLayout": "Page1" + } + ] + }, + { + "component": "cards1", + "rowIndices": [1], + "currentLayout": "Page1", + "children":[ + { + "component": "comp3", + "rowIndices": [1], + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [1], + "currentLayout": "Page1" + } + ] + } + ] + } + ] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/cards.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/cards.json new file mode 100644 index 0000000000..7557c3b04e --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/cards.json @@ -0,0 +1,67 @@ +{ + "name": "Simple cards layout with two components", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "cards1", + "type": "Cards", + "cards": [ + { + "children": ["comp3"] + }, + { + "children": ["comp4"] + } + ] + }, + { + "id": "comp3", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "cards1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp3", + "currentLayout": "Page1" + }, + { + "component": "comp4", + "currentLayout": "Page1" + } + ] + } + ] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/grid-in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/grid-in-group.json new file mode 100644 index 0000000000..0a28710157 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/grid-in-group.json @@ -0,0 +1,107 @@ +{ + "name": "Grid component in repeating group with two rows", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "group1", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "gruppe1" + }, + "children": ["comp3"] + }, + { + "id": "comp3", + "type": "Grid", + "rows": [ + { + "cells": [ + { + "text": "Group row 0" + }, + { + "component": "comp4" + } + ] + }, + { + "cells": [ + { + "text": "Group row 1" + }, + { + "labelFrom": "comp4" + } + ] + } + ], + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "group1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp3", + "rowIndices": [0], + "currentLayout": "Page1", + "children":[ + { + "component": "comp4", + "rowIndices": [0], + "currentLayout": "Page1" + } + ] + }, + { + "component": "comp3", + "rowIndices": [1], + "currentLayout": "Page1", + "children":[ + { + "component": "comp4", + "rowIndices": [1], + "currentLayout": "Page1" + } + ] + } + ] + } + ] + } + ], + "dataModel": { + "gruppe1": [ + { "altinnRowId": "row1", "comp3binding": "123", "comp4binding": "dadda" }, + { "altinnRowId": "row2", "comp3binding": "456", "comp4binding": "dodo" } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/grid.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/grid.json new file mode 100644 index 0000000000..abccba06d5 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/grid.json @@ -0,0 +1,81 @@ +{ + "name": "Simple grid with rows and columns", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "group1", + "type": "Grid", + "rows": [ + { + "cells": [ + { + "text": "Group row 0" + }, + { + "component": "comp3" + } + ] + }, + { + "cells": [ + { + "text": "Group row 1" + }, + { + "component": "comp4" + } + ] + } + ] + }, + { + "id": "comp3", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "group1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp3", + "currentLayout": "Page1" + }, + { + "component": "comp4", + "currentLayout": "Page1" + } + ] + } + ] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/tabs-in-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/tabs-in-group.json new file mode 100644 index 0000000000..d644ee8700 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/tabs-in-group.json @@ -0,0 +1,86 @@ +{ + "name": "Tabs in repeating group with two rows", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "group1", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "gruppe1" + }, + "children": ["comp3"] + }, + { + "id": "comp3", + "type": "Tabs", + "tabs": [{"id": "comp4", "children": ["comp4"]}], + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "group1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp3", + "rowIndices": [0], + "currentLayout": "Page1", + "children":[ + { + "component": "comp4", + "rowIndices": [0], + "currentLayout": "Page1" + } + ] + }, + { + "component": "comp3", + "rowIndices": [1], + "currentLayout": "Page1", + "children":[ + { + "component": "comp4", + "rowIndices": [1], + "currentLayout": "Page1" + } + ] + } + ] + } + ] + } + ], + "dataModel": { + "gruppe1": [ + { "altinnRowId": "row1", "comp3binding": "123", "comp4binding": "dadda" }, + { "altinnRowId": "row2", "comp3binding": "456", "comp4binding": "dodo" } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/tabs.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/tabs.json new file mode 100644 index 0000000000..32e649c57c --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/nonRepeatingGroups/tabs.json @@ -0,0 +1,60 @@ +{ + "name": "Simple tabs", + "layouts": { + "Page1": { + "data": { + "layout": [ + { + "id": "comp1", + "type": "Header" + }, + { + "id": "tabs1", + "type": "Tabs", + "tabs": [{"children": ["comp3"]},{"children": ["comp4"]}] + }, + { + "id": "comp3", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp3binding" + } + }, + { + "id": "comp4", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "gruppe1.comp4binding" + } + } + ] + } + } + }, + "expectedContexts": [ + { + "component": "Page1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp1", + "currentLayout": "Page1" + }, + { + "component": "tabs1", + "currentLayout": "Page1", + "children": [ + { + "component": "comp3", + "currentLayout": "Page1" + }, + { + "component": "comp4", + "currentLayout": "Page1" + } + ] + } + ] + } + ] +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveOneRow.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveOneRow.json index ffbadebc4a..e6f05b9f27 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveOneRow.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveOneRow.json @@ -76,33 +76,19 @@ "currentLayout": "Page1", "children": [ { - "component": "group0", + "component": "group1", "currentLayout": "Page1", "rowIndices": [0], "children": [ { - "component": "group1", + "component": "comp3", "currentLayout": "Page1", - "rowIndices": [0], - "children": [ - { - "component": "group1", - "currentLayout": "Page1", - "rowIndices": [0, 0], - "children": [ - { - "component": "comp3", - "currentLayout": "Page1", - "rowIndices": [0, 0] - }, - { - "component": "comp4", - "currentLayout": "Page1", - "rowIndices": [0, 0] - } - ] - } - ] + "rowIndices": [0, 0] + }, + { + "component": "comp4", + "currentLayout": "Page1", + "rowIndices": [0, 0] } ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsInner.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsInner.json index 4db3f26093..2e5c2efff5 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsInner.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsInner.json @@ -76,50 +76,29 @@ "currentLayout": "Page1", "children": [ { - "component": "group0", - "currentLayout": "Page1", + "component": "group1", "rowIndices": [0], + "currentLayout": "Page1", "children": [ { - "component": "group1", - "rowIndices": [0], + "component": "comp3", + "currentLayout": "Page1", + "rowIndices": [0, 0] + }, + { + "component": "comp4", + "currentLayout": "Page1", + "rowIndices": [0, 0] + }, + { + "component": "comp3", + "currentLayout": "Page1", + "rowIndices": [0, 1] + }, + { + "component": "comp4", "currentLayout": "Page1", - "children": [ - { - "component": "group1", - "currentLayout": "Page1", - "rowIndices": [0, 0], - "children": [ - { - "component": "comp3", - "currentLayout": "Page1", - "rowIndices": [0, 0] - }, - { - "component": "comp4", - "currentLayout": "Page1", - "rowIndices": [0, 0] - } - ] - }, - { - "component": "group1", - "currentLayout": "Page1", - "rowIndices": [0, 1], - "children": [ - { - "component": "comp3", - "currentLayout": "Page1", - "rowIndices": [0, 1] - }, - { - "component": "comp4", - "currentLayout": "Page1", - "rowIndices": [0, 1] - } - ] - } - ] + "rowIndices": [0, 1] } ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsOuter.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsOuter.json index af32c46a7c..6833b27e48 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsOuter.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/context-lists/recursiveGroups/recursiveTwoRowsOuter.json @@ -76,64 +76,36 @@ "currentLayout": "Page1", "children": [ { - "component": "group0", - "currentLayout": "Page1", + "component": "group1", "rowIndices": [0], + "currentLayout": "Page1", "children": [ { - "component": "group1", - "rowIndices": [0], - "currentLayout": "Page1", - "children": [ - { - "component": "group1", - "rowIndices": [0, 0], - "currentLayout": "Page1", - "children": [ - { - "component": "comp3", - "rowIndices": [0, 0], - "currentLayout": "Page1" - }, - { - "component": "comp4", - "rowIndices": [0, 0], - "currentLayout": "Page1" - } - ] - } - ] + "component": "comp3", + "rowIndices": [0, 0], + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [0, 0], + "currentLayout": "Page1" } ] }, { - "component": "group0", + "component": "group1", "rowIndices": [1], "currentLayout": "Page1", "children": [ { - "component": "group1", - "rowIndices": [1], - "currentLayout": "Page1", - "children": [ - { - "component": "group1", - "rowIndices": [1, 0], - "currentLayout": "Page1", - "children": [ - { - "component": "comp3", - "rowIndices": [1, 0], - "currentLayout": "Page1" - }, - { - "component": "comp4", - "rowIndices": [1, 0], - "currentLayout": "Page1" - } - ] - } - ] + "component": "comp3", + "rowIndices": [1, 0], + "currentLayout": "Page1" + }, + { + "component": "comp4", + "rowIndices": [1, 0], + "currentLayout": "Page1" } ] } diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/non-repeating-group-in-repeating-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/non-repeating-group-in-repeating-group.json new file mode 100644 index 0000000000..56f8774c22 --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/component/non-repeating-group-in-repeating-group.json @@ -0,0 +1,213 @@ +{ + "name": "Lookup inside nested repeating group", + "expression": ["component", "accordion-child"], + "expects": "Accordion child input 2,3", + "context": { + "component": "accordion", + "rowIndices": [2, 3], + "currentLayout": "Page1" + }, + "testCases": [ + { + "expression": ["component", "before"], + "expects": "Group before input 2,3" + }, + { + "expression": ["component", "after"], + "expects": "Group after input 2,3" + }, + { + "expression": ["component", "cards-child"], + "expects": "Cards child input 2,3" + }, + { + "expression": ["component", "non-repeating-group-child"], + "expects": "Non-repeating group child input 2,3" + }, + { + "expression": ["component", "tab1-child"], + "expects": "Tab 1 child input 2,3" + }, + { + "expression": ["component", "grid-child"], + "expects": "Grid child input 2,3" + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "group1", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "group1" + }, + "children": ["group2"] + }, + { + "id": "group2", + "type": "RepeatingGroup", + "dataModelBindings": { + "group": "group1.group2" + }, + "children": ["accordion", "cards", "non-repeating-group", "tabs", "grid"], + "rowsBefore": [ + { + "cells": + [ + { + "component": "before" + } + ] + } + ], + "rowsAfter": [ + { + "cells": + [ + { + "component": "after" + } + ] + } + ] + }, + { + "id": "before", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "group1.group2.before" + } + }, + { + "id": "accordion", + "type": "Accordion", + "children": ["accordion-child"] + }, + { + "id": "accordion-child", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "group1.group2.accordionChild" + } + }, + { + "id": "cards", + "type": "Cards", + "cards": [ + { + "children": ["cards-child"] + } + ] + }, + { + "id": "cards-child", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "group1.group2.cardsChild" + } + }, + { + "id": "non-repeating-group", + "type": "Group", + "dataModelBindings": { + "group": "group1.group2.nonRepeatingGroup" + }, + "children": ["non-repeating-group-child"] + }, + { + "id": "non-repeating-group-child", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "group1.group2.nonRepeatingGroupChild" + } + }, + { + "id": "tabs", + "type": "Tabs", + "tabs": [ + { + "id": "tab1", + "children": ["tab1-child"] + } + ] + }, + { + "id": "tab1-child", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "group1.group2.tab1Child" + } + }, + { + "id": "grid", + "type": "Grid", + "rows": [ + { + "cells": [{"component": "grid-child"}] + } + ] + }, + { + "id": "grid-child", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "group1.group2.gridChild" + } + }, + { + "id": "after", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "group1.group2.after" + } + } + ] + } + } + }, + "dataModel": { + "group1": [ + { + "group2": [ + { + "before": "FIRST UNUSED ROW", + "accordionChild": "FIRST UNUSED ROW", + "cardsChild": "FIRST UNUSED ROW", + "nonRepeatingGroupChild": "FIRST UNUSED ROW", + "tab1Child": "FIRST UNUSED ROW", + "gridChild": "FIRST UNUSED ROW", + "after": "FIRST UNUSED ROW" + } + ] + }, + {}, + { + "group2": [ + { + "before": "FIRST UNUSED ROW", + "accordionChild": "FIRST UNUSED ROW", + "cardsChild": "FIRST UNUSED ROW", + "nonRepeatingGroupChild": "FIRST UNUSED ROW", + "tab1Child": "FIRST UNUSED ROW", + "gridChild": "FIRST UNUSED ROW", + "after": "FIRST UNUSED ROW" + }, + {}, + {}, + { + "before": "Group before input 2,3", + "accordionChild": "Accordion child input 2,3", + "cardsChild": "Cards child input 2,3", + "nonRepeatingGroupChild": "Non-repeating group child input 2,3", + "tab1Child": "Tab 1 child input 2,3", + "gridChild": "Grid child input 2,3", + "after": "Group after input 2,3" + } + ] + } + ] + } +} diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existing-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existing-model.json index b65e299902..e3b0948c85 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existing-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/component-lookup-non-existing-model.json @@ -4,7 +4,7 @@ "component", "current-component" ], - "expectsFailure": "Data model with type non-existing not found", + "expectsFailure": "non-existing not found", "dataModels": [ { "dataElement": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json index 35ab8f531a..7dfabddfb7 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/dataModelMultiple/dataModel-non-existing-model.json @@ -5,7 +5,7 @@ "a.value", "non-existing" ], - "expectsFailure": "Data model with type non-existing not found", + "expectsFailure": "non-existing not found", "dataModels": [ { "dataElement": { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group-no-index-markers.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group-no-index-markers.json index c6cf039f17..68a3342703 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group-no-index-markers.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group-no-index-markers.json @@ -22,13 +22,13 @@ ], "layouts": { "Page1": { - "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", "data": { "layout": [] } }, "Page2": { - "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", "data": { "layout": [ { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group.json b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group.json index c6e2f17569..a945ccd189 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group.json +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/CommonTests/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group.json @@ -22,13 +22,13 @@ ], "layouts": { "Page1": { - "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", "data": { "layout": [] } }, "Page2": { - "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "$schema": "https://altinncdn.no/toolkits/altinn-app-frontend/4/schemas/json/layout/layout.schema.v1.json", "data": { "layout": [ { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs index b12be1218c..ca3b0663ac 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/ExpressionEvaluatorTests/ExpressionValueTests.cs @@ -170,13 +170,13 @@ public void TestUndefined() { ExpressionValue undefinedValue = default; Assert.Equal(JsonValueKind.Undefined, undefinedValue.ValueKind); - Assert.Throws(() => JsonSerializer.Serialize(undefinedValue)); Assert.Throws(() => undefinedValue.ToString()); Assert.Throws(() => undefinedValue.ToObject()); Assert.Throws(() => undefinedValue.Bool); Assert.Throws(() => undefinedValue.Number); Assert.Throws(() => undefinedValue.String); + Assert.Equal("null", JsonSerializer.Serialize(undefinedValue)); Assert.Throws(() => undefinedValue.GetHashCode()); Assert.Throws(() => undefinedValue.Equals(undefinedValue)); // Assert.Throws(() => undefinedValue.GetHashCode()); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs index 1da1c976df..4da738c948 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/LayoutTestUtils.cs @@ -1,6 +1,5 @@ using System.Text.Json; using Altinn.App.Core.Configuration; -using Altinn.App.Core.Helpers; using Altinn.App.Core.Internal.App; using Altinn.App.Core.Internal.AppModel; using Altinn.App.Core.Internal.Expressions; @@ -18,8 +17,6 @@ namespace Altinn.App.Core.Tests.LayoutExpressions.FullTests; public static class LayoutTestUtils { - private static readonly JsonSerializerOptions _jsonSerializerOptions = new(JsonSerializerDefaults.Web); - private const string Org = "ttd"; private const string App = "test"; private const string AppId = $"{Org}/{App}"; @@ -44,6 +41,12 @@ public static class LayoutTestUtils DataType = "default", }; + private static JsonDocumentOptions _options = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + public static async Task GetLayoutModelTools(object model, string folder) { var services = new ServiceCollection(); @@ -80,9 +83,9 @@ public static async Task GetLayoutModelTools(object model, var layoutBytes = await File.ReadAllBytesAsync(layoutFile); string pageName = layoutFile.Replace(layoutsPath + "/", string.Empty).Replace(".json", string.Empty); - PageComponentConverter.SetAsyncLocalPageName("layout", pageName); + using var document = JsonDocument.Parse(layoutBytes, _options); - pages.Add(JsonSerializer.Deserialize(layoutBytes.RemoveBom(), _jsonSerializerOptions)!); + pages.Add(new PageComponent(document.RootElement, pageName, "layout")); } var dataType = new DataType() { Id = DataTypeId }; var layout = new LayoutSetComponent(pages, "layout", dataType); diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt index e424938c07..67d082b663 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RemoveHiddenData/RemoveHiddenDataTests.TestRemoveHiddenData.verified.txt @@ -56,7 +56,7 @@ } }, { - Field: root.arbeidserfaring[2], + Field: root.arbeidserfaring[0].prosjekter[1], DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, @@ -64,7 +64,7 @@ } }, { - Field: root.arbeidserfaring[1], + Field: root.arbeidserfaring[0].prosjekter[3], DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, @@ -72,7 +72,7 @@ } }, { - Field: root.arbeidserfaring[0].prosjekter[3], + Field: root.arbeidserfaring[1], DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, @@ -80,7 +80,7 @@ } }, { - Field: root.arbeidserfaring[0].prosjekter[1], + Field: root.arbeidserfaring[2], DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt index 94f54ab259..9925971a74 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=False_hidePage2=False.verified.txt @@ -1,6 +1,6 @@ [ { - Field: some.data[1].prodMengde, + Field: some.data[0], DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, @@ -8,7 +8,7 @@ } }, { - Field: some.data[0], + Field: some.data[1].prodMengde, DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt index 94f54ab259..9925971a74 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.Test_Hidden_Multiple_Groups_hidePage1=True_hidePage2=False.verified.txt @@ -1,6 +1,6 @@ [ { - Field: some.data[1].prodMengde, + Field: some.data[0], DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, @@ -8,7 +8,7 @@ } }, { - Field: some.data[0], + Field: some.data[1].prodMengde, DataElementIdentifier: { Guid: Guid_1, Id: Guid_1, diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.cs index 96304826f2..ad5f55acbe 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/RepeatingGroupsHidden/RepeatingGroupsHidden.cs @@ -18,8 +18,8 @@ public async Task Test_Hidden_Multiple_Groups(bool hidePage1, bool hidePage2) var jsonData = $$""" { "some": { - "hidePage1": {{hidePage1.ToString().ToLowerInvariant()}}, - "hidePage2": {{hidePage2.ToString().ToLowerInvariant()}}, + "hidePage1": {{(hidePage1 ? "true" : "false")}}, + "hidePage2": {{(hidePage2 ? "true" : "false")}}, "hideGroup1": true, "data": [ { diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs index 3dd73b235f..1db2e43cc1 100644 --- a/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/FullTests/SubForm/SubFormTests.cs @@ -267,6 +267,13 @@ private record SubFormModel( Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; + + private static readonly JsonDocumentOptions _jsonDocumentOptions = new() + { + AllowTrailingCommas = true, + CommentHandling = JsonCommentHandling.Skip, + }; + private VerifySettings _verifySettings { get @@ -351,8 +358,8 @@ public async Task Test1() private static PageComponent ParsePage(string layoutId, string pageName, [StringSyntax("json")] string json) { - PageComponentConverter.SetAsyncLocalPageName(layoutId, pageName); - return JsonSerializer.Deserialize(json) ?? throw new JsonException("Deserialization failed"); + using var document = JsonDocument.Parse(json, _jsonDocumentOptions); + return new PageComponent(document.RootElement, pageName, layoutId); } private static string Json([StringSyntax("json")] string json) => json; diff --git a/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/LayoutEvaluatorTest.cs b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/LayoutEvaluatorTest.cs new file mode 100644 index 0000000000..f68363a03a --- /dev/null +++ b/test/Altinn.App.Core.Tests/LayoutExpressions/TestUtilities/LayoutEvaluatorTest.cs @@ -0,0 +1,51 @@ +using Altinn.App.Core.Internal.Expressions; +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Layout; + +public class LayoutEvaluatorTest +{ + [Fact] + public void OrderByListIndexReverse_OrdersByDataElementIdentifierDescending_ThenFieldDescending() + { + var id0 = new DataElementIdentifier(Guid.Empty); + var id1 = new DataElementIdentifier(Guid.NewGuid()); + var expected = new List + { + new() { DataElementIdentifier = id1, Field = "field[35]" }, + new() { DataElementIdentifier = id1, Field = "field[5]" }, + new() { DataElementIdentifier = id1, Field = "field[4]" }, + new() { DataElementIdentifier = id0, Field = "melding.list[10].id" }, + new() { DataElementIdentifier = id0, Field = "melding.list[9].id" }, + new() { DataElementIdentifier = id0, Field = "melding.list[8].id" }, + new() { DataElementIdentifier = id0, Field = "field1" }, + }; + var randomized = expected.ToArray(); + Random.Shared.Shuffle(randomized); + + var ordered = LayoutEvaluator.OrderByListIndexReverse(randomized.ToList()).ToList(); + + Assert.Equal(expected, ordered); + } + + [Fact] + public void OrderByListIndexReverse_HandlesEmptyList() + { + var fields = new List(); + var ordered = LayoutEvaluator.OrderByListIndexReverse(fields).ToList(); + Assert.Empty(ordered); + } + + [Fact] + public void OrderByListIndexReverse_HandlesSingleElement() + { + var id = new DataElementIdentifier(Guid.Empty); + var fields = new List + { + new() { DataElementIdentifier = id, Field = "fieldA" }, + }; + var ordered = LayoutEvaluator.OrderByListIndexReverse(fields).ToList(); + Assert.Single(ordered); + Assert.Equal(id, ordered[0].DataElementIdentifier); + Assert.Equal("fieldA", ordered[0].Field); + } +} diff --git a/test/Altinn.App.Core.Tests/Models/DataReferenceTests.cs b/test/Altinn.App.Core.Tests/Models/DataReferenceTests.cs new file mode 100644 index 0000000000..3ef32d6c45 --- /dev/null +++ b/test/Altinn.App.Core.Tests/Models/DataReferenceTests.cs @@ -0,0 +1,51 @@ +using Altinn.App.Core.Models; +using Altinn.App.Core.Models.Layout; + +namespace Altinn.App.Core.Tests.Models; + +public class DataReferenceTests +{ + [Theory] + [InlineData("a.b.c", "a.b")] + [InlineData("a.b[3].e.c", "a.b")] + [InlineData("a.b", "a.b")] + public void TestStartsWithTrue(string full, string start) + { + DataElementIdentifier dataElementIdentifier = new(Guid.NewGuid()); + var fullReference = new DataReference { Field = full, DataElementIdentifier = dataElementIdentifier }; + var startReference = new DataReference { Field = start, DataElementIdentifier = dataElementIdentifier }; + + Assert.True(fullReference.StartsWith(startReference)); + } + + [Theory] + [InlineData("a.be.c", "a.b")] + [InlineData("a.be[].c", "a.b")] + [InlineData("a.b[].c", "a.be")] + public void TestStartsWithFalse(string full, string start) + { + DataElementIdentifier dataElementIdentifier = new(Guid.NewGuid()); + var fullReference = new DataReference { Field = full, DataElementIdentifier = dataElementIdentifier }; + var startReference = new DataReference { Field = start, DataElementIdentifier = dataElementIdentifier }; + + Assert.False(fullReference.StartsWith(startReference)); + } + + [Fact] + public void TestStartsWithDifferentElements() + { + var fullReference = new DataReference + { + Field = "a.b", + DataElementIdentifier = new DataElementIdentifier(Guid.NewGuid()), + }; + + var startReference = new DataReference + { + Field = "a.b", + DataElementIdentifier = new DataElementIdentifier(Guid.NewGuid()), + }; + + Assert.False(fullReference.StartsWith(startReference)); + } +} diff --git a/test/Altinn.App.Core.Tests/Models/PageComponentConverterTests.cs b/test/Altinn.App.Core.Tests/Models/PageComponentConverterTests.cs index 0527dce256..121b113b01 100644 --- a/test/Altinn.App.Core.Tests/Models/PageComponentConverterTests.cs +++ b/test/Altinn.App.Core.Tests/Models/PageComponentConverterTests.cs @@ -1,10 +1,8 @@ -using System.Reflection; -using System.Runtime.CompilerServices; using System.Text.Json; using Altinn.App.Core.Models.Layout.Components; +using Altinn.App.Core.Models.Layout.Components.Base; using Altinn.App.Core.Tests.TestUtils; using FluentAssertions; -using Xunit.Sdk; namespace Altinn.App.Core.Tests.Models; @@ -21,21 +19,19 @@ public class PageComponentConverterTests public void RunPageComponentConverterTest(string fileName, string folder) { var testCase = LoadData(fileName, folder); - var exception = Record.Exception(() => JsonSerializer.Deserialize(testCase.Layout)); if (testCase.Valid) { - exception.Should().BeNull(); + var page = new PageComponent(testCase.Layout, "testPage", "testLayout"); if (testCase.ExpectedHierarchy is not null) { - var page = JsonSerializer.Deserialize(testCase.Layout)!; - var hierarchy = GenerateTestHierarchy(page); hierarchy.Should().BeEquivalentTo(testCase.ExpectedHierarchy); } } else { + var exception = Record.Exception(() => new PageComponent(testCase.Layout, "testPage", "testLayout")); exception.Should().NotBeNull(); } } @@ -46,24 +42,26 @@ private PageComponentConverterTestModel LoadData(string fileName, string folder) return JsonSerializer.Deserialize(data, _jsonSerializerOptions)!; } - private HierarchyTestModel[] GenerateTestHierarchy(GroupComponent group) + private List? GenerateTestHierarchy(BaseComponent component) { var children = new List(); - foreach (var child in group.Children) - { - if (child is GroupComponent childGroup) - { - children.Add( - new HierarchyTestModel { Id = childGroup.Id, Children = GenerateTestHierarchy(childGroup) } - ); - } - else + var childComponents = + component switch { - children.Add(new HierarchyTestModel { Id = child.Id }); - } + PageComponent page => page.Components, + RepeatingReferenceComponent repeatingGroup => repeatingGroup.AllChildren, + SimpleReferenceComponent nonRepeatingGroup => nonRepeatingGroup.AllChildren, + NoReferenceComponent => [], + _ => throw new NotSupportedException( + $"Component type {component.GetType().Name} is not supported for hierarchy generation." + ), + } ?? throw new Exception("Component has no children."); + foreach (var child in childComponents) + { + children.Add(new HierarchyTestModel { Id = child.Id, Children = GenerateTestHierarchy(child) }); } - return children.ToArray(); + return children.Count == 0 ? null : children; } } @@ -73,12 +71,12 @@ public class PageComponentConverterTestModel public JsonElement Layout { get; set; } - public HierarchyTestModel[]? ExpectedHierarchy { get; set; } + public List? ExpectedHierarchy { get; set; } } public class HierarchyTestModel { public string Id { get; set; } = string.Empty; - public HierarchyTestModel[]? Children { get; set; } + public List? Children { get; set; } } diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index cce629363e..1706061ba6 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -2981,6 +2981,7 @@ namespace Altinn.App.Core.Internal.Expressions public static Altinn.App.Core.Internal.Expressions.ExpressionValue False { get; } public static Altinn.App.Core.Internal.Expressions.ExpressionValue Null { get; } public static Altinn.App.Core.Internal.Expressions.ExpressionValue True { get; } + public static Altinn.App.Core.Internal.Expressions.ExpressionValue Undefined { get; } public bool Equals(Altinn.App.Core.Internal.Expressions.ExpressionValue other) { } public override bool Equals(object? obj) { } public override int GetHashCode() { } @@ -3009,10 +3010,12 @@ namespace Altinn.App.Core.Internal.Expressions public class LayoutEvaluatorState { public LayoutEvaluatorState(Altinn.App.Core.Features.IInstanceDataAccessor dataAccessor, Altinn.App.Core.Models.Layout.LayoutModel? componentModel, Altinn.App.Core.Internal.Texts.ITranslationService translationService, Altinn.App.Core.Configuration.FrontEndSettings frontEndSettings, string? gatewayAction = null, string? language = null, System.TimeZoneInfo? timeZone = null) { } + public Altinn.Platform.Storage.Interface.Models.Instance Instance { get; } public System.Threading.Tasks.Task AddInidicies(Altinn.App.Core.Models.Layout.ModelBinding binding, Altinn.App.Core.Models.Expressions.ComponentContext context) { } public System.Threading.Tasks.Task AddInidicies(Altinn.App.Core.Models.Layout.ModelBinding binding, Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier, int[]? indexes) { } public int CountDataElements(string dataTypeId) { } - public Altinn.App.Core.Models.Layout.Components.BaseComponent GetComponent(string pageName, string componentId) { } + [System.Obsolete("You need to get a context, not a component", true)] + public void GetComponent(string pageName, string componentId) { } public System.Threading.Tasks.Task GetComponentContext(string pageName, string componentId, int[]? rowIndexes = null) { } public System.Threading.Tasks.Task> GetComponentContexts() { } public string? GetFrontendSetting(string key) { } @@ -4204,14 +4207,16 @@ namespace Altinn.App.Core.Models.Expressions [System.Diagnostics.DebuggerTypeProxy(typeof(Altinn.App.Core.Models.Expressions.ComponentContext.DebuggerProxy))] public sealed class ComponentContext { - public ComponentContext(Altinn.App.Core.Models.Layout.Components.BaseComponent? component, int[]? rowIndices, Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier, System.Collections.Generic.List? childContexts = null) { } + public ComponentContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.Layout.Components.Base.BaseComponent? component, int[]? rowIndices, Altinn.App.Core.Models.DataElementIdentifier dataElementIdentifier, System.Collections.Generic.List? childContexts = null) { } public System.Collections.Generic.List ChildContexts { get; } - public Altinn.App.Core.Models.Layout.Components.BaseComponent? Component { get; } + public Altinn.App.Core.Models.Layout.Components.Base.BaseComponent? Component { get; } public Altinn.App.Core.Models.DataElementIdentifier DataElementIdentifier { get; } public System.Collections.Generic.IEnumerable Descendants { get; } + public bool HasChildContexts { get; } public Altinn.App.Core.Models.Expressions.ComponentContext? Parent { get; } public int[]? RowIndices { get; } - public System.Threading.Tasks.Task IsHidden(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state) { } + public Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState State { get; } + public System.Threading.Tasks.Task IsHidden() { } } [System.Text.Json.Serialization.JsonConverter(typeof(Altinn.App.Core.Models.Expressions.ExpressionConverter?))] public readonly struct Expression : System.IEquatable @@ -4253,6 +4258,7 @@ namespace Altinn.App.Core.Models.Expressions public ExpressionConverter() { } public override Altinn.App.Core.Models.Expressions.Expression Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) { } public override void Write(System.Text.Json.Utf8JsonWriter writer, Altinn.App.Core.Models.Expressions.Expression value, System.Text.Json.JsonSerializerOptions options) { } + public static Altinn.App.Core.Models.Expressions.Expression ReadStatic(System.Text.Json.JsonElement element) { } public static Altinn.App.Core.Models.Expressions.Expression ReadStatic(ref System.Text.Json.Utf8JsonReader reader, System.Text.Json.JsonSerializerOptions options) { } } [System.Text.Json.Serialization.JsonConverter(typeof(System.Text.Json.Serialization.JsonStringEnumConverter))] @@ -4297,58 +4303,101 @@ namespace Altinn.App.Core.Models.Expressions text = 36, } } -namespace Altinn.App.Core.Models.Layout.Components +namespace Altinn.App.Core.Models.Layout.Components.Base { - public class BaseComponent : System.IEquatable + public abstract class BaseComponent { - public BaseComponent(string id, string type, System.Collections.Generic.IReadOnlyDictionary? dataModelBindings, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, System.Collections.Generic.IReadOnlyDictionary? additionalProperties) { } - public System.Collections.Generic.IReadOnlyDictionary? AdditionalDebugProperties { get; } + protected BaseComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + protected BaseComponent(string id, string pageId, string layoutId, string type, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression removeWhenHidden, System.Collections.Generic.IReadOnlyDictionary dataModelBindings, System.Collections.Generic.IReadOnlyDictionary textResourceBindings) { } public System.Collections.Generic.IReadOnlyDictionary DataModelBindings { get; } public Altinn.App.Core.Models.Expressions.Expression Hidden { get; } public string Id { get; } - public virtual string LayoutId { get; } - public virtual string PageId { get; } - public Altinn.App.Core.Models.Layout.Components.BaseComponent? Parent { get; } + public string LayoutId { get; } + public string PageId { get; } public Altinn.App.Core.Models.Expressions.Expression ReadOnly { get; } + public Altinn.App.Core.Models.Expressions.Expression RemoveWhenHidden { get; } public Altinn.App.Core.Models.Expressions.Expression Required { get; } + public System.Collections.Generic.IReadOnlyDictionary TextResourceBindings { get; } public string Type { get; } + public abstract void ClaimChildren(System.Collections.Generic.Dictionary unclaimedComponents, System.Collections.Generic.Dictionary claimedComponents); + protected System.Collections.Generic.List GetChildrenWithoutMultipageGroupIndex(System.Text.Json.JsonElement component, string property) { } + public abstract System.Threading.Tasks.Task GetContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.DataElementIdentifier defaultDataElementIdentifier, int[]? rowIndexes, System.Collections.Generic.Dictionary layoutsLookup); + protected static Altinn.App.Core.Models.Expressions.Expression ParseExpression(System.Text.Json.JsonElement componentElement, string property) { } + } + public abstract class NoReferenceComponent : Altinn.App.Core.Models.Layout.Components.Base.BaseComponent + { + protected NoReferenceComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public override void ClaimChildren(System.Collections.Generic.Dictionary unclaimedComponents, System.Collections.Generic.Dictionary claimedComponents) { } + public override System.Threading.Tasks.Task GetContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.DataElementIdentifier defaultDataElementIdentifier, int[]? rowIndexes, System.Collections.Generic.Dictionary layoutsLookup) { } + } + public class RepeatingGroupRowComponent : Altinn.App.Core.Models.Layout.Components.Base.BaseComponent + { + public RepeatingGroupRowComponent(string id, string pageId, string layoutId, System.Collections.Generic.IReadOnlyDictionary dataModelBindings, Altinn.App.Core.Models.Expressions.Expression hiddenRow, Altinn.App.Core.Models.Expressions.Expression removeWhenHidden) { } + public override void ClaimChildren(System.Collections.Generic.Dictionary unclaimedComponents, System.Collections.Generic.Dictionary claimedComponents) { } + public override System.Threading.Tasks.Task GetContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.DataElementIdentifier defaultDataElementIdentifier, int[]? rowIndexes, System.Collections.Generic.Dictionary layoutsLookup) { } + } + public abstract class RepeatingReferenceComponent : Altinn.App.Core.Models.Layout.Components.Base.BaseComponent + { + protected RepeatingReferenceComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public abstract System.Collections.Generic.IReadOnlyList AfterChildReferences { get; } + public abstract System.Collections.Generic.IReadOnlyList BeforeChildReferences { get; } + public abstract Altinn.App.Core.Models.Layout.ModelBinding GroupModelBinding { get; } + public abstract Altinn.App.Core.Models.Expressions.Expression HiddenRow { get; } + public abstract System.Collections.Generic.IReadOnlyList RepeatingChildReferences { get; } + public override void ClaimChildren(System.Collections.Generic.Dictionary unclaimedComponents, System.Collections.Generic.Dictionary claimedComponents) { } + public override System.Threading.Tasks.Task GetContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.DataElementIdentifier defaultDataElementIdentifier, int[]? rowIndexes, System.Collections.Generic.Dictionary layoutsLookup) { } } - public class GridComponent : Altinn.App.Core.Models.Layout.Components.GroupComponent, System.IEquatable + public abstract class SimpleReferenceComponent : Altinn.App.Core.Models.Layout.Components.Base.BaseComponent { - public GridComponent(string id, string type, System.Collections.Generic.IReadOnlyDictionary? dataModelBindings, System.Collections.Generic.IReadOnlyCollection children, System.Collections.Generic.IReadOnlyCollection? childIDs, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, System.Collections.Generic.IReadOnlyDictionary? additionalProperties) { } + protected SimpleReferenceComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public abstract System.Collections.Generic.IReadOnlyCollection ChildReferences { get; } + public System.Collections.Generic.IReadOnlyDictionary ClaimedChildrenLookup { get; } + public override void ClaimChildren(System.Collections.Generic.Dictionary unclaimedComponents, System.Collections.Generic.Dictionary claimedComponents) { } + public override System.Threading.Tasks.Task GetContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.DataElementIdentifier defaultDataElementIdentifier, int[]? rowIndexes, System.Collections.Generic.Dictionary layoutsLookup) { } } - public class GridConfig : System.IEquatable +} +namespace Altinn.App.Core.Models.Layout.Components +{ + public sealed class CardsComponent : Altinn.App.Core.Models.Layout.Components.Base.SimpleReferenceComponent { - public GridConfig() { } - [System.Text.Json.Serialization.JsonPropertyName("rows")] - public Altinn.App.Core.Models.Layout.Components.GridConfig.GridRow[]? Rows { get; set; } - public System.Collections.Generic.List Children() { } - public static System.Collections.Generic.List ReadGridChildren(ref System.Text.Json.Utf8JsonReader reader, System.Text.Json.JsonSerializerOptions options) { } - public class GridRow : System.IEquatable + public CardsComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public System.Collections.Generic.IReadOnlyCollection Cards { get; } + public override System.Collections.Generic.IReadOnlyCollection ChildReferences { get; } + } + public sealed class CardsConfig + { + public CardsConfig() { } + [System.Text.Json.Serialization.JsonPropertyName("children")] + public System.Collections.Generic.List? Children { get; init; } + } + public sealed class GridComponent : Altinn.App.Core.Models.Layout.Components.Base.SimpleReferenceComponent + { + public GridComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public override System.Collections.Generic.IReadOnlyCollection ChildReferences { get; } + public System.Collections.Generic.List Rows { get; } + public class GridCellConfig + { + public GridCellConfig() { } + [System.Text.Json.Serialization.JsonPropertyName("component")] + public string? ComponentId { get; set; } + } + public class GridRowConfig { - public GridRow() { } + public GridRowConfig() { } [System.Text.Json.Serialization.JsonPropertyName("cells")] - public Altinn.App.Core.Models.Layout.Components.GridConfig.GridRow.GridCell?[]? Cells { get; set; } - public class GridCell : System.IEquatable - { - public GridCell() { } - [System.Text.Json.Serialization.JsonPropertyName("component")] - public string? ComponentId { get; set; } - } + public required System.Collections.Generic.List Cells { get; set; } } } - public class GroupComponent : Altinn.App.Core.Models.Layout.Components.BaseComponent, System.IEquatable + public sealed class NonRepeatingGroupComponent : Altinn.App.Core.Models.Layout.Components.Base.SimpleReferenceComponent { - public GroupComponent(string id, string type, System.Collections.Generic.IReadOnlyDictionary? dataModelBindings, System.Collections.Generic.IReadOnlyCollection children, System.Collections.Generic.IReadOnlyCollection? childIDs, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, System.Collections.Generic.IReadOnlyDictionary? additionalProperties) { } - public System.Collections.Generic.IEnumerable ChildIDs { get; } - public System.Collections.Generic.IEnumerable Children { get; } - public virtual void AddChild(Altinn.App.Core.Models.Layout.Components.BaseComponent child) { } + public NonRepeatingGroupComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public override System.Collections.Generic.IReadOnlyCollection ChildReferences { get; } } - public class OptionsComponent : Altinn.App.Core.Models.Layout.Components.BaseComponent, System.IEquatable + public sealed class OptionsComponent : Altinn.App.Core.Models.Layout.Components.Base.NoReferenceComponent { - public OptionsComponent(string id, string type, System.Collections.Generic.IReadOnlyDictionary? dataModelBindings, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, string? optionId, System.Collections.Generic.List? options, Altinn.App.Core.Models.Layout.Components.OptionsSource? optionsSource, bool secure, System.Collections.Generic.IReadOnlyDictionary? additionalProperties) { } - public string? OptionId { get; } + public OptionsComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } public System.Collections.Generic.List? Options { get; } + public string? OptionsId { get; } public Altinn.App.Core.Models.Layout.Components.OptionsSource? OptionsSource { get; } public bool Secure { get; } } @@ -4358,46 +4407,43 @@ namespace Altinn.App.Core.Models.Layout.Components public string Group { get; } public string Value { get; } } - [System.Text.Json.Serialization.JsonConverter(typeof(Altinn.App.Core.Models.Layout.PageComponentConverter))] - public class PageComponent : Altinn.App.Core.Models.Layout.Components.GroupComponent, System.IEquatable + public sealed class PageComponent : Altinn.App.Core.Models.Layout.Components.Base.BaseComponent { - public PageComponent(string id, string layoutId, System.Collections.Generic.List children, System.Collections.Generic.Dictionary componentLookup, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, System.Collections.Generic.IReadOnlyDictionary? extra) { } - public System.Collections.Generic.Dictionary ComponentLookup { get; } - public override string LayoutId { get; } - public override string PageId { get; } - public override void AddChild(Altinn.App.Core.Models.Layout.Components.BaseComponent child) { } + public PageComponent(System.Text.Json.JsonElement outerElement, string pageId, string layoutId) { } + public System.Collections.Generic.IReadOnlyList Components { get; } + public override void ClaimChildren(System.Collections.Generic.Dictionary unclaimedComponents, System.Collections.Generic.Dictionary claimedComponents) { } + public override System.Threading.Tasks.Task GetContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.DataElementIdentifier defaultDataElementIdentifier, int[]? rowIndexes, System.Collections.Generic.Dictionary layoutsLookup) { } } - public class RepeatingGroupComponent : Altinn.App.Core.Models.Layout.Components.GroupComponent, System.IEquatable + public sealed class RepeatingGroupComponent : Altinn.App.Core.Models.Layout.Components.Base.RepeatingReferenceComponent { - public RepeatingGroupComponent(string id, string type, System.Collections.Generic.IReadOnlyDictionary? dataModelBindings, System.Collections.Generic.IReadOnlyCollection children, System.Collections.Generic.IReadOnlyCollection? childIDs, int maxCount, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression hiddenRow, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, System.Collections.Generic.IReadOnlyDictionary? additionalProperties) { } - public Altinn.App.Core.Models.Expressions.Expression HiddenRow { get; } + public RepeatingGroupComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId, int maxCount) { } + public override System.Collections.Generic.IReadOnlyList AfterChildReferences { get; } + public override System.Collections.Generic.IReadOnlyList BeforeChildReferences { get; } + public override Altinn.App.Core.Models.Layout.ModelBinding GroupModelBinding { get; } + public override Altinn.App.Core.Models.Expressions.Expression HiddenRow { get; } public int MaxCount { get; } + public override System.Collections.Generic.IReadOnlyList RepeatingChildReferences { get; } + public System.Collections.Generic.IReadOnlyList RowsAfter { get; } + public System.Collections.Generic.IReadOnlyList RowsBefore { get; } } - public class RepeatingGroupRowComponent : Altinn.App.Core.Models.Layout.Components.BaseComponent, System.IEquatable + public sealed class SubFormComponent : Altinn.App.Core.Models.Layout.Components.Base.BaseComponent { - public RepeatingGroupRowComponent(string id, System.Collections.Generic.IReadOnlyDictionary dataModelBindings, Altinn.App.Core.Models.Expressions.Expression hiddenRow, Altinn.App.Core.Models.Layout.Components.BaseComponent parent) { } + public SubFormComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public string LayoutSetId { get; } + public override void ClaimChildren(System.Collections.Generic.Dictionary unclaimedComponents, System.Collections.Generic.Dictionary claimedComponents) { } + public override System.Threading.Tasks.Task GetContext(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state, Altinn.App.Core.Models.DataElementIdentifier defaultDataElementIdentifier, int[]? rowIndexes, System.Collections.Generic.Dictionary layoutsLookup) { } } - public class SubFormComponent : Altinn.App.Core.Models.Layout.Components.BaseComponent, System.IEquatable + public sealed class TabsComponent : Altinn.App.Core.Models.Layout.Components.Base.SimpleReferenceComponent { - public SubFormComponent(string id, string type, System.Collections.Generic.IReadOnlyDictionary? dataModelBindings, string layoutSetId, Altinn.App.Core.Models.Expressions.Expression hidden, Altinn.App.Core.Models.Expressions.Expression required, Altinn.App.Core.Models.Expressions.Expression readOnly, System.Collections.Generic.IReadOnlyDictionary? additionalProperties) { } - public string LayoutSetId { get; } - public class CellContent : System.IEquatable - { - public CellContent(string Query, string DefaultContent) { } - public string DefaultContent { get; init; } - public string Query { get; init; } - } - public class TableColumn : System.IEquatable - { - public TableColumn(string HeaderContent, Altinn.App.Core.Models.Layout.Components.SubFormComponent.CellContent CellContent) { } - public Altinn.App.Core.Models.Layout.Components.SubFormComponent.CellContent CellContent { get; init; } - public string HeaderContent { get; init; } - } + public TabsComponent(System.Text.Json.JsonElement componentElement, string pageId, string layoutId) { } + public override System.Collections.Generic.IReadOnlyCollection ChildReferences { get; } + public System.Collections.Generic.IReadOnlyCollection Tabs { get; } } - public class SummaryComponent : Altinn.App.Core.Models.Layout.Components.BaseComponent, System.IEquatable + public sealed class TabsConfig { - public SummaryComponent(string id, string type, Altinn.App.Core.Models.Expressions.Expression hidden, string componentRef, System.Collections.Generic.IReadOnlyDictionary? additionalProperties) { } - public string ComponentRef { get; } + public TabsConfig() { } + [System.Text.Json.Serialization.JsonPropertyName("children")] + public System.Collections.Generic.List? Children { get; init; } } } namespace Altinn.App.Core.Models.Layout @@ -4406,15 +4452,15 @@ namespace Altinn.App.Core.Models.Layout { public required Altinn.App.Core.Models.DataElementIdentifier DataElementIdentifier { get; init; } public required string Field { get; init; } + public bool StartsWith(Altinn.App.Core.Models.Layout.DataReference prefix) { } } - public class LayoutModel + public sealed class LayoutModel { public LayoutModel(System.Collections.Generic.List layouts, Altinn.App.Core.Models.LayoutSet? defaultLayout) { } public Altinn.Platform.Storage.Interface.Models.DataType DefaultDataType { get; } - public System.Threading.Tasks.Task> GenerateComponentContexts(Altinn.Platform.Storage.Interface.Models.Instance instance, Altinn.App.Core.Helpers.DataModel.DataModel dataModel) { } - public Altinn.App.Core.Models.Layout.Components.BaseComponent GetComponent(string pageName, string componentId) { } + public System.Threading.Tasks.Task> GenerateComponentContexts(Altinn.App.Core.Internal.Expressions.LayoutEvaluatorState state) { } } - public class LayoutSetComponent : System.IEquatable + public sealed class LayoutSetComponent { public LayoutSetComponent(System.Collections.Generic.List pages, string id, Altinn.Platform.Storage.Interface.Models.DataType defaultDataType) { } public Altinn.Platform.Storage.Interface.Models.DataType DefaultDataType { get; } @@ -4430,14 +4476,6 @@ namespace Altinn.App.Core.Models.Layout [System.Text.Json.Serialization.JsonPropertyName("field")] public required string Field { get; init; } } - public class PageComponentConverter : System.Text.Json.Serialization.JsonConverter - { - public PageComponentConverter() { } - public override Altinn.App.Core.Models.Layout.Components.PageComponent Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) { } - public override void Write(System.Text.Json.Utf8JsonWriter writer, Altinn.App.Core.Models.Layout.Components.PageComponent value, System.Text.Json.JsonSerializerOptions options) { } - public static Altinn.App.Core.Models.Layout.Components.PageComponent ReadNotNull(ref System.Text.Json.Utf8JsonReader reader, string pageName, string layoutId, System.Text.Json.JsonSerializerOptions options) { } - public static void SetAsyncLocalPageName(string layoutId, string pageName) { } - } } namespace Altinn.App.Core.Models.Notifications.Email {