From 88a1fea39a7787dd08a80b003edb1ee385523dbd Mon Sep 17 00:00:00 2001 From: andreastanderen <71079896+standeren@users.noreply.github.com> Date: Wed, 20 Mar 2024 12:34:30 +0100 Subject: [PATCH 1/3] Refactor --- .../Controllers/AppDevelopmentController.cs | 120 ++------- .../EmptyLayoutSetIdException.cs | 9 +- .../NoLayoutSetsFileFoundException.cs | 26 ++ .../NonUniqueLayoutSetIdException.cs | 26 ++ .../Exceptions/InvalidLayoutSetIdException.cs | 33 +++ .../AppDevelopmentExceptionFilterAttribute.cs | 2 +- .../GitRepository/AltinnAppGitRepository.cs | 229 +++++++++--------- .../Implementation/AppDevelopmentService.cs | 23 +- .../Interfaces/IAppDevelopmentService.cs | 4 +- .../Services/AppDevelopmentServiceTest.cs | 2 +- frontend/language/src/nb.json | 2 +- 11 files changed, 244 insertions(+), 232 deletions(-) rename backend/src/Designer/Exceptions/{ => AppDevelopment}/EmptyLayoutSetIdException.cs (70%) create mode 100644 backend/src/Designer/Exceptions/AppDevelopment/NoLayoutSetsFileFoundException.cs create mode 100644 backend/src/Designer/Exceptions/AppDevelopment/NonUniqueLayoutSetIdException.cs create mode 100644 backend/src/Designer/Exceptions/InvalidLayoutSetIdException.cs diff --git a/backend/src/Designer/Controllers/AppDevelopmentController.cs b/backend/src/Designer/Controllers/AppDevelopmentController.cs index 896da70021d..680b7de8350 100644 --- a/backend/src/Designer/Controllers/AppDevelopmentController.cs +++ b/backend/src/Designer/Controllers/AppDevelopmentController.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Altinn.Studio.DataModeling.Metamodel; @@ -32,7 +31,6 @@ public class AppDevelopmentController : Controller private readonly IRepository _repository; private readonly ISourceControl _sourceControl; private readonly IAltinnGitRepositoryFactory _altinnGitRepositoryFactory; - private readonly string _layoutSetNameRegEx = "[a-zA-Z0-9-]{3,28}"; private readonly ApplicationInsightsSettings _applicationInsightsSettings; @@ -70,7 +68,7 @@ public IActionResult Index(string org, string app) /// /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. - /// Name of the layoutset to get layouts for + /// Name of the layout set to get layouts for /// A that observes if operation is cancelled. /// The model representation as JSON [HttpGet] @@ -80,11 +78,6 @@ public async Task GetFormLayouts(string org, string app, [FromQue { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); Dictionary formLayouts = await _appDevelopmentService.GetFormLayouts(editingContext, layoutSetName, cancellationToken); @@ -117,11 +110,6 @@ public async Task SaveFormLayout(string org, string app, [FromQuer { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); await _appDevelopmentService.SaveFormLayout(editingContext, layoutSetName, layoutName, formLayout, cancellationToken); @@ -138,7 +126,7 @@ public async Task SaveFormLayout(string org, string app, [FromQuer /// /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. - /// The name of the layoutset the specific layout belongs to + /// The name of the layout set the specific layout belongs to /// The form layout to be deleted /// A success message if the save was successful [HttpDelete] @@ -147,11 +135,6 @@ public ActionResult DeleteFormLayout(string org, string app, [FromQuery] string { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); _appDevelopmentService.DeleteFormLayout(editingContext, layoutSetName, layoutName); @@ -169,7 +152,7 @@ public ActionResult DeleteFormLayout(string org, string app, [FromQuery] string /// The new name of the form layout. /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. - /// Name of the layoutset the specific layout belongs to + /// Name of the layout set the specific layout belongs to /// The current name of the form layout /// A success message if the save was successful [HttpPost] @@ -178,11 +161,6 @@ public ActionResult UpdateFormLayoutName(string org, string app, [FromQuery] str { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); _appDevelopmentService.UpdateFormLayoutName(editingContext, layoutSetName, layoutName, newName); @@ -195,9 +173,9 @@ public ActionResult UpdateFormLayoutName(string org, string app, [FromQuery] str } /// - /// Saves the layout settings for an app without layoutsets + /// Saves the layout settings for an app without layout sets /// - /// Name of the layoutset the layoutsettings belong to + /// Name of the layout set the layout settings belong to /// The data to be saved /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. @@ -210,11 +188,6 @@ public async Task SaveLayoutSettings(string org, string app, [From { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); await _appDevelopmentService.SaveLayoutSettings(editingContext, layoutSettings, layoutSetName, cancellationToken); @@ -231,7 +204,7 @@ public async Task SaveLayoutSettings(string org, string app, [From /// /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. - /// Name of the layoutset the specific layoutsettings belong to + /// Name of the layout set the specific layout settings belong to /// An that observes if operation is cancelled. /// The content of the settings file [HttpGet] @@ -241,11 +214,6 @@ public async Task GetLayoutSettings(string org, string app, [From { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); var layoutSettings = await _appDevelopmentService.GetLayoutSettings(editingContext, layoutSetName, cancellationToken); @@ -298,7 +266,7 @@ public async Task GetModelMetadata(string org, string app, [FromQ } /// - /// Get all layoutsets in the layout-set.json file + /// Get all layout sets in the layout-set.json file /// /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. @@ -309,21 +277,14 @@ public async Task GetModelMetadata(string org, string app, [FromQ [Route("layout-sets")] public async Task GetLayoutSets(string org, string app, CancellationToken cancellationToken) { - try - { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); - LayoutSets layoutSets = await _appDevelopmentService.GetLayoutSets(editingContext, cancellationToken); - if (layoutSets is null) - { - return Ok(); - } - return Ok(layoutSets); - } - catch (FileNotFoundException) + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); + LayoutSets layoutSets = await _appDevelopmentService.GetLayoutSets(editingContext, cancellationToken); + if (layoutSets is null) { - return NotFound("Layout-sets.json not found"); + return Ok(); } + return Ok(layoutSets); } /// @@ -338,17 +299,10 @@ public async Task GetLayoutSets(string org, string app, Cancellat [Route("layout-set/{layoutSetIdToUpdate}")] public async Task AddLayoutSet(string org, string app, [FromBody] LayoutSetConfig layoutSet, CancellationToken cancellationToken) { - try - { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); - LayoutSets layoutSets = await _appDevelopmentService.AddLayoutSet(editingContext, layoutSet, cancellationToken); - return Ok(layoutSets); - } - catch (FileNotFoundException exception) - { - return NotFound($"Layout-sets.json not found: {exception}"); - } + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); + LayoutSets layoutSets = await _appDevelopmentService.AddLayoutSet(editingContext, layoutSet, cancellationToken); + return Ok(layoutSets); } /// @@ -356,7 +310,7 @@ public async Task AddLayoutSet(string org, string app, [FromBody] /// /// Unique identifier of the organisation responsible for the app. /// Application identifier which is unique within an organisation. - /// The id of a new set or the set to replace + /// The id of a layout set to replace /// The config needed for the layout set to be added to layout-sets.json /// An that observes if operation is cancelled. [HttpPut] @@ -364,17 +318,10 @@ public async Task AddLayoutSet(string org, string app, [FromBody] [Route("layout-set/{layoutSetIdToUpdate}")] public async Task UpdateLayoutSet(string org, string app, [FromRoute] string layoutSetIdToUpdate, [FromBody] LayoutSetConfig layoutSet, CancellationToken cancellationToken) { - try - { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); - LayoutSets layoutSets = await _appDevelopmentService.UpdateLayoutSet(editingContext, layoutSetIdToUpdate, layoutSet, cancellationToken); - return Ok(layoutSets); - } - catch (FileNotFoundException exception) - { - return NotFound($"Layout-sets.json not found: {exception}"); - } + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); + LayoutSets layoutSets = await _appDevelopmentService.UpdateLayoutSet(editingContext, layoutSetIdToUpdate, layoutSet, cancellationToken); + return Ok(layoutSets); } /// @@ -391,11 +338,6 @@ public async Task GetRuleHandler(string org, string app, [FromQue { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); string ruleHandler = await _appDevelopmentService.GetRuleHandler(editingContext, layoutSetName, cancellationToken); @@ -423,18 +365,12 @@ public async Task GetRuleHandler(string org, string app, [FromQue [Route("rule-handler")] public async Task SaveRuleHandler(string org, string app, [FromQuery] string layoutSetName, CancellationToken cancellationToken) { - string content = string.Empty; try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); using (StreamReader reader = new(Request.Body)) { - content = await reader.ReadToEndAsync(); + var content = await reader.ReadToEndAsync(cancellationToken); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); await _appDevelopmentService.SaveRuleHandler(editingContext, content, layoutSetName, cancellationToken); } @@ -463,11 +399,6 @@ public async Task SaveRuleConfig(string org, string app, [FromBod { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); await _appDevelopmentService.SaveRuleConfig(editingContext, ruleConfig, layoutSetName, cancellationToken); @@ -493,11 +424,6 @@ public async Task GetRuleConfig(string org, string app, [FromQuer { try { - bool isValidLayoutSetName = string.IsNullOrEmpty(layoutSetName) || Regex.IsMatch(layoutSetName, _layoutSetNameRegEx); - if (!isValidLayoutSetName) - { - return BadRequest("LayoutSetName is not valid"); - } string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); string ruleConfig = await _appDevelopmentService.GetRuleConfigAndAddDataToRootIfNotAlreadyPresent(editingContext, layoutSetName, cancellationToken); diff --git a/backend/src/Designer/Exceptions/EmptyLayoutSetIdException.cs b/backend/src/Designer/Exceptions/AppDevelopment/EmptyLayoutSetIdException.cs similarity index 70% rename from backend/src/Designer/Exceptions/EmptyLayoutSetIdException.cs rename to backend/src/Designer/Exceptions/AppDevelopment/EmptyLayoutSetIdException.cs index f10036f705f..628448aaee2 100644 --- a/backend/src/Designer/Exceptions/EmptyLayoutSetIdException.cs +++ b/backend/src/Designer/Exceptions/AppDevelopment/EmptyLayoutSetIdException.cs @@ -1,7 +1,6 @@ using System; -using System.Runtime.Serialization; -namespace Altinn.Studio.Designer.Exceptions +namespace Altinn.Studio.Designer.Exceptions.AppDevelopment { /// /// Indicates that an error occurred during C# code generation. @@ -23,11 +22,5 @@ public EmptyLayoutSetIdException(string message) : base(message) public EmptyLayoutSetIdException(string message, Exception innerException) : base(message, innerException) { } - - /// - protected EmptyLayoutSetIdException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } - } } diff --git a/backend/src/Designer/Exceptions/AppDevelopment/NoLayoutSetsFileFoundException.cs b/backend/src/Designer/Exceptions/AppDevelopment/NoLayoutSetsFileFoundException.cs new file mode 100644 index 00000000000..64c06a69c99 --- /dev/null +++ b/backend/src/Designer/Exceptions/AppDevelopment/NoLayoutSetsFileFoundException.cs @@ -0,0 +1,26 @@ +using System; + +namespace Altinn.Studio.Designer.Exceptions.AppDevelopment +{ + /// + /// Indicates that an error occurred during C# code generation. + /// + [Serializable] + public class NoLayoutSetsFileFoundException : Exception + { + /// + public NoLayoutSetsFileFoundException() + { + } + + /// + public NoLayoutSetsFileFoundException(string message) : base(message) + { + } + + /// + public NoLayoutSetsFileFoundException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/backend/src/Designer/Exceptions/AppDevelopment/NonUniqueLayoutSetIdException.cs b/backend/src/Designer/Exceptions/AppDevelopment/NonUniqueLayoutSetIdException.cs new file mode 100644 index 00000000000..5a01656ba4d --- /dev/null +++ b/backend/src/Designer/Exceptions/AppDevelopment/NonUniqueLayoutSetIdException.cs @@ -0,0 +1,26 @@ +using System; + +namespace Altinn.Studio.Designer.Exceptions.AppDevelopment +{ + /// + /// Indicates that an error occurred during C# code generation. + /// + [Serializable] + public class NonUniqueLayoutSetIdException : Exception + { + /// + public NonUniqueLayoutSetIdException() + { + } + + /// + public NonUniqueLayoutSetIdException(string message) : base(message) + { + } + + /// + public NonUniqueLayoutSetIdException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/backend/src/Designer/Exceptions/InvalidLayoutSetIdException.cs b/backend/src/Designer/Exceptions/InvalidLayoutSetIdException.cs new file mode 100644 index 00000000000..e2236cb37f5 --- /dev/null +++ b/backend/src/Designer/Exceptions/InvalidLayoutSetIdException.cs @@ -0,0 +1,33 @@ +using System; +using System.Runtime.Serialization; + +namespace Altinn.Studio.Designer.Exceptions +{ + /// + /// Indicates that an error occurred during C# code generation. + /// + [Serializable] + public class InvalidLayoutSetIdException : Exception + { + /// + public InvalidLayoutSetIdException() + { + } + + /// + public InvalidLayoutSetIdException(string message) : base(message) + { + } + + /// + public InvalidLayoutSetIdException(string message, Exception innerException) : base(message, innerException) + { + } + + /// + protected InvalidLayoutSetIdException(SerializationInfo info, StreamingContext context) : base(info, context) + { + } + + } +} diff --git a/backend/src/Designer/Filters/AppDevelopment/AppDevelopmentExceptionFilterAttribute.cs b/backend/src/Designer/Filters/AppDevelopment/AppDevelopmentExceptionFilterAttribute.cs index 589c342a677..4a266df89d9 100644 --- a/backend/src/Designer/Filters/AppDevelopment/AppDevelopmentExceptionFilterAttribute.cs +++ b/backend/src/Designer/Filters/AppDevelopment/AppDevelopmentExceptionFilterAttribute.cs @@ -21,7 +21,7 @@ public override void OnException(ExceptionContext context) { context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, AppDevelopmentErrorCodes.NonUniqueLayoutSetIdError, HttpStatusCode.BadRequest)) { StatusCode = (int)HttpStatusCode.BadRequest }; } - if (context.Exception is EmptyLayoutSetIdException) + if (context.Exception is InvalidLayoutSetIdException) { context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, AppDevelopmentErrorCodes.EmptyLayoutSetIdError, HttpStatusCode.BadRequest)) { StatusCode = (int)HttpStatusCode.BadRequest }; } diff --git a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs index e1bcc0af8f4..fa7ed3e6784 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs @@ -10,15 +10,16 @@ using System.Threading.Tasks; using Altinn.Studio.DataModeling.Metamodel; using Altinn.Studio.Designer.Configuration; +using Altinn.Studio.Designer.Exceptions.AppDevelopment; using Altinn.Studio.Designer.Helpers; using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Models.App; using Altinn.Studio.Designer.TypedHttpClients.Exceptions; using LibGit2Sharp; -using Microsoft.AspNetCore.Http; using Microsoft.IdentityModel.Tokens; using JsonSerializer = System.Text.Json.JsonSerializer; using LayoutSets = Altinn.Studio.Designer.Models.LayoutSets; +using NonUniqueLayoutSetIdException = Altinn.Studio.Designer.Exceptions.NonUniqueLayoutSetIdException; namespace Altinn.Studio.Designer.Infrastructure.GitRepository { @@ -30,40 +31,40 @@ namespace Altinn.Studio.Designer.Infrastructure.GitRepository /// It should however, not have any business logic. public class AltinnAppGitRepository : AltinnGitRepository { - private const string MODEL_FOLDER_PATH = "App/models/"; - private const string CONFIG_FOLDER_PATH = "App/config/"; - private const string OPTIONS_FOLDER_PATH = "App/options/"; - private const string LAYOUTS_FOLDER_NAME = "App/ui/"; - private const string IMAGES_FOLDER_NAME = "App/wwwroot/"; - private const string LAYOUTS_IN_SET_FOLDER_NAME = "layouts/"; - private const string LANGUAGE_RESOURCE_FOLDER_NAME = "texts/"; - private const string MARKDOWN_TEXTS_FOLDER_NAME = "md/"; - private const string PROCESS_DEFINITION_FOLDER_PATH = "App/config/process/"; - private const string CSHTML_PATH = "App/views/Home/Index.cshtml"; - - private const string SERVICE_CONFIG_FILENAME = "config.json"; - private const string LAYOUT_SETTINGS_FILENAME = "Settings.json"; - private const string APP_METADATA_FILENAME = "applicationmetadata.json"; - private const string LAYOUT_SETS_FILENAME = "layout-sets.json"; - private const string RULE_HANDLER_FILENAME = "RuleHandler.js"; - private const string RULE_CONFIGURATION_FILENAME = "RuleConfiguration.json"; - private const string PROCESS_DEFINITION_FILENAME = "process.bpmn"; - - private static string ProcessDefinitionFilePath => Path.Combine(PROCESS_DEFINITION_FOLDER_PATH, PROCESS_DEFINITION_FILENAME); + private const string ModelFolderPath = "App/models/"; + private const string ConfigFolderPath = "App/config/"; + private const string OptionsFolderPath = "App/options/"; + private const string LayoutsFolderName = "App/ui/"; + private const string ImagesFolderName = "App/wwwroot/"; + private const string LayoutsInSetFolderName = "layouts/"; + private const string LanguageResourceFolderName = "texts/"; + private const string MarkdownTextsFolderName = "md/"; + private const string ProcessDefinitionFolderPath = "App/config/process/"; + private const string CshtmlPath = "App/views/Home/Index.cshtml"; + + private const string ServiceConfigFilename = "config.json"; + private const string LayoutSettingsFilename = "Settings.json"; + private const string AppMetadataFilename = "applicationmetadata.json"; + private const string LayoutSetsFilename = "layout-sets.json"; + private const string RuleHandlerFilename = "RuleHandler.js"; + private const string RuleConfigurationFilename = "RuleConfiguration.json"; + private const string ProcessDefinitionFilename = "process.bpmn"; + + private static string ProcessDefinitionFilePath => Path.Combine(ProcessDefinitionFolderPath, ProcessDefinitionFilename); private const string LayoutSettingsSchemaUrl = "https://altinncdn.no/schemas/json/layout/layoutSettings.schema.v1.json"; private const string LayoutSchemaUrl = "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json"; private const string TextResourceFileNamePattern = "resource.??.json"; + + public static readonly string InitialLayoutFileName = "Side1.json"; - public static string InitialLayoutFileName = "Side1.json"; + public readonly JsonNode InitialLayout = new JsonObject { ["$schema"] = LayoutSchemaUrl, ["data"] = new JsonObject { ["layout"] = new JsonArray([]) } }; - public JsonNode InitialLayout = new JsonObject { ["$schema"] = LayoutSchemaUrl, ["data"] = new JsonObject { ["layout"] = new JsonArray([]) } }; + public readonly JsonNode InitialLayoutSettings = new JsonObject { ["$schema"] = LayoutSettingsSchemaUrl, ["pages"] = new JsonObject { ["order"] = new JsonArray([InitialLayoutFileName.Replace(".json", "")]) } }; - public JsonNode InitialLayoutSettings = new JsonObject { ["$schema"] = LayoutSettingsSchemaUrl, ["pages"] = new JsonObject { ["order"] = new JsonArray([InitialLayoutFileName.Replace(".json", "")]) } }; - - private static readonly JsonSerializerOptions _jsonOptions = new() + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, @@ -90,16 +91,16 @@ public AltinnAppGitRepository(string org, string repository, string developer, s public async Task GetApplicationMetadata(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - string appMetadataRelativeFilePath = Path.Combine(CONFIG_FOLDER_PATH, APP_METADATA_FILENAME); + string appMetadataRelativeFilePath = Path.Combine(ConfigFolderPath, AppMetadataFilename); string fileContent = await ReadTextByRelativePathAsync(appMetadataRelativeFilePath, cancellationToken); - ApplicationMetadata applicationMetaData = JsonSerializer.Deserialize(fileContent, _jsonOptions); + ApplicationMetadata applicationMetaData = JsonSerializer.Deserialize(fileContent, JsonOptions); return applicationMetaData; } public bool ApplicationMetadataExists() { - string appMetadataRelativeFilePath = Path.Combine(CONFIG_FOLDER_PATH, APP_METADATA_FILENAME); + string appMetadataRelativeFilePath = Path.Combine(ConfigFolderPath, AppMetadataFilename); return FileExistsByRelativePath(appMetadataRelativeFilePath); } @@ -109,8 +110,8 @@ public bool ApplicationMetadataExists() /// The updated application metadata to persist. public async Task SaveApplicationMetadata(ApplicationMetadata applicationMetadata) { - string metadataAsJson = JsonSerializer.Serialize(applicationMetadata, _jsonOptions); - string appMetadataRelativeFilePath = Path.Combine(CONFIG_FOLDER_PATH, APP_METADATA_FILENAME); + string metadataAsJson = JsonSerializer.Serialize(applicationMetadata, JsonOptions); + string appMetadataRelativeFilePath = Path.Combine(ConfigFolderPath, AppMetadataFilename); await WriteTextByRelativePathAsync(appMetadataRelativeFilePath, metadataAsJson, true); } @@ -120,8 +121,8 @@ public async Task SaveApplicationMetadata(ApplicationMetadata applicationMetadat /// The updated config to persist. public async Task SaveAppMetadataConfig(ServiceConfiguration serviceConfiguration) { - string config = JsonSerializer.Serialize(serviceConfiguration, _jsonOptions); - string configRelativeFilePath = Path.Combine(SERVICE_CONFIG_FILENAME); + string config = JsonSerializer.Serialize(serviceConfiguration, JsonOptions); + string configRelativeFilePath = Path.Combine(ServiceConfigFilename); await WriteTextByRelativePathAsync(configRelativeFilePath, config, true); } @@ -130,13 +131,13 @@ public async Task SaveAppMetadataConfig(ServiceConfiguration serviceConfiguratio /// public async Task GetAppMetadataConfig() { - string serviceConfigFilePath = Path.Combine(SERVICE_CONFIG_FILENAME); + string serviceConfigFilePath = Path.Combine(ServiceConfigFilename); if (!FileExistsByRelativePath(serviceConfigFilePath)) { throw new FileNotFoundException("Config file not found."); } string fileContent = await ReadTextByRelativePathAsync(serviceConfigFilePath); - ServiceConfiguration config = JsonSerializer.Deserialize(fileContent, _jsonOptions); + ServiceConfiguration config = JsonSerializer.Deserialize(fileContent, JsonOptions); return config; } @@ -174,7 +175,7 @@ public async Task SaveModelMetadata(string modelMetadata, string modelName) /// The name of the model, will be used as filename. public async Task SaveCSharpClasses(string csharpClasses, string modelName) { - string csharpModelRelativeFilePath = Path.Combine(MODEL_FOLDER_PATH, $"{modelName}.cs"); + string csharpModelRelativeFilePath = Path.Combine(ModelFolderPath, $"{modelName}.cs"); await WriteTextByRelativePathAsync(csharpModelRelativeFilePath, csharpClasses, true); } @@ -216,7 +217,7 @@ public async Task SaveXsd(MemoryStream xsdMemoryStream, string fileName) /// A string containing the relative path to the file saved. public override async Task SaveXsd(string xsd, string fileName) { - string filePath = Path.Combine(MODEL_FOLDER_PATH, fileName); + string filePath = Path.Combine(ModelFolderPath, fileName); await WriteTextByRelativePathAsync(filePath, xsd, true); return filePath; @@ -228,7 +229,7 @@ public override async Task SaveXsd(string xsd, string fileName) /// A string with the relative path to the model folder within the app. public string GetRelativeModelFolder() { - return MODEL_FOLDER_PATH; + return ModelFolderPath; } public List GetLanguages() @@ -269,7 +270,7 @@ public async Task GetTextV1(string language, CancellationToken can throw new NotFoundException("Text resource file not found."); } string fileContent = await ReadTextByRelativePathAsync(resourcePath, cancellationToken); - TextResource textResource = JsonSerializer.Deserialize(fileContent, _jsonOptions); + TextResource textResource = JsonSerializer.Deserialize(fileContent, JsonOptions); return textResource; } @@ -278,7 +279,7 @@ public async Task SaveTextV1(string languageCode, TextResource jsonTexts) { string fileName = $"resource.{languageCode}.json"; string textsFileRelativeFilePath = GetPathToJsonTextsFile(fileName); - string texts = JsonSerializer.Serialize(jsonTexts, _jsonOptions); + string texts = JsonSerializer.Serialize(jsonTexts, JsonOptions); await WriteTextByRelativePathAsync(textsFileRelativeFilePath, texts); } @@ -293,7 +294,7 @@ public async Task> GetTextsV2(string languageCode) string fileName = $"{languageCode}.texts.json"; string textsFileRelativeFilePath = GetPathToJsonTextsFile(fileName); string texts = await ReadTextByRelativePathAsync(textsFileRelativeFilePath); - Dictionary jsonTexts = JsonSerializer.Deserialize>(texts, _jsonOptions); + Dictionary jsonTexts = JsonSerializer.Deserialize>(texts, JsonOptions); return jsonTexts; } @@ -307,7 +308,7 @@ public async Task SaveTextsV2(string languageCode, Dictionary js { string fileName = $"{languageCode}.texts.json"; string textsFileRelativeFilePath = GetPathToJsonTextsFile(fileName); - string texts = JsonSerializer.Serialize(jsonTexts, _jsonOptions); + string texts = JsonSerializer.Serialize(jsonTexts, JsonOptions); await WriteTextByRelativePathAsync(textsFileRelativeFilePath, texts); } @@ -315,7 +316,7 @@ public async Task SaveTextsV2(string languageCode, Dictionary js /// Overwrite or creates a markdown file for a specific text for a specific language. /// /// Language identifier - /// Keyvaluepair containing markdown text + /// KeyValuePair containing markdown text public async Task SaveTextMarkdown(string languageCode, KeyValuePair text) { string fileName = $"{text.Key}.{languageCode}.texts.md"; @@ -355,11 +356,11 @@ public void DeleteTexts(string languageCode) } /// - /// Returns all the layouts for a specific layoutset + /// Returns all the layouts for a specific layout set /// - /// The name of the layoutset where the layout belong + /// The name of the layout set where the layout belong /// A that observes if operation is cancelled. - /// A list of all layouts for a layoutset + /// A list of all layouts for a layout set public async Task> GetFormLayouts(string layoutSetName, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); @@ -375,10 +376,10 @@ public async Task> GetFormLayouts(string layoutSetN } /// - /// Returns the layout for a specific layoutset + /// Returns the layout for a specific layout set /// - /// The name of the layoutset where the layout belong - /// The name of layoutfile + /// The name of the layout set where the layout belong + /// The name of layout file /// A that observes if operation is cancelled. /// The layout public async Task GetLayout(string layoutSetName, string layoutName, CancellationToken cancellationToken = default) @@ -390,10 +391,10 @@ public async Task GetLayout(string layoutSetName, string layoutName, C } /// - /// Returns the layout for a specific layoutset + /// Returns the layout for a specific layout set /// - /// The name of the layoutset where the layout belong - /// The name of layoutfile + /// The name of the layout set where the layout belong + /// The name of layout file /// The layout public void DeleteLayout(string layoutSetName, string layoutName) { @@ -406,13 +407,13 @@ public void DeleteLayout(string layoutSetName, string layoutName) } /// - /// Gets a list of all layoutset names - /// If app does not use layoutset the default folder for layouts "layouts" will be returned + /// Gets a list of all layout set names + /// If app does not use layout set the default folder for layouts "layouts" will be returned /// - /// An array of all layoutset names + /// An array of all layout set names public string[] GetLayoutSetNames() { - string layoutSetsRelativePath = Path.Combine(LAYOUTS_FOLDER_NAME); + string layoutSetsRelativePath = Path.Combine(LayoutsFolderName); string[] layoutSetNames = GetDirectoriesByRelativeDirectory(layoutSetsRelativePath); return layoutSetNames; @@ -426,7 +427,7 @@ public void ChangeLayoutSetFolderName(string oldLayoutSetName, string newLayoutS cancellationToken.ThrowIfCancellationRequested(); if (DirectoryExistsByRelativePath(GetPathToLayoutSet(newLayoutSetName))) { - throw new BadHttpRequestException("Suggested new layout set name already exist"); + throw new NonUniqueLayoutSetIdException("Suggested new layout set name already exist"); } string destAbsolutePath = GetAbsoluteFileOrDirectoryPathSanitized(GetPathToLayoutSet(newLayoutSetName, true)); @@ -441,23 +442,23 @@ public void ChangeLayoutSetFolderName(string oldLayoutSetName, string newLayoutS } /// - /// Check if app uses layoutsets or not based on whether - /// the list of layoutset names actually are layoutset names + /// Check if app uses layout sets or not based on whether + /// the list of layout set names actually are layout set names /// or only the default folder for layouts /// - /// A boolean representing if the app uses layoutsets or not + /// A boolean representing if the app uses layout sets or not public bool AppUsesLayoutSets() { - string layoutSetJsonFilePath = Path.Combine(LAYOUTS_FOLDER_NAME, "layout-sets.json"); + string layoutSetJsonFilePath = Path.Combine(LayoutsFolderName, "layout-sets.json"); return FileExistsByRelativePath(layoutSetJsonFilePath); } /// - /// Gets all layout names for a specific layoutset + /// Gets all layout names for a specific layout set /// - /// The name of the layoutset where the layout belong - /// An array with the name of all layout files under the specific layoutset + /// The name of the layout set where the layout belong + /// An array with the name of all layout files under the specific layout set public string[] GetLayoutNames(string layoutSetName) { string layoutSetPath = GetPathToLayoutSet(layoutSetName); @@ -478,9 +479,9 @@ public string[] GetLayoutNames(string layoutSetName) } /// - /// Gets the Settings.json for a specific layoutset + /// Gets the Settings.json for a specific layout set /// - /// The name of the layoutset where the layout belong + /// The name of the layout set where the layout belong /// An that observes if operation is cancelled. /// The content of Settings.json public async Task GetLayoutSettingsAndCreateNewIfNotFound(string layoutSetName, CancellationToken cancellationToken = default) @@ -491,7 +492,7 @@ public async Task GetLayoutSettingsAndCreateNewIfNotFound(string layou { await CreateLayoutSettings(layoutSetName); } - string fileContent = await ReadTextByRelativePathAsync(layoutSettingsPath); + string fileContent = await ReadTextByRelativePathAsync(layoutSettingsPath, cancellationToken); var layoutSettings = JsonNode.Parse(fileContent); return layoutSettings; @@ -527,23 +528,23 @@ private static string[] MakePageOrder(string[] layoutNames) } /// - /// Saves the Settings.json for a specific layoutset + /// Saves the Settings.json for a specific layout set /// - /// The name of the layoutset where the layout belong - /// The layoutsettings to be saved + /// The name of the layout set where the layout belong + /// The layout settings to be saved /// The content of Settings.json public async Task SaveLayoutSettings(string layoutSetName, JsonNode layoutSettings) { string layoutSettingsPath = GetPathToLayoutSettings(layoutSetName); - string serializedLayoutSettings = layoutSettings.ToJsonString(_jsonOptions); + string serializedLayoutSettings = layoutSettings.ToJsonString(JsonOptions); await WriteTextByRelativePathAsync(layoutSettingsPath, serializedLayoutSettings); } /// - /// Saves layout file to specific layoutset. If layoutset is null - /// it will be stored as if the app does not use layoutsets, meaning under /App/ui/layouts/. + /// Saves layout file to specific layout set. If layout set is null + /// it will be stored as if the app does not use layout sets, meaning under /App/ui/layouts/. /// - /// The name of the layoutset where the layout belong + /// The name of the layout set where the layout belong /// The name of layout file /// The actual layout that is saved /// An that observes if operation is cancelled. @@ -551,7 +552,7 @@ public async Task SaveLayout(string layoutSetName, string layoutFileName, JsonNo { cancellationToken.ThrowIfCancellationRequested(); string layoutFilePath = GetPathToLayoutFile(layoutSetName, layoutFileName); - string serializedLayout = layout.ToJsonString(_jsonOptions); + string serializedLayout = layout.ToJsonString(JsonOptions); await WriteTextByRelativePathAsync(layoutFilePath, serializedLayout, true, cancellationToken); } @@ -576,8 +577,8 @@ public async Task GetLayoutSetsFile(CancellationToken cancellationTo { string layoutSetsFilePath = GetPathToLayoutSetsFile(); cancellationToken.ThrowIfCancellationRequested(); - string fileContent = await ReadTextByRelativePathAsync(layoutSetsFilePath); - LayoutSets layoutSetsFile = JsonSerializer.Deserialize(fileContent, _jsonOptions); + string fileContent = await ReadTextByRelativePathAsync(layoutSetsFilePath, cancellationToken); + LayoutSets layoutSetsFile = JsonSerializer.Deserialize(fileContent, JsonOptions); return layoutSetsFile; } @@ -589,20 +590,20 @@ public async Task SaveLayoutSetsFile(LayoutSets layoutSets) if (AppUsesLayoutSets()) { string layoutSetsFilePath = GetPathToLayoutSetsFile(); - string layoutSetsString = JsonSerializer.Serialize(layoutSets, _jsonOptions); + string layoutSetsString = JsonSerializer.Serialize(layoutSets, JsonOptions); await WriteTextByRelativePathAsync(layoutSetsFilePath, layoutSetsString); } else { - throw new NotFoundException("No layout set was found for this app"); + throw new NoLayoutSetsFileFoundException("No layout set was found for this app."); } } /// - /// Saves the RuleHandler.js for a specific layoutset + /// Saves the RuleHandler.js for a specific layout set /// - /// The name of the layoutset where the layout belong - /// The layoutsettings to be saved + /// The name of the layout set where the layout belong + /// The layout settings to be saved /// The content of Settings.json public async Task SaveRuleHandler(string layoutSetName, string ruleHandler) { @@ -611,9 +612,9 @@ public async Task SaveRuleHandler(string layoutSetName, string ruleHandler) } /// - /// Gets the RuleHandler.js for a specific layoutset + /// Gets the RuleHandler.js for a specific layout set /// - /// The name of the layoutset where the layout belong + /// The name of the layout set where the layout belong /// A that observes if operation is cancelled. /// The content of Settings.json public async Task GetRuleHandler(string layoutSetName, CancellationToken cancellationToken = default) @@ -639,7 +640,7 @@ public async Task SaveRuleConfiguration(string layoutSetName, JsonNode ruleConfi { cancellationToken.ThrowIfCancellationRequested(); string ruleConfigurationPath = GetPathToRuleConfiguration(layoutSetName); - string serializedRuleConfiguration = ruleConfiguration.ToJsonString(_jsonOptions); + string serializedRuleConfiguration = ruleConfiguration.ToJsonString(JsonOptions); await WriteTextByRelativePathAsync(ruleConfigurationPath, serializedRuleConfiguration, cancellationToken: cancellationToken); } @@ -683,9 +684,9 @@ private async Task AddDataToRootOfRuleConfigIfNotPresent(string layoutSe public async Task GetAppFrontendCshtml(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); - if (FileExistsByRelativePath(CSHTML_PATH)) + if (FileExistsByRelativePath(CshtmlPath)) { - string cshtml = await ReadTextByRelativePathAsync(CSHTML_PATH, cancellationToken); + string cshtml = await ReadTextByRelativePathAsync(CshtmlPath, cancellationToken); return cshtml; } @@ -700,7 +701,7 @@ public async Task GetAppFrontendCshtml(CancellationToken cancellationTok /// public async Task GetOptions(string optionsListId, CancellationToken cancellationToken = default) { - string optionsFilePath = Path.Combine(OPTIONS_FOLDER_PATH, $"{optionsListId}.json"); + string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionsListId}.json"); if (!FileExistsByRelativePath(optionsFilePath)) { throw new NotFoundException("Options file not found."); @@ -716,14 +717,14 @@ public async Task GetOptions(string optionsListId, CancellationToken can /// public string[] GetOptionListIds() { - string optionsFolder = Path.Combine(OPTIONS_FOLDER_PATH); + string optionsFolder = Path.Combine(OptionsFolderPath); if (!DirectoryExistsByRelativePath(optionsFolder)) { throw new NotFoundException("Options folder not found."); } string[] fileNames = GetFilesByRelativeDirectory(optionsFolder); List optionListIds = new(); - foreach (string fileName in fileNames.Select(f => Path.GetFileNameWithoutExtension(f))) + foreach (string fileName in fileNames.Select(Path.GetFileNameWithoutExtension)) { optionListIds.Add(fileName); } @@ -732,7 +733,7 @@ public string[] GetOptionListIds() } /// - /// Saves the processdefinition file on disk. + /// Saves the process definition file on disk. /// /// Stream of the file to be saved. /// A that observes if operation is cancelled. @@ -778,78 +779,78 @@ public Stream GetImage(string imageFilePath, CancellationToken cancellationToken /// A string with the relative path to the model file, including file extension. private string GetPathToModelJsonSchema(string modelName) { - return Path.Combine(MODEL_FOLDER_PATH, $"{modelName}.schema.json"); + return Path.Combine(ModelFolderPath, $"{modelName}.schema.json"); } private string GetPathToModelMetadata(string modelName) { - return Path.Combine(MODEL_FOLDER_PATH, $"{modelName}.metadata.json"); + return Path.Combine(ModelFolderPath, $"{modelName}.metadata.json"); } private static string GetPathToTexts() { - return Path.Combine(CONFIG_FOLDER_PATH, LANGUAGE_RESOURCE_FOLDER_NAME); + return Path.Combine(ConfigFolderPath, LanguageResourceFolderName); } private static string GetPathToImage(string imageFilePath) { - return Path.Combine(IMAGES_FOLDER_NAME, imageFilePath); + return Path.Combine(ImagesFolderName, imageFilePath); } private static string GetPathToJsonTextsFile(string fileName) { return fileName.IsNullOrEmpty() ? - Path.Combine(CONFIG_FOLDER_PATH, LANGUAGE_RESOURCE_FOLDER_NAME) : - Path.Combine(CONFIG_FOLDER_PATH, LANGUAGE_RESOURCE_FOLDER_NAME, fileName); + Path.Combine(ConfigFolderPath, LanguageResourceFolderName) : + Path.Combine(ConfigFolderPath, LanguageResourceFolderName, fileName); } private static string GetPathToMarkdownTextFile(string fileName) { - return Path.Combine(CONFIG_FOLDER_PATH, LANGUAGE_RESOURCE_FOLDER_NAME, MARKDOWN_TEXTS_FOLDER_NAME, fileName); + return Path.Combine(ConfigFolderPath, LanguageResourceFolderName, MarkdownTextsFolderName, fileName); } - - // can be null if app does not use layoutset + + // can be null if app does not use layout set private static string GetPathToLayoutSet(string layoutSetName, bool excludeLayoutsFolderName = false) { - var layoutFolderName = excludeLayoutsFolderName ? string.Empty : LAYOUTS_IN_SET_FOLDER_NAME; + var layoutFolderName = excludeLayoutsFolderName ? string.Empty : LayoutsInSetFolderName; return layoutSetName.IsNullOrEmpty() ? - Path.Combine(LAYOUTS_FOLDER_NAME, layoutFolderName) : - Path.Combine(LAYOUTS_FOLDER_NAME, layoutSetName, layoutFolderName); + Path.Combine(LayoutsFolderName, layoutFolderName) : + Path.Combine(LayoutsFolderName, layoutSetName, layoutFolderName); } - // can be null if app does not use layoutset + // can be null if app does not use layout set private static string GetPathToLayoutFile(string layoutSetName, string fileName) { return layoutSetName.IsNullOrEmpty() ? - Path.Combine(LAYOUTS_FOLDER_NAME, LAYOUTS_IN_SET_FOLDER_NAME, fileName) : - Path.Combine(LAYOUTS_FOLDER_NAME, layoutSetName, LAYOUTS_IN_SET_FOLDER_NAME, fileName); + Path.Combine(LayoutsFolderName, LayoutsInSetFolderName, fileName) : + Path.Combine(LayoutsFolderName, layoutSetName, LayoutsInSetFolderName, fileName); } - // can be null if app does not use layoutset + // can be null if app does not use layout set private static string GetPathToLayoutSettings(string layoutSetName) { return layoutSetName.IsNullOrEmpty() ? - Path.Combine(LAYOUTS_FOLDER_NAME, LAYOUT_SETTINGS_FILENAME) : - Path.Combine(LAYOUTS_FOLDER_NAME, layoutSetName, LAYOUT_SETTINGS_FILENAME); + Path.Combine(LayoutsFolderName, LayoutSettingsFilename) : + Path.Combine(LayoutsFolderName, layoutSetName, LayoutSettingsFilename); } private static string GetPathToLayoutSetsFile() { - return Path.Combine(LAYOUTS_FOLDER_NAME, LAYOUT_SETS_FILENAME); + return Path.Combine(LayoutsFolderName, LayoutSetsFilename); } private static string GetPathToRuleHandler(string layoutSetName) { return layoutSetName.IsNullOrEmpty() ? - Path.Combine(LAYOUTS_FOLDER_NAME, RULE_HANDLER_FILENAME) : - Path.Combine(LAYOUTS_FOLDER_NAME, layoutSetName, RULE_HANDLER_FILENAME); + Path.Combine(LayoutsFolderName, RuleHandlerFilename) : + Path.Combine(LayoutsFolderName, layoutSetName, RuleHandlerFilename); } private static string GetPathToRuleConfiguration(string layoutSetName) { return layoutSetName.IsNullOrEmpty() ? - Path.Combine(LAYOUTS_FOLDER_NAME, RULE_CONFIGURATION_FILENAME) : - Path.Combine(LAYOUTS_FOLDER_NAME, layoutSetName, RULE_CONFIGURATION_FILENAME); + Path.Combine(LayoutsFolderName, RuleConfigurationFilename) : + Path.Combine(LayoutsFolderName, layoutSetName, RuleConfigurationFilename); } /// diff --git a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs index c56f97c51c4..8430148937a 100644 --- a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs +++ b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs @@ -2,11 +2,13 @@ using System.IO; using System.Linq; using System.Text.Json.Nodes; +using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Altinn.App.Core.Models; using Altinn.Studio.DataModeling.Metamodel; using Altinn.Studio.Designer.Exceptions; +using Altinn.Studio.Designer.Exceptions.AppDevelopment; using Altinn.Studio.Designer.Helpers; using Altinn.Studio.Designer.Infrastructure.GitRepository; using Altinn.Studio.Designer.Models; @@ -15,6 +17,7 @@ using Microsoft.AspNetCore.Http; using NuGet.Versioning; using LayoutSets = Altinn.Studio.Designer.Models.LayoutSets; +using NonUniqueLayoutSetIdException = Altinn.Studio.Designer.Exceptions.NonUniqueLayoutSetIdException; using PlatformStorageModels = Altinn.Platform.Storage.Interface.Models; namespace Altinn.Studio.Designer.Services.Implementation @@ -26,6 +29,7 @@ public class AppDevelopmentService : IAppDevelopmentService { private readonly IAltinnGitRepositoryFactory _altinnGitRepositoryFactory; private readonly ISchemaModelService _schemaModelService; + private readonly string _layoutSetNameRegEx = "[a-zA-Z0-9-]{2,28}"; /// /// Constructor @@ -237,7 +241,8 @@ public async Task GetLayoutSets(AltinnRepoEditingContext altinnRepoE return layoutSets; } - return null; + throw new NoLayoutSetsFileFoundException( + "No layout set found for this app."); } /// @@ -250,11 +255,11 @@ public async Task AddLayoutSet(AltinnRepoEditingContext altinnRepoEd altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); if (!altinnAppGitRepository.AppUsesLayoutSets()) { - throw new FileNotFoundException("No layout set found for this app"); + throw new NoLayoutSetsFileFoundException("No layout set found for this app."); } - if (string.IsNullOrEmpty(newLayoutSet.Id)) + if (Regex.IsMatch(newLayoutSet.Id, _layoutSetNameRegEx)) { - throw new EmptyLayoutSetIdException("New layout set name must have a value."); + throw new InvalidLayoutSetIdException("New layout set name is not valid."); } LayoutSets layoutSets = await altinnAppGitRepository.GetLayoutSetsFile(cancellationToken); if (layoutSets.Sets.Exists(set => set.Id == newLayoutSet.Id)) @@ -275,11 +280,12 @@ public async Task UpdateLayoutSet(AltinnRepoEditingContext altinnRep altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); if (!altinnAppGitRepository.AppUsesLayoutSets()) { - throw new FileNotFoundException("No layout set found for this app"); + + throw new NoLayoutSetsFileFoundException("No layout set found for this app."); } - if (string.IsNullOrEmpty(newLayoutSet.Id)) + if (!Regex.IsMatch(newLayoutSet.Id, _layoutSetNameRegEx)) { - throw new EmptyLayoutSetIdException("New layout set name must have a value."); + throw new InvalidLayoutSetIdException("New layout set name is not valid."); } LayoutSets layoutSets = await altinnAppGitRepository.GetLayoutSetsFile(cancellationToken); LayoutSetConfig layoutSetToReplace = layoutSets.Sets.Find(set => set.Id == layoutSetToUpdateId); @@ -287,11 +293,12 @@ public async Task UpdateLayoutSet(AltinnRepoEditingContext altinnRep { return await UpdateExistingLayoutSet(altinnAppGitRepository, layoutSets, layoutSetToReplace, newLayoutSet); } - // Layout set name is updated which means layout set folder must be updated also + // NewLayoutSet Id is not the same as existing layout set Id so must check if the suggested new Id already exists if (layoutSets.Sets.Exists(set => set.Id == newLayoutSet.Id)) { throw new NonUniqueLayoutSetIdException($"Layout set name, {newLayoutSet.Id}, already exists."); } + // Layout set name is updated which means layout set folder must be updated also altinnAppGitRepository.ChangeLayoutSetFolderName(layoutSetToUpdateId, newLayoutSet.Id, cancellationToken); return await UpdateExistingLayoutSet(altinnAppGitRepository, layoutSets, layoutSetToReplace, newLayoutSet); } diff --git a/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs b/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs index 9be33ebe060..44623d830b7 100644 --- a/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs +++ b/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs @@ -95,7 +95,7 @@ public Task GetModelMetadata( public Task GetLayoutSets(AltinnRepoEditingContext altinnRepoEditingContext, CancellationToken cancellationToken = default); /// - /// Adds a config for an additional layout set to the layout-set.json + /// Adds a config for an additional layout set to the layout-sets.json /// /// An . /// Config for the new layout set @@ -107,7 +107,7 @@ public Task GetModelMetadata( /// /// An . /// The id of the layout set to replace - /// Config for the new layout set + /// Config for the updated layout set /// An that observes if operation is cancelled. public Task UpdateLayoutSet(AltinnRepoEditingContext altinnRepoEditingContext, string layoutSetToUpdateId, LayoutSetConfig newLayoutSet, CancellationToken cancellationToken = default); diff --git a/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs b/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs index 5608947073e..975b3952a29 100644 --- a/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs +++ b/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs @@ -180,7 +180,7 @@ public async Task AddLayoutSet_WhenLayoutSetDoesNotExist_ShouldAddNewLayoutSet() // Assert updatedLayoutSets.Should().NotBeNull(); updatedLayoutSets.Sets.Should().HaveCount(4); - updatedLayoutSets.Sets.Should().Contain(newLayoutSet); // Ensure newLayoutSet is added + updatedLayoutSets.Sets.Should().Contain(newLayoutSet); } [Fact] diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 7db294998bc..6fe90f7e3a7 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -14,7 +14,7 @@ "address_component.validation_error_house_number": "Bolignummer er ugyldig", "address_component.validation_error_zipcode": "Postnummer er ugyldig", "api_errors.AD_01": "Navnet er allerede i bruk for en annen sidegruppe.", - "api_errors.AD_02": "Navnet på sidegruppen kan ikke være tomt.", + "api_errors.AD_02": "Ugyldig navn på sidegruppe.", "api_errors.DM_01": "Noe gikk galt under bygging av datamodellen.", "api_errors.DM_05": "Kunne ikke lese ugyldig XML.", "api_errors.DM_CsharpCompiler_NameCollision": "Navnet {{nodeName}} er i bruk på et element som har et overordnet element med samme navn. Gi et av disse elementene et annet navn.", From 3914d2cd08b214d2bc8d7f5aaa7ba51b0dcf9c9c Mon Sep 17 00:00:00 2001 From: andreastanderen Date: Thu, 21 Mar 2024 12:18:16 +0100 Subject: [PATCH 2/3] Add BpmnApiContext with queryresults and mutation callbacks --- .../GitRepository/AltinnAppGitRepository.cs | 4 +- .../Implementation/AppDevelopmentService.cs | 10 +- .../Services/AppDevelopmentServiceTest.cs | 7 +- .../processEditor/ProcessEditor.test.tsx | 75 +--------- .../features/processEditor/ProcessEditor.tsx | 23 ++- .../AppBarConfig/AppPreviewBarConfig.tsx | 2 +- frontend/language/src/nb.json | 2 + .../process-editor/src/ProcessEditor.test.tsx | 7 +- .../process-editor/src/ProcessEditor.tsx | 58 ++++---- .../ConfigEndEvent/ConfigEndEvent.test.tsx | 133 +++++++++++++----- .../ConfigEndEvent/ConfigEndEvent.tsx | 40 ++++-- .../ConfigPanel/ConfigPanel.test.tsx | 15 +- .../components/ConfigPanel/ConfigPanel.tsx | 29 +--- .../src/contexts/BpmnApiContext.tsx | 53 +++++++ .../src/hooks/queries/useLayoutSetsQuery.ts | 0 .../hooks/useCustomReceiptLayoutSetName.ts | 2 +- ...validateLayoutNameAndLayoutSetName.test.ts | 0 .../validateLayoutNameAndLayoutSetName.ts | 0 .../shared/src/utils/layoutSetsUtils.ts | 14 ++ frontend/packages/ux-editor-v3/src/App.tsx | 2 +- .../src/components/Elements/Elements.tsx | 2 +- .../Elements/LayoutSetsContainer.tsx | 2 +- .../mutations/useAddItemToLayoutMutation.ts | 2 +- .../useUpdateFormComponentMutation.ts | 2 +- .../utils/designViewUtils/designViewUtils.ts | 2 +- frontend/packages/ux-editor/src/App.tsx | 2 +- .../Elements/LayoutSetsContainer.tsx | 2 +- .../mutations/useAddItemToLayoutMutation.ts | 2 +- .../useUpdateFormComponentMutation.ts | 2 +- .../utils/designViewUtils/designViewUtils.ts | 2 +- 30 files changed, 276 insertions(+), 220 deletions(-) create mode 100644 frontend/packages/process-editor/src/contexts/BpmnApiContext.tsx rename frontend/packages/{ux-editor => shared}/src/hooks/queries/useLayoutSetsQuery.ts (100%) rename frontend/packages/{ux-editor/src/utils/validationUtils => shared/src/utils/LayoutAndLayoutSetNameValidationUtils}/validateLayoutNameAndLayoutSetName.test.ts (100%) rename frontend/packages/{ux-editor/src/utils/validationUtils => shared/src/utils/LayoutAndLayoutSetNameValidationUtils}/validateLayoutNameAndLayoutSetName.ts (100%) diff --git a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs index fa7ed3e6784..3573ecde50e 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs @@ -57,7 +57,7 @@ public class AltinnAppGitRepository : AltinnGitRepository private const string LayoutSchemaUrl = "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json"; private const string TextResourceFileNamePattern = "resource.??.json"; - + public static readonly string InitialLayoutFileName = "Side1.json"; public readonly JsonNode InitialLayout = new JsonObject { ["$schema"] = LayoutSchemaUrl, ["data"] = new JsonObject { ["layout"] = new JsonArray([]) } }; @@ -808,7 +808,7 @@ private static string GetPathToMarkdownTextFile(string fileName) { return Path.Combine(ConfigFolderPath, LanguageResourceFolderName, MarkdownTextsFolderName, fileName); } - + // can be null if app does not use layout set private static string GetPathToLayoutSet(string layoutSetName, bool excludeLayoutsFolderName = false) { diff --git a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs index 8430148937a..8fc277368af 100644 --- a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs +++ b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs @@ -202,7 +202,7 @@ public async Task GetModelMetadata(AltinnRepoEditingContext altin private string GetModelName(ApplicationMetadata applicationMetadata, [CanBeNull] string taskId) { - // fallback to first model if no task_id is provided (no layoutsets) + // fallback to first model if no task_id is provided (no layout sets) if (taskId == null) { return applicationMetadata.DataTypes.FirstOrDefault(data => data.AppLogic != null && !string.IsNullOrEmpty(data.AppLogic.ClassRef) && !string.IsNullOrEmpty(data.TaskId))?.Id ?? string.Empty; @@ -221,7 +221,13 @@ private bool DoesDataTaskMatchTaskId(PlatformStorageModels.DataType data, [CanBe private async Task GetTaskIdBasedOnLayoutSet(AltinnRepoEditingContext altinnRepoEditingContext, string layoutSetName, CancellationToken cancellationToken = default) { + if (string.IsNullOrEmpty(layoutSetName)) + { + // App without layout sets --> no need for task_id, we just retrieve the first occurence of a dataType with a classRef + return null; + } LayoutSets layoutSets = await GetLayoutSets(altinnRepoEditingContext, cancellationToken); + return layoutSets?.Sets?.Find(set => set.Id == layoutSetName)?.Tasks[0]; } @@ -257,7 +263,7 @@ public async Task AddLayoutSet(AltinnRepoEditingContext altinnRepoEd { throw new NoLayoutSetsFileFoundException("No layout set found for this app."); } - if (Regex.IsMatch(newLayoutSet.Id, _layoutSetNameRegEx)) + if (!Regex.IsMatch(newLayoutSet.Id, _layoutSetNameRegEx)) { throw new InvalidLayoutSetIdException("New layout set name is not valid."); } diff --git a/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs b/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs index 975b3952a29..959ecce4902 100644 --- a/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs +++ b/backend/tests/Designer.Tests/Services/AppDevelopmentServiceTest.cs @@ -3,7 +3,7 @@ using System.IO; using System.Text.Json.Nodes; using System.Threading.Tasks; -using Altinn.Studio.Designer.Exceptions; +using Altinn.Studio.Designer.Exceptions.AppDevelopment; using Altinn.Studio.Designer.Factories; using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Services.Implementation; @@ -12,6 +12,7 @@ using FluentAssertions; using Moq; using Xunit; +using NonUniqueLayoutSetIdException = Altinn.Studio.Designer.Exceptions.NonUniqueLayoutSetIdException; namespace Designer.Tests.Services; @@ -212,7 +213,7 @@ public async Task UpdateLayoutSet_WhenAppHasNoLayoutSets_ShouldThrowFileNotFound Func act = async () => await _appDevelopmentService.UpdateLayoutSet(AltinnRepoEditingContext.FromOrgRepoDeveloper(_org, targetRepository, _developer), "layoutSet1", new LayoutSetConfig()); // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } [Fact] @@ -228,7 +229,7 @@ public async Task AddLayoutSet_WhenAppHasNoLayoutSets_ShouldThrowFileNotFoundExc Func act = async () => await _appDevelopmentService.AddLayoutSet(AltinnRepoEditingContext.FromOrgRepoDeveloper(_org, targetRepository, _developer), new LayoutSetConfig()); // Assert - await act.Should().ThrowAsync(); + await act.Should().ThrowAsync(); } private List GetFileNamesInLayoutSet(string layoutSetName) diff --git a/frontend/app-development/features/processEditor/ProcessEditor.test.tsx b/frontend/app-development/features/processEditor/ProcessEditor.test.tsx index 78599b8badd..2c88c6ceb28 100644 --- a/frontend/app-development/features/processEditor/ProcessEditor.test.tsx +++ b/frontend/app-development/features/processEditor/ProcessEditor.test.tsx @@ -1,28 +1,19 @@ import React from 'react'; -import { act, screen } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { ProcessEditor } from './ProcessEditor'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { renderWithProviders } from '../../test/testUtils'; import { QueryKey } from 'app-shared/types/QueryKey'; import type { AppVersion } from 'app-shared/types/AppVersion'; import { textMock } from '../../../testing/mocks/i18nMock'; -import { APP_DEVELOPMENT_BASENAME, PROTECTED_TASK_NAME_CUSTOM_RECEIPT } from 'app-shared/constants'; +import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; import { useBpmnContext } from '../../../packages/process-editor/src/contexts/BpmnContext'; -import { queriesMock } from 'app-shared/mocks/queriesMock'; -import userEvent from '@testing-library/user-event'; -import { layoutSets } from 'app-shared/mocks/mocks'; -import { layoutSetsMock } from '../../../packages/ux-editor/src/testing/layoutMock'; -import type { LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; // test data const org = 'org'; const app = 'app'; const defaultAppVersion: AppVersion = { backendVersion: '8.0.0', frontendVersion: '4.0.0' }; -jest.mock('app-shared/hooks/useConfirmationDialogOnPageLeave', () => ({ - useConfirmationDialogOnPageLeave: jest.fn(), -})); - jest.mock('../../../packages/process-editor/src/contexts/BpmnContext', () => ({ ...jest.requireActual('../../../packages/process-editor/src/contexts/BpmnContext'), useBpmnContext: jest.fn(), @@ -69,68 +60,6 @@ describe('ProcessEditor', () => { renderProcessEditor({ bpmnFile: 'mockBpmn', queryClient: queryClientMock }); screen.getByText(textMock('process_editor.configuration_panel_end_event')); }); - - it('calls onUpdateLayoutSet and trigger addLayoutSet mutation call when layoutSetName for custom receipt is added', async () => { - const customReceiptLayoutSetName = 'CustomReceipt'; - const user = userEvent.setup(); - const queryClientMock = createQueryClientMock(); - queryClientMock.setQueryData([QueryKey.AppVersion, org, app], defaultAppVersion); - (useBpmnContext as jest.Mock).mockReturnValue({ - bpmnDetails: { type: 'bpmn:EndEvent' }, - isEditAllowed: true, - }); - renderProcessEditor({ bpmnFile: 'mockBpmn', queryClient: queryClientMock }); - const inputFieldButton = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add'), - ); - await act(() => user.click(inputFieldButton)); - const inputField = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), - ); - await act(() => user.type(inputField, customReceiptLayoutSetName)); - await act(() => user.tab()); - expect(queriesMock.addLayoutSet).toHaveBeenCalledTimes(1); - expect(queriesMock.addLayoutSet).toHaveBeenCalledWith(org, app, customReceiptLayoutSetName, { - id: customReceiptLayoutSetName, - tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], - }); - }); - - it('calls onUpdateLayoutSet and trigger updateLayoutSet mutation call when layoutSetName for custom receipt is changed', async () => { - const customReceiptLayoutSetName = 'CustomReceipt'; - const newCustomReceiptLayoutSetName = 'NewCustomReceipt'; - const user = userEvent.setup(); - const queryClientMock = createQueryClientMock(); - queryClientMock.setQueryData([QueryKey.AppVersion, org, app], defaultAppVersion); - const layoutSetsWithCustomReceipt: LayoutSetConfig[] = [ - ...layoutSets.sets, - { id: customReceiptLayoutSetName, tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT] }, - ]; - queryClientMock.setQueryData([QueryKey.LayoutSets, org, app], { - ...layoutSetsMock, - sets: layoutSetsWithCustomReceipt, - }); - (useBpmnContext as jest.Mock).mockReturnValue({ - bpmnDetails: { type: 'bpmn:EndEvent' }, - isEditAllowed: true, - }); - renderProcessEditor({ bpmnFile: 'mockBpmn', queryClient: queryClientMock }); - const inputFieldButton = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add'), - ); - await act(() => user.click(inputFieldButton)); - const inputField = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), - ); - await act(() => user.clear(inputField)); - await act(() => user.type(inputField, newCustomReceiptLayoutSetName)); - await act(() => user.tab()); - expect(queriesMock.updateLayoutSet).toHaveBeenCalledTimes(1); - expect(queriesMock.updateLayoutSet).toHaveBeenCalledWith(org, app, customReceiptLayoutSetName, { - id: newCustomReceiptLayoutSetName, - tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], - }); - }); }); const renderProcessEditor = ({ bpmnFile = null, queryClient = createQueryClientMock() } = {}) => { diff --git a/frontend/app-development/features/processEditor/ProcessEditor.tsx b/frontend/app-development/features/processEditor/ProcessEditor.tsx index 284625065fc..4ae3f2f207d 100644 --- a/frontend/app-development/features/processEditor/ProcessEditor.tsx +++ b/frontend/app-development/features/processEditor/ProcessEditor.tsx @@ -8,20 +8,21 @@ import { Spinner } from '@digdir/design-system-react'; import { useTranslation } from 'react-i18next'; import { useAppVersionQuery } from 'app-shared/hooks/queries'; import { useUpdateLayoutSetMutation } from '../../hooks/mutations/useUpdateLayoutSetMutation'; -import type { LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; -import { useCustomReceiptLayoutSetName } from 'app-shared/hooks/useCustomReceiptLayoutSetName'; import { useAddLayoutSetMutation } from '../../hooks/mutations/useAddLayoutSetMutation'; import { type MetaDataForm } from '@altinn/process-editor/src/contexts/BpmnConfigPanelContext'; +import { useCustomReceiptLayoutSetName } from 'app-shared/hooks/useCustomReceiptLayoutSetName'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; export const ProcessEditor = (): React.ReactElement => { const { t } = useTranslation(); const { org, app } = useStudioUrlParams(); const { data: bpmnXml, isError: hasBpmnQueryError } = useBpmnQuery(org, app); const { data: appLibData, isLoading: appLibDataLoading } = useAppVersionQuery(org, app); + const bpmnMutation = useBpmnMutation(org, app); const { mutate: mutateLayoutSet } = useUpdateLayoutSetMutation(org, app); const { mutate: addLayoutSet } = useAddLayoutSetMutation(org, app); - const existingCustomReceipt: string | undefined = useCustomReceiptLayoutSetName(org, app); - const bpmnMutation = useBpmnMutation(org, app); + const existingCustomReceiptName: string | undefined = useCustomReceiptLayoutSetName(org, app); + const { data: layoutSets } = useLayoutSetsQuery(org, app); const saveBpmnXml = async (xml: string, metaData: MetaDataForm): Promise => { const formData = new FormData(); @@ -38,12 +39,6 @@ export const ProcessEditor = (): React.ReactElement => { ); }; - const updateLayoutSet = (layoutSetIdToUpdate: string, layoutSetConfig: LayoutSetConfig) => { - if (layoutSetIdToUpdate === layoutSetConfig.id) - addLayoutSet({ layoutSetIdToUpdate, layoutSetConfig }); - else mutateLayoutSet({ layoutSetIdToUpdate, layoutSetConfig }); - }; - if (appLibDataLoading) { return ; } @@ -51,11 +46,13 @@ export const ProcessEditor = (): React.ReactElement => { // TODO: Handle error will be handled better after issue #10735 is resolved return ( ); }; diff --git a/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx b/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx index ebb272132fa..1ea60fc434b 100644 --- a/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx +++ b/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx @@ -7,7 +7,7 @@ import classes from '../AppPreviewSubMenu.module.css'; import { ArrowCirclepathIcon, EyeIcon, LinkIcon } from '@navikt/aksel-icons'; import { useTranslation } from 'react-i18next'; import type { AppPreviewSubMenuProps } from '../AppPreviewSubMenu'; -import { useLayoutSetsQuery } from '../../../../packages/ux-editor/src/hooks/queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { TopBarMenu } from 'app-shared/enums/TopBarMenu'; import type { TopBarMenuItem } from 'app-shared/types/TopBarMenuItem'; diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 6fe90f7e3a7..cd4092714bd 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -755,6 +755,8 @@ "process_editor.configuration_panel_header_help_text_signing": "Signerings-oppgave (signing) brukes når sluttbruker skal bekrefte med signatur.", "process_editor.configuration_panel_header_help_text_title": "Informasjon om valgt oppgave", "process_editor.configuration_panel_id_label": "ID:", + "process_editor.configuration_panel_layout_set_id_not_unique": "Sidegruppenavnet må være unikt", + "process_editor.configuration_panel_layout_set_id_not_valid_format": "Sidegruppenavnet må være unikt", "process_editor.configuration_panel_missing_task": "Oppgave", "process_editor.configuration_panel_name_label": "Navn: ", "process_editor.configuration_panel_no_task_message": "Velg en oppgave i diagrammet til venstre for å se detaljer om valgt oppgave.", diff --git a/frontend/packages/process-editor/src/ProcessEditor.test.tsx b/frontend/packages/process-editor/src/ProcessEditor.test.tsx index 934bc2a257a..a45c3a32103 100644 --- a/frontend/packages/process-editor/src/ProcessEditor.test.tsx +++ b/frontend/packages/process-editor/src/ProcessEditor.test.tsx @@ -15,10 +15,12 @@ const mockOnSave = jest.fn(); const defaultProps: ProcessEditorProps = { bpmnXml: mockBPMNXML, - existingCustomReceipt: undefined, - onUpdateLayoutSet: jest.fn(), onSave: mockOnSave, appLibVersion: mockAppLibVersion8, + layoutSets: { sets: [] }, + existingCustomReceiptLayoutSetName: undefined, + addLayoutSet: jest.fn(), + mutateLayoutSet: jest.fn(), }; const renderProcessEditor = (props: Partial = {}) => { @@ -35,7 +37,6 @@ const renderProcessEditor = (props: Partial = {}) => { describe('ProcessEditor', () => { beforeEach(jest.clearAllMocks); - it('should render loading while bpmnXml is undefined', () => { renderProcessEditor({ bpmnXml: undefined }); expect(screen.getByTitle(textMock('process_editor.loading'))).toBeInTheDocument(); diff --git a/frontend/packages/process-editor/src/ProcessEditor.tsx b/frontend/packages/process-editor/src/ProcessEditor.tsx index 60561ba373a..06fb819d835 100644 --- a/frontend/packages/process-editor/src/ProcessEditor.tsx +++ b/frontend/packages/process-editor/src/ProcessEditor.tsx @@ -13,23 +13,28 @@ import { ConfigPanel } from './components/ConfigPanel'; import { ConfigViewerPanel } from './components/ConfigViewerPanel'; import classes from './ProcessEditor.module.css'; -import type { LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; +import type { BpmnApiContextProps } from './contexts/BpmnApiContext'; +import { BpmnApiContextProvider } from './contexts/BpmnApiContext'; export type ProcessEditorProps = { + appLibVersion: string; bpmnXml: string | undefined | null; - existingCustomReceipt: string | undefined; onSave: (bpmnXml: string, metaData?: MetaDataForm) => void; - onUpdateLayoutSet: (layoutSetIdToUpdate: string, layoutSetConfig: LayoutSetConfig) => void; - appLibVersion: string; + layoutSets: BpmnApiContextProps['layoutSets']; + existingCustomReceiptLayoutSetName: BpmnApiContextProps['existingCustomReceiptLayoutSetName']; + addLayoutSet: BpmnApiContextProps['addLayoutSet']; + mutateLayoutSet: BpmnApiContextProps['mutateLayoutSet']; }; export const ProcessEditor = ({ + appLibVersion, bpmnXml, - existingCustomReceipt, onSave, - onUpdateLayoutSet, - appLibVersion, -}: ProcessEditorProps): React.ReactElement => { + layoutSets, + existingCustomReceiptLayoutSetName, + addLayoutSet, + mutateLayoutSet, +}: ProcessEditorProps): JSX.Element => { const { t } = useTranslation(); if (bpmnXml === undefined) { @@ -42,26 +47,22 @@ export const ProcessEditor = ({ return ( - - - + + + + + ); }; -type BpmnCanvasProps = Pick< - ProcessEditorProps, - 'onSave' | 'existingCustomReceipt' | 'onUpdateLayoutSet' ->; -const BpmnCanvas = ({ - onSave, - existingCustomReceipt, - onUpdateLayoutSet, -}: BpmnCanvasProps): React.ReactElement | null => { +type BpmnCanvasProps = Pick; +const BpmnCanvas = ({ onSave }: BpmnCanvasProps): React.ReactElement | null => { const { isEditAllowed } = useBpmnContext(); const { metaDataForm, resetForm } = useBpmnConfigPanelFormContext(); @@ -73,14 +74,7 @@ const BpmnCanvas = ({ return (
- {isEditAllowed ? ( - - ) : ( - - )} + {isEditAllowed ? : }
); }; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx index 0ff39bbcc2f..baba579f8bd 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx @@ -2,33 +2,64 @@ import React from 'react'; import { render as rtlRender, act, screen } from '@testing-library/react'; import { textMock } from '../../../../../../testing/mocks/i18nMock'; import { ConfigEndEvent } from './ConfigEndEvent'; -import type { LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; import userEvent from '@testing-library/user-event'; import { PROTECTED_TASK_NAME_CUSTOM_RECEIPT } from 'app-shared/constants'; +import type { LayoutSetConfig, LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import { useBpmnApiContext } from '../../../contexts/BpmnApiContext'; + +jest.mock('../../../contexts/BpmnApiContext', () => ({ + useBpmnApiContext: jest.fn().mockReturnValue({ + layoutSets: {}, + existingCustomReceiptLayoutSetName: undefined, + addLayoutSet: jest.fn(), + mutateLayoutSet: jest.fn(), + }), +})); + +// Test data +const invalidFormatLayoutSetName: string = 'Receipt/'; +const emptyLayoutSetName: string = ''; +const existingLayoutSetName: string = 'layoutSetName1'; +const existingCustomReceiptLayoutSetName: string = 'CustomReceipt'; +const layoutSetWithCustomReceipt: LayoutSetConfig = { + id: existingCustomReceiptLayoutSetName, + tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], +}; +const layoutSetWithDataTask: LayoutSetConfig = { + id: existingLayoutSetName, + tasks: ['Task_1'], +}; +const layoutSetsWithCustomReceipt: LayoutSets = { sets: [layoutSetWithCustomReceipt] }; describe('ConfigEndEvent', () => { it('should display the header for end event', () => { - render(); + renderConfigEndEventPanel(); screen.getByText(textMock('process_editor.configuration_panel_end_event')); }); it('should display informal text when no custom receipt layout set exists', () => { - render(); + renderConfigEndEventPanel(); screen.getByText(textMock('process_editor.configuration_panel_custom_receipt_add')); }); it('should display existing layout set name of receipt when custom receipt layout set exists', () => { - const existingCustomReceiptLayoutSetName = 'CustomReceipt'; - render({ existingCustomReceiptName: existingCustomReceiptLayoutSetName }); + (useBpmnApiContext as jest.Mock).mockReturnValue({ + existingCustomReceiptLayoutSetName: existingCustomReceiptLayoutSetName, + }); + renderConfigEndEventPanel(); screen.getByText(textMock('process_editor.configuration_panel_custom_receipt_name')); screen.getByRole('button', { name: existingCustomReceiptLayoutSetName }); }); - it('should call onUpdateLayoutSet when a custom receipt is created by adding a name to the input field', async () => { + it('calls addLayoutSet mutation when layoutSetName for custom receipt is added', async () => { const customReceiptLayoutSetName = 'CustomReceipt'; - const updateLayoutSetMock = jest.fn(); const user = userEvent.setup(); - render({ onUpdateLayoutSet: updateLayoutSetMock }); + const addLayoutSetMock = jest.fn(); + (useBpmnApiContext as jest.Mock).mockReturnValue({ + layoutSets: { sets: [layoutSetWithDataTask] }, + addLayoutSet: addLayoutSetMock, + }); + renderConfigEndEventPanel(); const inputFieldButton = screen.getByTitle( textMock('process_editor.configuration_panel_custom_receipt_add'), ); @@ -38,22 +69,26 @@ describe('ConfigEndEvent', () => { ); await act(() => user.type(inputField, customReceiptLayoutSetName)); await act(() => user.tab()); - expect(updateLayoutSetMock).toHaveBeenCalledTimes(1); - expect(updateLayoutSetMock).toHaveBeenCalledWith(customReceiptLayoutSetName, { - id: customReceiptLayoutSetName, - tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], + expect(addLayoutSetMock).toHaveBeenCalledTimes(1); + expect(addLayoutSetMock).toHaveBeenCalledWith({ + layoutSetIdToUpdate: undefined, + layoutSetConfig: { + id: customReceiptLayoutSetName, + tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], + }, }); }); - it('should call onUpdateLayoutSet when a custom receipt is updated by changing the name in the input field', async () => { - const existingCustomReceiptLayoutSetName = 'CustomReceipt'; - const newCustomReceiptLayoutSetName = 'newCustomReceipt'; - const updateLayoutSetMock = jest.fn(); + it('calls updateLayoutSet mutation when layoutSetName for custom receipt is changed', async () => { + const newCustomReceiptLayoutSetName = 'NewCustomReceipt'; const user = userEvent.setup(); - render({ - existingCustomReceiptName: existingCustomReceiptLayoutSetName, - onUpdateLayoutSet: updateLayoutSetMock, + const updateLayoutSetMock = jest.fn(); + (useBpmnApiContext as jest.Mock).mockReturnValue({ + layoutSets: layoutSetsWithCustomReceipt, + existingCustomReceiptLayoutSetName: existingCustomReceiptLayoutSetName, + mutateLayoutSet: updateLayoutSetMock, }); + renderConfigEndEventPanel(); const inputFieldButton = screen.getByTitle( textMock('process_editor.configuration_panel_custom_receipt_add'), ); @@ -65,24 +100,52 @@ describe('ConfigEndEvent', () => { await act(() => user.type(inputField, newCustomReceiptLayoutSetName)); await act(() => user.tab()); expect(updateLayoutSetMock).toHaveBeenCalledTimes(1); - expect(updateLayoutSetMock).toHaveBeenCalledWith(existingCustomReceiptLayoutSetName, { - id: newCustomReceiptLayoutSetName, - tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], + expect(updateLayoutSetMock).toHaveBeenCalledWith({ + layoutSetIdToUpdate: existingCustomReceiptLayoutSetName, + layoutSetConfig: { + id: newCustomReceiptLayoutSetName, + tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], + }, }); }); + it.each([ + invalidFormatLayoutSetName, + emptyLayoutSetName, + existingLayoutSetName, + existingCustomReceiptLayoutSetName, + ])('shows correct errormessage when layoutSetId is %s', async (invalidLayoutSetId: string) => { + const user = userEvent.setup(); + const updateLayoutSetMock = jest.fn(); + (useBpmnApiContext as jest.Mock).mockReturnValue({ + layoutSets: { sets: [layoutSetWithCustomReceipt, layoutSetWithDataTask] }, + existingCustomReceiptLayoutSetName: existingCustomReceiptLayoutSetName, + mutateLayoutSet: updateLayoutSetMock, + }); + renderConfigEndEventPanel(); + const inputFieldButton = screen.getByTitle( + textMock('process_editor.configuration_panel_custom_receipt_add'), + ); + await act(() => user.click(inputFieldButton)); + const inputField = screen.getByTitle( + textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), + ); + if (invalidLayoutSetId === emptyLayoutSetName) { + await act(() => user.clear(inputField)); + await act(() => user.tab()); + screen.getByText(textMock('validation_errors.required')); + } else { + await act(() => user.clear(inputField)); + await act(() => user.type(inputField, invalidLayoutSetId)); + await act(() => user.tab()); + } + if (invalidLayoutSetId === invalidFormatLayoutSetName) + screen.getByText(textMock('ux_editor.pages_error_format')); + if (invalidLayoutSetId === existingLayoutSetName) + screen.getByText(textMock('process_editor.configuration_panel_layout_set_id_not_unique')); + expect(updateLayoutSetMock).not.toHaveBeenCalled(); + }); }); -const render = ({ - existingCustomReceiptName = undefined, - onUpdateLayoutSet = jest.fn(), -}: { - existingCustomReceiptName?: string | undefined; - onUpdateLayoutSet?: (layoutSetIdToUpdate: string, layoutSetConfig: LayoutSetConfig) => void; -} = {}) => { - return rtlRender( - , - ); +const renderConfigEndEventPanel = () => { + return rtlRender(); }; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx index 8b806d004f6..fc1bb75118b 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx @@ -7,24 +7,26 @@ import { useTranslation } from 'react-i18next'; import classes from './ConfigEndEvent.module.css'; import { PROTECTED_TASK_NAME_CUSTOM_RECEIPT } from 'app-shared/constants'; import { ConfigIcon } from '../ConfigContent/ConfigIcon'; +import { getLayoutSetIdValidationErrorKey } from 'app-shared/utils/layoutSetsUtils'; +import { useBpmnApiContext } from '../../../contexts/BpmnApiContext'; -export interface ConfigEndEventProps { - existingCustomReceiptName: string | undefined; - onUpdateLayoutSet: (layoutSetIdToUpdate: string, layoutSetConfig: LayoutSetConfig) => void; -} - -export const ConfigEndEvent = ({ - existingCustomReceiptName, - onUpdateLayoutSet, -}: ConfigEndEventProps) => { +export const ConfigEndEvent = () => { const { t } = useTranslation(); + const { layoutSets, existingCustomReceiptLayoutSetName, addLayoutSet, mutateLayoutSet } = + useBpmnApiContext(); + const handleUpdateLayoutSet = (layoutSetIdToUpdate: string, customReceiptId: string) => { + if (layoutSetIdToUpdate === customReceiptId || (!layoutSetIdToUpdate && !customReceiptId)) + return; const customReceiptLayoutSetConfig: LayoutSetConfig = { id: customReceiptId, tasks: [PROTECTED_TASK_NAME_CUSTOM_RECEIPT], }; - onUpdateLayoutSet(layoutSetIdToUpdate, customReceiptLayoutSetConfig); + if (!layoutSetIdToUpdate) + addLayoutSet({ layoutSetIdToUpdate, layoutSetConfig: customReceiptLayoutSetConfig }); + else mutateLayoutSet({ layoutSetIdToUpdate, layoutSetConfig: customReceiptLayoutSetConfig }); }; + return ( <>
- {existingCustomReceiptName + {existingCustomReceiptLayoutSetName ? t('process_editor.configuration_panel_custom_receipt_name') : t('process_editor.configuration_panel_custom_receipt_add')} , - value: existingCustomReceiptName, + value: existingCustomReceiptLayoutSetName, onBlur: ({ target }) => - handleUpdateLayoutSet(existingCustomReceiptName ?? target.value, target.value), + handleUpdateLayoutSet(existingCustomReceiptLayoutSetName, target.value), size: 'small', }} + customValidation={(newLayoutSetId: string) => { + const validationResult = getLayoutSetIdValidationErrorKey( + layoutSets, + existingCustomReceiptLayoutSetName, + newLayoutSetId, + ); + return validationResult ? t(validationResult) : undefined; + }} />
diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.test.tsx index da0a29667d4..546c14720b3 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.test.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.test.tsx @@ -9,6 +9,7 @@ import { BpmnTypeEnum } from '../../enum/BpmnTypeEnum'; import { BpmnConfigPanelFormContextProvider } from '../../contexts/BpmnConfigPanelContext'; import type Modeler from 'bpmn-js/lib/Modeler'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { BpmnApiContextProvider } from '../../contexts/BpmnApiContext'; jest.mock('app-shared/utils/featureToggleUtils', () => ({ shouldDisplayFeature: jest.fn().mockReturnValue(false), @@ -69,7 +70,6 @@ describe('ConfigPanel', () => { it('should display the details about the end event when bpmnDetails.type is "EndEvent" and customizeEndEvent feature flag is enabled', () => { (shouldDisplayFeature as jest.Mock).mockReturnValue(true); renderConfigPanel({ bpmnDetails: { ...mockBpmnDetails, type: BpmnTypeEnum.EndEvent } }); - expect( screen.getByText(textMock('process_editor.configuration_panel_end_event')), ).toBeInTheDocument(); @@ -104,9 +104,16 @@ describe('ConfigPanel', () => { const renderConfigPanel = (rootContextProps: Partial = {}) => { return render( - - - + + + + + , ); }; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx index 9f9e2a6d956..f56b5266c1b 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx @@ -5,34 +5,18 @@ import { useBpmnContext } from '../../contexts/BpmnContext'; import { BpmnTypeEnum } from '../../enum/BpmnTypeEnum'; import { ConfigContent } from './ConfigContent'; import { ConfigEndEvent } from './ConfigEndEvent'; -import type { LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; import { ConfigSurface } from '../ConfigSurface/ConfigSurface'; -export interface ConfigPanelProps { - existingCustomReceiptName: string | undefined; - onUpdateLayoutSet: (layoutSetIdToUpdate: string, layoutSetConfig: LayoutSetConfig) => void; -} - -export const ConfigPanel = ({ - existingCustomReceiptName, - onUpdateLayoutSet, -}: ConfigPanelProps): React.ReactElement => { +export const ConfigPanel = (): React.ReactElement => { return ( - + ); }; -type ConfigPanelContentProps = ConfigPanelProps; -const ConfigPanelContent = ({ - existingCustomReceiptName, - onUpdateLayoutSet, -}: ConfigPanelContentProps): React.ReactElement => { +const ConfigPanelContent = (): React.ReactElement => { const { t } = useTranslation(); const { bpmnDetails } = useBpmnContext(); @@ -49,12 +33,7 @@ const ConfigPanelContent = ({ const shouldDisplayEndEventConfig = shouldDisplayFeature('customizeEndEvent') && bpmnDetails.type === BpmnTypeEnum.EndEvent; if (shouldDisplayEndEventConfig) { - return ( - - ); + return ; } const isSupportedConfig = bpmnDetails.type === BpmnTypeEnum.Task; diff --git a/frontend/packages/process-editor/src/contexts/BpmnApiContext.tsx b/frontend/packages/process-editor/src/contexts/BpmnApiContext.tsx new file mode 100644 index 00000000000..89dc0509274 --- /dev/null +++ b/frontend/packages/process-editor/src/contexts/BpmnApiContext.tsx @@ -0,0 +1,53 @@ +import type { LayoutSets, LayoutSetConfig } from 'app-shared/types/api/LayoutSetsResponse'; +import React, { createContext, useContext } from 'react'; + +export type BpmnApiContextProps = { + layoutSets: LayoutSets; + existingCustomReceiptLayoutSetName: string | undefined; + addLayoutSet: (data: { layoutSetIdToUpdate: string; layoutSetConfig: LayoutSetConfig }) => void; + mutateLayoutSet: (data: { + layoutSetIdToUpdate: string; + layoutSetConfig: LayoutSetConfig; + }) => void; +}; + +export const BpmnApiContext = createContext(undefined); + +export type BpmnApiContextProviderProps = { + children: React.ReactNode; + layoutSets: LayoutSets; + existingCustomReceiptLayoutSetName: string | undefined; + addLayoutSet: (data: { layoutSetIdToUpdate: string; layoutSetConfig: LayoutSetConfig }) => void; + mutateLayoutSet: (data: { + layoutSetIdToUpdate: string; + layoutSetConfig: LayoutSetConfig; + }) => void; +}; +export const BpmnApiContextProvider = ({ + children, + layoutSets, + existingCustomReceiptLayoutSetName, + addLayoutSet, + mutateLayoutSet, +}: BpmnApiContextProviderProps) => { + return ( + + {children} + + ); +}; + +export const useBpmnApiContext = (): BpmnApiContextProps => { + const context = useContext(BpmnApiContext); + if (context === undefined) { + throw new Error('useBpmnApiContext must be used within a BpmnApiContextProvider'); + } + return context; +}; diff --git a/frontend/packages/ux-editor/src/hooks/queries/useLayoutSetsQuery.ts b/frontend/packages/shared/src/hooks/queries/useLayoutSetsQuery.ts similarity index 100% rename from frontend/packages/ux-editor/src/hooks/queries/useLayoutSetsQuery.ts rename to frontend/packages/shared/src/hooks/queries/useLayoutSetsQuery.ts diff --git a/frontend/packages/shared/src/hooks/useCustomReceiptLayoutSetName.ts b/frontend/packages/shared/src/hooks/useCustomReceiptLayoutSetName.ts index c097096b25b..98ec4d029a5 100644 --- a/frontend/packages/shared/src/hooks/useCustomReceiptLayoutSetName.ts +++ b/frontend/packages/shared/src/hooks/useCustomReceiptLayoutSetName.ts @@ -1,4 +1,4 @@ -import { useLayoutSetsQuery } from '../../../ux-editor/src/hooks/queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { getLayoutSetNameForCustomReceipt } from 'app-shared/utils/layoutSetsUtils'; export const useCustomReceiptLayoutSetName = (org: string, app: string): string | undefined => { diff --git a/frontend/packages/ux-editor/src/utils/validationUtils/validateLayoutNameAndLayoutSetName.test.ts b/frontend/packages/shared/src/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName.test.ts similarity index 100% rename from frontend/packages/ux-editor/src/utils/validationUtils/validateLayoutNameAndLayoutSetName.test.ts rename to frontend/packages/shared/src/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName.test.ts diff --git a/frontend/packages/ux-editor/src/utils/validationUtils/validateLayoutNameAndLayoutSetName.ts b/frontend/packages/shared/src/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName.ts similarity index 100% rename from frontend/packages/ux-editor/src/utils/validationUtils/validateLayoutNameAndLayoutSetName.ts rename to frontend/packages/shared/src/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName.ts diff --git a/frontend/packages/shared/src/utils/layoutSetsUtils.ts b/frontend/packages/shared/src/utils/layoutSetsUtils.ts index f36973f2e05..cda4afe59d7 100644 --- a/frontend/packages/shared/src/utils/layoutSetsUtils.ts +++ b/frontend/packages/shared/src/utils/layoutSetsUtils.ts @@ -1,7 +1,21 @@ import { PROTECTED_TASK_NAME_CUSTOM_RECEIPT } from 'app-shared/constants'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import { validateLayoutNameAndLayoutSetName } from 'app-shared/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName'; export const getLayoutSetNameForCustomReceipt = (layoutSets: LayoutSets): string | undefined => { return layoutSets?.sets?.find((set) => set.tasks.includes(PROTECTED_TASK_NAME_CUSTOM_RECEIPT)) ?.id; }; + +export const getLayoutSetIdValidationErrorKey = ( + layoutSets: LayoutSets, + oldLayoutSetId: string, + newLayoutSetId: string, +): string => { + if (oldLayoutSetId === newLayoutSetId) return null; + if (!validateLayoutNameAndLayoutSetName(newLayoutSetId)) return 'ux_editor.pages_error_format'; + if (!newLayoutSetId) return 'validation_errors.required'; + if (layoutSets.sets.some((set) => set.id === newLayoutSetId)) + return 'process_editor.configuration_panel_layout_set_id_not_unique'; + return null; +}; diff --git a/frontend/packages/ux-editor-v3/src/App.tsx b/frontend/packages/ux-editor-v3/src/App.tsx index d0aa1a95477..11a0ebd7e72 100644 --- a/frontend/packages/ux-editor-v3/src/App.tsx +++ b/frontend/packages/ux-editor-v3/src/App.tsx @@ -8,7 +8,7 @@ import { useDatamodelMetadataQuery } from './hooks/queries/useDatamodelMetadataQ import { selectedLayoutNameSelector } from './selectors/formLayoutSelectors'; import { useWidgetsQuery } from './hooks/queries/useWidgetsQuery'; import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery'; -import { useLayoutSetsQuery } from './hooks/queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { useAppContext } from './hooks/useAppContext'; import { FormItemContextProvider } from './containers/FormItemContext'; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx index 1d9c87ce445..3c8adc8a9f3 100644 --- a/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx +++ b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx @@ -6,7 +6,7 @@ import { Heading, Paragraph } from '@digdir/design-system-react'; import { useText } from '../../hooks'; import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors'; import { useFormLayoutSettingsQuery } from '../../hooks/queries/useFormLayoutSettingsQuery'; -import { useLayoutSetsQuery } from '../../hooks/queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { LayoutSetsContainer } from './LayoutSetsContainer'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx index 9883442b1a4..612538c2200 100644 --- a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx +++ b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useLayoutSetsQuery } from '../../hooks/queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { NativeSelect } from '@digdir/design-system-react'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { useText } from '../../hooks'; diff --git a/frontend/packages/ux-editor-v3/src/hooks/mutations/useAddItemToLayoutMutation.ts b/frontend/packages/ux-editor-v3/src/hooks/mutations/useAddItemToLayoutMutation.ts index dbfabdca486..aea03b6572f 100644 --- a/frontend/packages/ux-editor-v3/src/hooks/mutations/useAddItemToLayoutMutation.ts +++ b/frontend/packages/ux-editor-v3/src/hooks/mutations/useAddItemToLayoutMutation.ts @@ -5,7 +5,7 @@ import { useFormLayoutMutation } from './useFormLayoutMutation'; import { useAddAppAttachmentMetadataMutation } from './useAddAppAttachmentMetadataMutation'; import type { FormFileUploaderComponent } from '../../types/FormComponent'; import { addItemOfType } from '../../utils/formLayoutUtils'; -import { useLayoutSetsQuery } from '../queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { TASKID_FOR_STATELESS_APPS } from 'app-shared/constants'; export interface AddFormItemMutationArgs { diff --git a/frontend/packages/ux-editor-v3/src/hooks/mutations/useUpdateFormComponentMutation.ts b/frontend/packages/ux-editor-v3/src/hooks/mutations/useUpdateFormComponentMutation.ts index bbd34f82527..e5446e52793 100644 --- a/frontend/packages/ux-editor-v3/src/hooks/mutations/useUpdateFormComponentMutation.ts +++ b/frontend/packages/ux-editor-v3/src/hooks/mutations/useUpdateFormComponentMutation.ts @@ -11,7 +11,7 @@ import { useFormLayout } from '../useFormLayoutsSelector'; import { ObjectUtils } from '@studio/pure-functions'; import { useFormLayoutMutation } from './useFormLayoutMutation'; import type { FormComponent, FormFileUploaderComponent } from '../../types/FormComponent'; -import { useLayoutSetsQuery } from '../queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { TASKID_FOR_STATELESS_APPS } from 'app-shared/constants'; export interface UpdateFormComponentMutationArgs { diff --git a/frontend/packages/ux-editor-v3/src/utils/designViewUtils/designViewUtils.ts b/frontend/packages/ux-editor-v3/src/utils/designViewUtils/designViewUtils.ts index b141eeac677..704aedda342 100644 --- a/frontend/packages/ux-editor-v3/src/utils/designViewUtils/designViewUtils.ts +++ b/frontend/packages/ux-editor-v3/src/utils/designViewUtils/designViewUtils.ts @@ -1,5 +1,5 @@ import type { TranslationKey } from 'language/type'; -import { validateLayoutNameAndLayoutSetName } from '../../utils/validationUtils/validateLayoutNameAndLayoutSetName'; +import { validateLayoutNameAndLayoutSetName } from 'app-shared/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName'; /** * Checks if the new written page name already exists diff --git a/frontend/packages/ux-editor/src/App.tsx b/frontend/packages/ux-editor/src/App.tsx index d4154033d21..09cb29824e5 100644 --- a/frontend/packages/ux-editor/src/App.tsx +++ b/frontend/packages/ux-editor/src/App.tsx @@ -8,7 +8,7 @@ import { useDatamodelMetadataQuery } from './hooks/queries/useDatamodelMetadataQ import { selectedLayoutNameSelector } from './selectors/formLayoutSelectors'; import { useWidgetsQuery } from './hooks/queries/useWidgetsQuery'; import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery'; -import { useLayoutSetsQuery } from './hooks/queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { useAppContext } from './hooks/useAppContext'; import { FormItemContextProvider } from './containers/FormItemContext'; diff --git a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx index a4d5b67bffd..20bf8ef5343 100644 --- a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useLayoutSetsQuery } from '../../hooks/queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { NativeSelect } from '@digdir/design-system-react'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { useText } from '../../hooks'; diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useAddItemToLayoutMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useAddItemToLayoutMutation.ts index 6273e55f3ca..2d45e67ca6c 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useAddItemToLayoutMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useAddItemToLayoutMutation.ts @@ -5,7 +5,7 @@ import { useFormLayoutMutation } from './useFormLayoutMutation'; import { useAddAppAttachmentMetadataMutation } from './useAddAppAttachmentMetadataMutation'; import type { FormFileUploaderComponent } from '../../types/FormComponent'; import { addItemOfType } from '../../utils/formLayoutUtils'; -import { useLayoutSetsQuery } from '../queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { TASKID_FOR_STATELESS_APPS } from 'app-shared/constants'; export interface AddFormItemMutationArgs { diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useUpdateFormComponentMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useUpdateFormComponentMutation.ts index 5eab4591352..d9a62e26c2c 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useUpdateFormComponentMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useUpdateFormComponentMutation.ts @@ -11,7 +11,7 @@ import { useFormLayout } from '../useFormLayoutsSelector'; import { ObjectUtils } from '@studio/pure-functions'; import { useFormLayoutMutation } from './useFormLayoutMutation'; import type { FormComponent, FormFileUploaderComponent } from '../../types/FormComponent'; -import { useLayoutSetsQuery } from '../queries/useLayoutSetsQuery'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { TASKID_FOR_STATELESS_APPS } from 'app-shared/constants'; export interface UpdateFormComponentMutationArgs { diff --git a/frontend/packages/ux-editor/src/utils/designViewUtils/designViewUtils.ts b/frontend/packages/ux-editor/src/utils/designViewUtils/designViewUtils.ts index b141eeac677..704aedda342 100644 --- a/frontend/packages/ux-editor/src/utils/designViewUtils/designViewUtils.ts +++ b/frontend/packages/ux-editor/src/utils/designViewUtils/designViewUtils.ts @@ -1,5 +1,5 @@ import type { TranslationKey } from 'language/type'; -import { validateLayoutNameAndLayoutSetName } from '../../utils/validationUtils/validateLayoutNameAndLayoutSetName'; +import { validateLayoutNameAndLayoutSetName } from 'app-shared/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName'; /** * Checks if the new written page name already exists From 5edfa068467ef891b2fe70311e6b8f91542dbe51 Mon Sep 17 00:00:00 2001 From: andreastanderen Date: Wed, 27 Mar 2024 12:26:16 +0100 Subject: [PATCH 3/3] Fix bouncing input field when error message is visible --- .../ConfigEndEvent/ConfigEndEvent.test.tsx | 36 +++++++++---------- .../ConfigEndEvent/ConfigEndEvent.tsx | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx index baba579f8bd..4dd4a18577d 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.test.tsx @@ -60,13 +60,13 @@ describe('ConfigEndEvent', () => { addLayoutSet: addLayoutSetMock, }); renderConfigEndEventPanel(); - const inputFieldButton = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add'), - ); + const inputFieldButton = screen.getByRole('button', { + name: textMock('process_editor.configuration_panel_custom_receipt_add'), + }); await act(() => user.click(inputFieldButton)); - const inputField = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), - ); + const inputField = screen.getByRole('textbox', { + name: textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), + }); await act(() => user.type(inputField, customReceiptLayoutSetName)); await act(() => user.tab()); expect(addLayoutSetMock).toHaveBeenCalledTimes(1); @@ -89,13 +89,13 @@ describe('ConfigEndEvent', () => { mutateLayoutSet: updateLayoutSetMock, }); renderConfigEndEventPanel(); - const inputFieldButton = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add'), - ); + const inputFieldButton = screen.getByRole('button', { + name: existingCustomReceiptLayoutSetName, + }); await act(() => user.click(inputFieldButton)); - const inputField = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), - ); + const inputField = screen.getByRole('textbox', { + name: textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), + }); await act(() => user.clear(inputField)); await act(() => user.type(inputField, newCustomReceiptLayoutSetName)); await act(() => user.tab()); @@ -122,13 +122,13 @@ describe('ConfigEndEvent', () => { mutateLayoutSet: updateLayoutSetMock, }); renderConfigEndEventPanel(); - const inputFieldButton = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add'), - ); + const inputFieldButton = screen.getByRole('button', { + name: existingCustomReceiptLayoutSetName, + }); await act(() => user.click(inputFieldButton)); - const inputField = screen.getByTitle( - textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), - ); + const inputField = screen.getByRole('textbox', { + name: textMock('process_editor.configuration_panel_custom_receipt_add_button_title'), + }); if (invalidLayoutSetId === emptyLayoutSetName) { await act(() => user.clear(inputField)); await act(() => user.tab()); diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx index fc1bb75118b..0186c3ef32f 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigEndEvent/ConfigEndEvent.tsx @@ -55,7 +55,7 @@ export const ConfigEndEvent = () => { fullWidth: true, }} inputProps={{ - title: t('process_editor.configuration_panel_custom_receipt_add_button_title'), + label: t('process_editor.configuration_panel_custom_receipt_add_button_title'), icon: , value: existingCustomReceiptLayoutSetName, onBlur: ({ target }) =>