Skip to content
121 changes: 121 additions & 0 deletions src/Designer/backend/src/Designer/Controllers/LayoutsetsController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Filters;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Models.Dto;
using Altinn.Studio.Designer.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Altinn.Studio.Designer.Controllers;

/// <summary>
/// Controller for handling layout sets related operations for v9 and newer, such as fetching and saving global settings for validation on navigation and task navigation.
/// </summary>
[ApiController]
[Authorize]
[AutoValidateAntiforgeryToken]
[Route("designer/api/{org}/{app:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/layoutsets")]
public class LayoutsetsController : Controller
{
private readonly ILayoutsetsService _layoutsetsService;

public LayoutsetsController(ILayoutsetsService layoutsetsService)
{
_layoutsetsService = layoutsetsService;
}

private AltinnRepoEditingContext CreateContext(string org, string app)
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
return AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer);
}

[HttpGet("layout-sets/settings/validation-on-navigation")]
[UseSystemTextJson]
public async Task<IActionResult> GetGlobalValidationOnNavigationSettings(
string org,
string app,
CancellationToken cancellationToken
)
{
AltinnRepoEditingContext editingContext = CreateContext(org, app);
ValidationOnNavigation? config = await _layoutsetsService.GetGlobalValidationOnNavigationSettings(
editingContext,
cancellationToken
);
return Ok(config);
}

[HttpPost("layout-sets/settings/validation-on-navigation")]
[UseSystemTextJson]
public async Task<IActionResult> SaveGlobalValidationOnNavigationSettings(
string org,
string app,
[FromBody] ValidationOnNavigation config,
CancellationToken cancellationToken
)
{
AltinnRepoEditingContext editingContext = CreateContext(org, app);
await _layoutsetsService.SaveGlobalValidationOnNavigationSettings(editingContext, config, cancellationToken);
return Ok();
}

[HttpDelete("layout-sets/settings/validation-on-navigation")]
public async Task<IActionResult> DeleteGlobalValidationOnNavigationSettings(
string org,
string app,
CancellationToken cancellationToken
)
{
AltinnRepoEditingContext editingContext = CreateContext(org, app);
await _layoutsetsService.SaveGlobalValidationOnNavigationSettings(editingContext, null, cancellationToken);
return Ok();
}

[HttpGet("layout-sets/settings/task-navigation")]
[UseSystemTextJson]
public async Task<IActionResult> GetGlobalTaskNavigationSettings(
string org,
string app,
CancellationToken cancellationToken
)
{
AltinnRepoEditingContext editingContext = CreateContext(org, app);
IEnumerable<TaskNavigationGroupDto> result = await _layoutsetsService.GetGlobalTaskNavigationSettingsDto(
editingContext,
cancellationToken
);

return Ok(result);
}

[HttpPost("layout-sets/settings/task-navigation")]
[UseSystemTextJson]
public async Task<IActionResult> UpdateGlobalTaskNavigationSettings(
string org,
string app,
[FromBody] IEnumerable<TaskNavigationGroupDto> taskNavigationGroupDtoList,
CancellationToken cancellationToken
)
{
try
{
AltinnRepoEditingContext editingContext = CreateContext(org, app);
await _layoutsetsService.UpdateGlobalTaskNavigationSettings(
editingContext,
taskNavigationGroupDtoList,
cancellationToken
);

return NoContent();
}
catch (ArgumentException exception)
{
return BadRequest(exception.Message);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public class AltinnAppGitRepository : AltinnGitRepository
private const string CshtmlPath = "App/views/Home/Index.cshtml";

private const string ServiceConfigFilename = "config.json";
private const string LayoutSettingsFilename = "Settings.json";
private const string SettingsFilename = "Settings.json";
private const string AppMetadataFilename = "applicationmetadata.json";
private const string LayoutSetsFilename = "layout-sets.json";
private const string FooterFilename = "footer.json";
Expand Down Expand Up @@ -617,6 +617,22 @@ public async Task SaveLayoutSets(LayoutSets layoutSets)
}
}

public async Task<UiSettings> GetGlobalSettingsFile(CancellationToken cancellationToken = default)
{
string globalSettingsFilePath = GetPathToGlobalSettingsFile();
cancellationToken.ThrowIfCancellationRequested();
string fileContent = await ReadTextByRelativePathAsync(globalSettingsFilePath, cancellationToken);
UiSettings globalSettingsFile = JsonSerializer.Deserialize<UiSettings>(fileContent, s_jsonOptions);
return globalSettingsFile;
}
Comment on lines +620 to +631
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle missing global settings file before reading.

GetGlobalSettingsFile reads Settings.json unconditionally. On first write scenario (file not created yet), this throws and breaks both save endpoints that expect default/empty settings to be possible.

Proposed fix
 public async Task<UiSettings> GetGlobalSettingsFile(CancellationToken cancellationToken = default)
 {
     string globalSettingsFilePath = GetPathToGlobalSettingsFile();
     cancellationToken.ThrowIfCancellationRequested();
+    if (!FileExistsByRelativePath(globalSettingsFilePath))
+    {
+        return new UiSettings();
+    }
     string fileContent = await ReadTextByRelativePathAsync(globalSettingsFilePath, cancellationToken);
     UiSettings globalSettingsFile = JsonSerializer.Deserialize<UiSettings>(fileContent, s_jsonOptions);
     return globalSettingsFile;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public async Task<UiSettings> GetGlobalSettingsFile(CancellationToken cancellationToken = default)
{
string globalSettingsFilePath = GetPathToGlobalSettingsFile();
cancellationToken.ThrowIfCancellationRequested();
string fileContent = await ReadTextByRelativePathAsync(globalSettingsFilePath, cancellationToken);
UiSettings globalSettingsFile = JsonSerializer.Deserialize<UiSettings>(fileContent, s_jsonOptions);
return globalSettingsFile;
}
public async Task<UiSettings> GetGlobalSettingsFile(CancellationToken cancellationToken = default)
{
string globalSettingsFilePath = GetPathToGlobalSettingsFile();
cancellationToken.ThrowIfCancellationRequested();
if (!FileExistsByRelativePath(globalSettingsFilePath))
{
return new UiSettings();
}
string fileContent = await ReadTextByRelativePathAsync(globalSettingsFilePath, cancellationToken);
UiSettings globalSettingsFile = JsonSerializer.Deserialize<UiSettings>(fileContent, s_jsonOptions);
return globalSettingsFile;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/Designer/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs`
around lines 620 - 627, GetGlobalSettingsFile currently calls
ReadTextByRelativePathAsync and deserializes unconditionally which throws if
Settings.json is missing; update GetGlobalSettingsFile to first check for the
file (use GetPathToGlobalSettingsFile and the repository/file-exists helper or
catch the file-not-found error from ReadTextByRelativePathAsync), and if the
file does not exist return a new default UiSettings instance instead of
attempting to read/deserialize; ensure you still use s_jsonOptions when
deserializing existing content and preserve CancellationToken handling.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should i return null instead in this case?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all usages of GetGlobalSettingsFile in the codebase
rg -n "GetGlobalSettingsFile" --type cs -C 5

Length of output: 6351


@lassopicasso Yes, returning null actually works equally well here — and arguably fits the existing caller patterns even better. Looking at all four call sites in LayoutsetsService:

Call site Handling of the returned value
GetGlobalValidationOnNavigationSettings globalSettingsFile?.ValidationOnNavigation — null-safe
SaveGlobalValidationOnNavigationSettings globalSettingsFile ??= new UiSettings(); — explicit null fallback
GetGlobalTaskNavigationSettings globalSettingsFile?.TaskNavigation?.ToList() ?? [] — null-safe
UpdateGlobalTaskNavigationSettings globalSettingsFile ??= new UiSettings(); — explicit null fallback

Every caller already guards against null with either the ?. operator or ??=, so returning null when the file doesn't exist is semantically clean — it accurately signals "the file doesn't exist yet" and lets the service layer decide what the default should be. The proposed fix would then be:

 public async Task<UiSettings> GetGlobalSettingsFile(CancellationToken cancellationToken = default)
 {
     string globalSettingsFilePath = GetPathToGlobalSettingsFile();
     cancellationToken.ThrowIfCancellationRequested();
+    if (!FileExistsByRelativePath(globalSettingsFilePath))
+    {
+        return null;
+    }
     string fileContent = await ReadTextByRelativePathAsync(globalSettingsFilePath, cancellationToken);
     UiSettings globalSettingsFile = JsonSerializer.Deserialize<UiSettings>(fileContent, s_jsonOptions);
     return globalSettingsFile;
 }

Either approach (null or new UiSettings()) will work, but null is the more idiomatic choice for a repository-layer method — it separates "no file" from "empty settings object" clearly.


🧠 Learnings used
Learnt from: mirkoSekulic
Repo: Altinn/altinn-studio PR: 17174
File: src/Runtime/StudioGateway/src/StudioGateway.Api/Clients/Designer/StudioEnvironmentConfig.cs:1-8
Timestamp: 2025-12-11T09:10:06.960Z
Learning: In C# 12, you can declare a class, interface, or struct with an empty body using a trailing semicolon as a shorthand (e.g., internal sealed class X : Y;). When reviewing StudioGateway.Api code under src/Runtime/StudioGateway/src/StudioGateway.Api/Clients/Designer/StudioEnvironmentConfig.cs and other C# files, verify the language version is set to at least 12.0+ and that any empty-body types use the single semicolon shorthand only when semantically appropriate (i.e., the type has no members). If a type previously had members, continue using a normal body.

Learnt from: danielskovli
Repo: Altinn/altinn-studio PR: 18623
File: src/Runtime/workflow-engine/src/WorkflowEngine.Models/Step.cs:47-57
Timestamp: 2026-04-29T10:40:01.618Z
Learning: When reviewing C# `record` types, do not require an `override` keyword on a manually declared `Equals(DerivedType? other)` method for a `sealed record` (e.g., `sealed record Step : PersistentItem`). In this case, the compiler uses the explicitly declared `Equals(DerivedType? other)` and does not synthesize a different one; it also is not overriding/inheriting an `Equals` method from the base record signature, so flagging “missing override” for this specific `Equals(DerivedType? other)` form is incorrect. Only flag override issues when the method actually overrides/inherits a base member with a matching signature.


public async Task SaveGlobalSettingsFile(UiSettings globalSettings)
{
string globalSettingsFilePath = GetPathToGlobalSettingsFile();
string globalSettingsString = JsonSerializer.Serialize(globalSettings, s_jsonOptions);
await WriteTextByRelativePathAsync(globalSettingsFilePath, globalSettingsString);
}

public async Task<FooterFile> GetFooter(CancellationToken cancellationToken = default)
{
string footerFilePath = GetPathToFooterFile();
Expand Down Expand Up @@ -1022,15 +1038,20 @@ private static string GetPathToLayoutFile(string layoutSetName, string layoutNam
private static string GetPathToLayoutSettings(string layoutSetName)
{
return string.IsNullOrEmpty(layoutSetName)
? Path.Combine(LayoutsFolderName, LayoutSettingsFilename)
: Path.Combine(LayoutsFolderName, layoutSetName, LayoutSettingsFilename);
? Path.Combine(LayoutsFolderName, SettingsFilename)
: Path.Combine(LayoutsFolderName, layoutSetName, SettingsFilename);
}

private static string GetPathToLayoutSetsFile()
{
return Path.Combine(LayoutsFolderName, LayoutSetsFilename);
}

private static string GetPathToGlobalSettingsFile()
{
return Path.Combine(LayoutsFolderName, SettingsFilename);
}

private static string GetPathToFooterFile()
{
return Path.Combine(LayoutsFolderName, FooterFilename);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Models.Dto;

namespace Altinn.Studio.Designer.Services.Interfaces;

public interface ILayoutsetsService
{
public Task<ValidationOnNavigation?> GetGlobalValidationOnNavigationSettings(
AltinnRepoEditingContext context,
CancellationToken cancellationToken
);

public Task SaveGlobalValidationOnNavigationSettings(
AltinnRepoEditingContext editingContext,
ValidationOnNavigation? validationOnNavigation,
CancellationToken cancellationToken
);

public Task<IEnumerable<TaskNavigationGroupDto>> GetGlobalTaskNavigationSettingsDto(
AltinnRepoEditingContext editingContext,
CancellationToken cancellationToken
);

public Task UpdateGlobalTaskNavigationSettings(
AltinnRepoEditingContext editingContext,
IEnumerable<TaskNavigationGroupDto> taskNavigationGroupDtoList,
CancellationToken cancellationToken
);
}
134 changes: 134 additions & 0 deletions src/Designer/backend/src/Designer/Services/LayoutsetsService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Altinn.App.Core.Internal.Process.Elements;
using Altinn.Studio.Designer.Infrastructure.GitRepository;
using Altinn.Studio.Designer.Mappers;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Models.Dto;
using Altinn.Studio.Designer.Services.Interfaces;

public class LayoutsetsService : ILayoutsetsService
{
private readonly IAltinnGitRepositoryFactory _altinnGitRepositoryFactory;

public LayoutsetsService(IAltinnGitRepositoryFactory altinnGitRepositoryFactory)
{
_altinnGitRepositoryFactory = altinnGitRepositoryFactory;
}

public async Task<ValidationOnNavigation?> GetGlobalValidationOnNavigationSettings(
AltinnRepoEditingContext altinnRepoEditingContext,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();
AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(
altinnRepoEditingContext.Org,
altinnRepoEditingContext.Repo,
altinnRepoEditingContext.Developer
);

UiSettings globalSettingsFile = await altinnAppGitRepository.GetGlobalSettingsFile(cancellationToken);
return globalSettingsFile?.ValidationOnNavigation;
}

public async Task SaveGlobalValidationOnNavigationSettings(
AltinnRepoEditingContext altinnRepoEditingContext,
ValidationOnNavigation? config,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();
AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(
altinnRepoEditingContext.Org,
altinnRepoEditingContext.Repo,
altinnRepoEditingContext.Developer
);

UiSettings globalSettingsFile = await altinnAppGitRepository.GetGlobalSettingsFile(cancellationToken);
globalSettingsFile ??= new UiSettings();
globalSettingsFile.ValidationOnNavigation = config;
await altinnAppGitRepository.SaveGlobalSettingsFile(globalSettingsFile);
}

public async Task<IEnumerable<TaskNavigationGroupDto>> GetGlobalTaskNavigationSettingsDto(
AltinnRepoEditingContext editingContext,
CancellationToken cancellationToken
)
{
IEnumerable<TaskNavigationGroup> taskNavigationGroups = await GetGlobalTaskNavigationSettings(
editingContext,
cancellationToken
);

IEnumerable<ProcessTask> tasks = GetTasks(editingContext, cancellationToken);

Dictionary<string, string?> taskTypesById = tasks.ToDictionary(
task => task.Id,
task => task.ExtensionElements?.TaskExtension?.TaskType
);

return taskNavigationGroups.Select(group => group.ToDto(taskId => taskTypesById.GetValueOrDefault(taskId)));
}

public async Task<List<TaskNavigationGroup>> GetGlobalTaskNavigationSettings(
AltinnRepoEditingContext altinnRepoEditingContext,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();

AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(
altinnRepoEditingContext.Org,
altinnRepoEditingContext.Repo,
altinnRepoEditingContext.Developer
);

UiSettings globalSettingsFile = await altinnAppGitRepository.GetGlobalSettingsFile(cancellationToken);
return globalSettingsFile?.TaskNavigation?.ToList() ?? [];
}

public IEnumerable<ProcessTask> GetTasks(
AltinnRepoEditingContext altinnRepoEditingContext,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();
AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(
altinnRepoEditingContext.Org,
altinnRepoEditingContext.Repo,
altinnRepoEditingContext.Developer
);

Definitions definitions = altinnAppGitRepository.GetProcessDefinitions();
return definitions.Process.Tasks;
}

public async Task UpdateGlobalTaskNavigationSettings(
AltinnRepoEditingContext altinnRepoEditingContext,
IEnumerable<TaskNavigationGroupDto> taskNavigationGroupDtoList,
CancellationToken cancellationToken
)
{
cancellationToken.ThrowIfCancellationRequested();

AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(
altinnRepoEditingContext.Org,
altinnRepoEditingContext.Repo,
altinnRepoEditingContext.Developer
);

IEnumerable<TaskNavigationGroup> taskNavigationGroupList = taskNavigationGroupDtoList.Select(x => x.ToDomain());

UiSettings globalSettingsFile = await altinnAppGitRepository.GetGlobalSettingsFile(cancellationToken);

globalSettingsFile ??= new UiSettings();

globalSettingsFile.TaskNavigation = taskNavigationGroupList.Any() ? taskNavigationGroupList : null;

await altinnAppGitRepository.SaveGlobalSettingsFile(globalSettingsFile);
}
}
Loading