From b0b879e4b7df848858447899c266dd4c06cf06b8 Mon Sep 17 00:00:00 2001 From: andreastanderen <71079896+standeren@users.noreply.github.com> Date: Tue, 12 Dec 2023 09:17:57 +0100 Subject: [PATCH 01/21] Compile csharp datamodel before saving (#11785) * Compile csharp datamodel before saving * Add ref to datammodelling.csproj in datamodelling.Tests.csproj * Use custom filter exception for csharp compilation error * Add customErrorMessages to problemDetails for csharpCompiler * List errors from compilation in toast if there are any * Make minimumValidJsonSchema used in datamodel tests valid * Add tests * Add test for serviceContext * List custom error messages from schema generation in a custom component * Add frontend tests for new schemaErrorsPanel component * Add direct nuget packages references * Add test for triggering schema errors panel in datamodel component * Use compiler inside converter * Fix PR comments --- .../Converter/Csharp}/Compiler.cs | 24 ++-- .../Csharp/CsharpCompilationException.cs | 18 +++ .../Csharp/JsonMetadataToCsharpConverter.cs | 8 +- backend/src/DataModeling/DataModeling.csproj | 3 + backend/src/Designer/Designer.csproj | 2 + .../DataModelingExceptionFilterAttribute.cs | 5 + .../Designer/Filters/ProblemDetailsUtils.cs | 10 +- .../Implementation/AppDevelopmentService.cs | 1 - .../Implementation/SchemaModelService.cs | 4 +- .../Services/Models/EnvironmentsModel.cs | 2 +- .../Assertions/TypeAssertions.cs | 1 + .../CsharpEnd2EndGenerationTests.cs | 1 + .../DataModeling.Tests.csproj | 1 + .../DataModelsController/PutDatamodelTests.cs | 35 +++++- .../Designer.Tests/Designer.Tests.csproj | 1 - .../Services/SchemaModelServiceTests.cs | 36 +++++- .../Designer.Tests/Utils/TestDataHelper.cs | 4 +- .../SharedResources.Tests.csproj | 2 - .../dataModelling/DataModelling.test.tsx | 119 ++++++++++++++---- .../SchemaEditorWithToolbar.tsx | 12 +- .../SchemaGenerationErrorsPanel.module.css | 5 + .../SchemaGenerationErrorsPanel.test.tsx | 82 ++++++++++++ .../SchemaGenerationErrorsPanel.tsx | 63 ++++++++++ .../TopToolbar/GenerateModelsButton.tsx | 17 ++- .../TopToolbar/TopToolbar.test.tsx | 16 ++- .../TopToolbar/TopToolbar.tsx | 17 ++- .../mutations/useGenerateModelsMutation.ts | 7 +- frontend/language/src/nb.json | 2 + .../src/contexts/ServicesContext.test.tsx | 32 +++-- .../packages/shared/src/mocks/apiErrorMock.ts | 8 +- .../packages/shared/src/types/api/ApiError.ts | 3 +- 31 files changed, 451 insertions(+), 90 deletions(-) rename backend/{tests/SharedResources.Tests => src/DataModeling/Converter/Csharp}/Compiler.cs (67%) create mode 100644 backend/src/DataModeling/Converter/Csharp/CsharpCompilationException.cs create mode 100644 frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.module.css create mode 100644 frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.test.tsx create mode 100644 frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.tsx diff --git a/backend/tests/SharedResources.Tests/Compiler.cs b/backend/src/DataModeling/Converter/Csharp/Compiler.cs similarity index 67% rename from backend/tests/SharedResources.Tests/Compiler.cs rename to backend/src/DataModeling/Converter/Csharp/Compiler.cs index f316935aabb..888945a1376 100644 --- a/backend/tests/SharedResources.Tests/Compiler.cs +++ b/backend/src/DataModeling/Converter/Csharp/Compiler.cs @@ -3,17 +3,22 @@ using System.IO; using System.Linq; using System.Reflection; -using System.Text; using Basic.Reference.Assemblies; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Emit; using Microsoft.CodeAnalysis.Text; -namespace SharedResources.Tests +namespace Altinn.Studio.DataModeling.Converter.Csharp { public static class Compiler { + /// + /// Try to compile csharp class from generated csharp code as string + /// + /// Csharp code as string + /// Throws a custom compiler exception with corresponding diagnostics if compilation fails + /// The corresponding assembly public static Assembly CompileToAssembly(string csharpCode) { var syntaxTree = SyntaxFactory.ParseSyntaxTree(SourceText.From(csharpCode)); @@ -25,7 +30,7 @@ public static Assembly CompileToAssembly(string csharpCode) .AddReferences(MetadataReference.CreateFromFile(typeof(Newtonsoft.Json.JsonPropertyAttribute).GetTypeInfo().Assembly.Location)) .AddSyntaxTrees(syntaxTree); - Assembly assembly = null; + Assembly assembly; using (var ms = new MemoryStream()) { EmitResult result = compilation.Emit(ms); @@ -36,19 +41,16 @@ public static Assembly CompileToAssembly(string csharpCode) diagnostic.IsWarningAsError || diagnostic.Severity == DiagnosticSeverity.Error); - var errors = new StringBuilder(); + List customErrorMessages = new(); foreach (Diagnostic diagnostic in failures) { - errors.AppendLine($"{diagnostic.Id}: {diagnostic.GetMessage()}"); + customErrorMessages.Add(diagnostic.GetMessage()); } - throw new Exception($"Uh dude, you seem to have provoked some compilation errors with your code change. Please fix before merging! {errors}"); - } - else - { - ms.Seek(0, SeekOrigin.Begin); - assembly = Assembly.Load(ms.ToArray()); + throw new CsharpCompilationException("Csharp compilation failed.", customErrorMessages); } + ms.Seek(0, SeekOrigin.Begin); + assembly = Assembly.Load(ms.ToArray()); } return assembly; diff --git a/backend/src/DataModeling/Converter/Csharp/CsharpCompilationException.cs b/backend/src/DataModeling/Converter/Csharp/CsharpCompilationException.cs new file mode 100644 index 00000000000..905ee46ba69 --- /dev/null +++ b/backend/src/DataModeling/Converter/Csharp/CsharpCompilationException.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using System.Runtime.Serialization; + +namespace Altinn.Studio.DataModeling.Converter.Csharp; + +public class CsharpCompilationException : Exception +{ + + public List CustomErrorMessages { get; } + + /// + public CsharpCompilationException(string message, List customErrorMessages) : base(message) + { + CustomErrorMessages = customErrorMessages; + } + +} diff --git a/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs b/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs index adcd073adfc..4dad1d1a1b0 100644 --- a/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs +++ b/backend/src/DataModeling/Converter/Csharp/JsonMetadataToCsharpConverter.cs @@ -28,7 +28,7 @@ public JsonMetadataToCsharpConverter(CSharpGenerationSettings generationSettings /// The model code in C# public string CreateModelFromMetadata(ModelMetadata serviceMetadata) { - Dictionary classes = new Dictionary(); + Dictionary classes = new(); CreateModelFromMetadataRecursive(classes, serviceMetadata.Elements.Values.First(el => el.ParentElement == null), serviceMetadata, serviceMetadata.TargetNamespace); @@ -47,7 +47,11 @@ public string CreateModelFromMetadata(ModelMetadata serviceMetadata) .Append(string.Concat(classes.Values)) .AppendLine("}"); - return writer.ToString(); + string cSharpClasses = writer.ToString(); + + Compiler.CompileToAssembly(cSharpClasses); + + return cSharpClasses; } /// diff --git a/backend/src/DataModeling/DataModeling.csproj b/backend/src/DataModeling/DataModeling.csproj index 80edb2d0c7b..fd6cd4744be 100644 --- a/backend/src/DataModeling/DataModeling.csproj +++ b/backend/src/DataModeling/DataModeling.csproj @@ -25,8 +25,11 @@ + + + diff --git a/backend/src/Designer/Designer.csproj b/backend/src/Designer/Designer.csproj index 1f61df57f6e..4c85cb35c15 100644 --- a/backend/src/Designer/Designer.csproj +++ b/backend/src/Designer/Designer.csproj @@ -37,6 +37,8 @@ + + diff --git a/backend/src/Designer/Filters/DataModeling/DataModelingExceptionFilterAttribute.cs b/backend/src/Designer/Filters/DataModeling/DataModelingExceptionFilterAttribute.cs index b8ba7f67a8f..afa64c98270 100644 --- a/backend/src/Designer/Filters/DataModeling/DataModelingExceptionFilterAttribute.cs +++ b/backend/src/Designer/Filters/DataModeling/DataModelingExceptionFilterAttribute.cs @@ -20,6 +20,11 @@ public override void OnException(ExceptionContext context) return; } + if (context.Exception is CsharpCompilationException compilationException) + { + context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, DataModelingErrorCodes.CsharpGenerationError, HttpStatusCode.BadRequest, compilationException.CustomErrorMessages)) { StatusCode = (int)HttpStatusCode.BadRequest }; + } + if (context.Exception is CsharpGenerationException) { context.Result = new ObjectResult(ProblemDetailsUtils.GenerateProblemDetails(context.Exception, DataModelingErrorCodes.CsharpGenerationError, HttpStatusCode.InternalServerError)) { StatusCode = (int)HttpStatusCode.InternalServerError }; diff --git a/backend/src/Designer/Filters/ProblemDetailsUtils.cs b/backend/src/Designer/Filters/ProblemDetailsUtils.cs index d9af84caab8..e7ec36243ef 100644 --- a/backend/src/Designer/Filters/ProblemDetailsUtils.cs +++ b/backend/src/Designer/Filters/ProblemDetailsUtils.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Net; using Microsoft.AspNetCore.Mvc; @@ -6,10 +7,10 @@ namespace Altinn.Studio.Designer.Filters { public static class ProblemDetailsUtils { - public static ProblemDetails GenerateProblemDetails(Exception ex, string customErrorCode, HttpStatusCode statusCode) + public static ProblemDetails GenerateProblemDetails(Exception ex, string customErrorCode, HttpStatusCode statusCode, List customErrorMessages = null) { string exceptionType = ex.GetType().Name; - ProblemDetails details = new ProblemDetails + ProblemDetails details = new() { Title = $"{exceptionType} occured.", Detail = ex.Message, @@ -17,6 +18,11 @@ public static ProblemDetails GenerateProblemDetails(Exception ex, string customE Type = exceptionType }; details.Extensions.Add("errorCode", customErrorCode); + + if (customErrorMessages is not null) + { + details.Extensions.Add("customErrorMessages", customErrorMessages); + } return details; } } diff --git a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs index e7c50f611c5..1c73cc50258 100644 --- a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs +++ b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Data; using System.IO; using System.Text.Json.Nodes; using System.Threading; diff --git a/backend/src/Designer/Services/Implementation/SchemaModelService.cs b/backend/src/Designer/Services/Implementation/SchemaModelService.cs index 4ca30bfbf3b..05bf244fed8 100644 --- a/backend/src/Designer/Services/Implementation/SchemaModelService.cs +++ b/backend/src/Designer/Services/Implementation/SchemaModelService.cs @@ -285,8 +285,8 @@ private Json.Schema.JsonSchema GenerateJsonSchemaFromXsd(Stream xsdStream) private async Task UpdateCSharpClasses(AltinnAppGitRepository altinnAppGitRepository, ModelMetadata modelMetadata, string schemaName) { - string classes = _modelMetadataToCsharpConverter.CreateModelFromMetadata(modelMetadata); - await altinnAppGitRepository.SaveCSharpClasses(classes, schemaName); + string csharpClasses = _modelMetadataToCsharpConverter.CreateModelFromMetadata(modelMetadata); + await altinnAppGitRepository.SaveCSharpClasses(csharpClasses, schemaName); } private static async Task UpdateApplicationMetadata(AltinnAppGitRepository altinnAppGitRepository, string schemaName, string typeName) diff --git a/backend/src/Designer/Services/Models/EnvironmentsModel.cs b/backend/src/Designer/Services/Models/EnvironmentsModel.cs index 9fd5ae5c876..c91ddd360c4 100644 --- a/backend/src/Designer/Services/Models/EnvironmentsModel.cs +++ b/backend/src/Designer/Services/Models/EnvironmentsModel.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; using System.Text.Json.Serialization; -using Microsoft.Build.Framework; namespace Altinn.Studio.Designer.Services.Models; diff --git a/backend/tests/DataModeling.Tests/Assertions/TypeAssertions.cs b/backend/tests/DataModeling.Tests/Assertions/TypeAssertions.cs index ec5fabd587e..e9a0da31ece 100644 --- a/backend/tests/DataModeling.Tests/Assertions/TypeAssertions.cs +++ b/backend/tests/DataModeling.Tests/Assertions/TypeAssertions.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; +using Altinn.Studio.DataModeling.Converter.Csharp; using FluentAssertions; using SharedResources.Tests; using Xunit; diff --git a/backend/tests/DataModeling.Tests/CsharpEnd2EndGenerationTests.cs b/backend/tests/DataModeling.Tests/CsharpEnd2EndGenerationTests.cs index 22d12c319b7..692ea8f6323 100644 --- a/backend/tests/DataModeling.Tests/CsharpEnd2EndGenerationTests.cs +++ b/backend/tests/DataModeling.Tests/CsharpEnd2EndGenerationTests.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Xml.Serialization; +using Altinn.Studio.DataModeling.Converter.Csharp; using DataModeling.Tests.Assertions; using DataModeling.Tests.BaseClasses; using DataModeling.Tests.TestDataClasses; diff --git a/backend/tests/DataModeling.Tests/DataModeling.Tests.csproj b/backend/tests/DataModeling.Tests/DataModeling.Tests.csproj index aed3ab77ea9..8978acf833a 100644 --- a/backend/tests/DataModeling.Tests/DataModeling.Tests.csproj +++ b/backend/tests/DataModeling.Tests/DataModeling.Tests.csproj @@ -6,6 +6,7 @@ + diff --git a/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs b/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs index 6c596c12db1..bd6924da9b1 100644 --- a/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs +++ b/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs @@ -16,8 +16,6 @@ using Altinn.Studio.DataModeling.Converter.Metadata; using Altinn.Studio.DataModeling.Json; using Altinn.Studio.DataModeling.Validator.Json; -using Altinn.Studio.Designer.Controllers; -using Altinn.Studio.Designer.Filters.DataModeling; using Designer.Tests.Controllers.ApiTests; using Designer.Tests.Controllers.DataModelsController.Utils; using Designer.Tests.Utils; @@ -36,7 +34,9 @@ public class PutDatamodelTests : DisagnerEndpointsTestsBase, private static string VersionPrefix(string org, string repository) => $"/designer/api/{org}/{repository}/datamodels"; private string TargetTestRepository { get; } - private const string MinimumValidJsonSchema = "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"schema.json\",\"type\":\"object\",\"properties\":{\"root\":{\"$ref\":\"#/$defs/rootType\"}},\"$defs\":{\"rootType\":{\"properties\":{\"keyword\":{\"type\":\"string\"}}}}}"; + private const string MinimumValidJsonSchema = "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"schema.json\",\"type\":\"object\",\"properties\":{\"rootType\":{\"$ref\":\"#/$defs/rootType\"}},\"$defs\":{\"rootType\":{\"properties\":{\"keyword\":{\"type\":\"string\"}}}}}"; + + private const string JsonSchemaThatWillNotCompile = "{\"$schema\":\"https://json-schema.org/draft/2020-12/schema\",\"$id\":\"schema.json\",\"type\":\"object\",\"properties\":{\"root\":{\"$ref\":\"#/$defs/rootType\"}},\"$defs\":{\"rootType\":{\"properties\":{\"keyword\":{\"type\":\"string\"}}}}}"; public PutDatamodelTests(WebApplicationFactory factory) : base(factory) { @@ -66,6 +66,35 @@ public async Task ValidInput_ShouldReturn_NoContent_And_Create_Files(string mode await FilesWithCorrectNameAndContentShouldBeCreated(modelName); } + [Theory] + [InlineData("testModel.schema.json", "ttd", "hvem-er-hvem", "testUser")] + public async Task InvalidInput_ShouldReturn_BadRequest_And_CustomErrorMessages(string modelPath, string org, string repo, string user) + { + string url = $"{VersionPrefix(org, TargetTestRepository)}/datamodel?modelPath={modelPath}"; + + await CopyRepositoryForTest(org, repo, user, TargetTestRepository); + + using var request = new HttpRequestMessage(HttpMethod.Put, url) + { + Content = new StringContent(JsonSchemaThatWillNotCompile, Encoding.UTF8, MediaTypeNames.Application.Json) + }; + + var response = await HttpClient.SendAsync(request); + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + var problemDetailsJson = await response.Content.ReadAsStringAsync(); + var problemDetails = JsonSerializer.Deserialize(problemDetailsJson); + + problemDetails.Should().NotBeNull(); + problemDetails.Extensions.Should().ContainKey("customErrorMessages"); + + var customErrorMessages = problemDetails.Extensions["customErrorMessages"]; + customErrorMessages.Should().NotBeNull(); + var customErrorMessagesElement = (JsonElement)customErrorMessages; + var firstErrorMessage = customErrorMessagesElement.EnumerateArray().FirstOrDefault().GetString(); + firstErrorMessage.Should().Be("'root': member names cannot be the same as their enclosing type"); + } + [Theory] [InlineData("testModel.schema.json", "ttd", "hvem-er-hvem", "testUser", "Model/JsonSchema/General/NonXsdContextSchema.json")] public async Task ValidSchema_ShouldReturn_NoContent_And_Create_Files(string modelPath, string org, string repo, string user, string schemaPath) diff --git a/backend/tests/Designer.Tests/Designer.Tests.csproj b/backend/tests/Designer.Tests/Designer.Tests.csproj index ac46b6af8fb..97b59ad8229 100644 --- a/backend/tests/Designer.Tests/Designer.Tests.csproj +++ b/backend/tests/Designer.Tests/Designer.Tests.csproj @@ -24,7 +24,6 @@ - all diff --git a/backend/tests/Designer.Tests/Services/SchemaModelServiceTests.cs b/backend/tests/Designer.Tests/Services/SchemaModelServiceTests.cs index 29ad8588ce6..694a5b66738 100644 --- a/backend/tests/Designer.Tests/Services/SchemaModelServiceTests.cs +++ b/backend/tests/Designer.Tests/Services/SchemaModelServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text.Encodings.Web; using System.Text.Json; @@ -6,6 +7,7 @@ using System.Threading.Tasks; using System.Xml.Linq; using System.Xml.Schema; +using Altinn.Studio.DataModeling.Converter.Csharp; using Altinn.Studio.DataModeling.Json.Keywords; using Altinn.Studio.Designer.Factories; using Altinn.Studio.Designer.Models; @@ -109,8 +111,8 @@ public async Task UpdateSchema_AppRepo_ShouldUpdate() // Act ISchemaModelService schemaModelService = new SchemaModelService(altinnGitRepositoryFactory, TestDataHelper.LogFactory, TestDataHelper.ServiceRepositorySettings, TestDataHelper.XmlSchemaToJsonSchemaConverter, TestDataHelper.JsonSchemaToXmlSchemaConverter, TestDataHelper.ModelMetadataToCsharpConverter); - var expectedSchemaUpdates = @"{""properties"":{""root"":{""$ref"":""#/definitions/rootType""}},""definitions"":{""rootType"":{""properties"":{""keyword"":{""type"":""string""}}}}}"; - await schemaModelService.UpdateSchema(editingContext, $"App/models/HvemErHvem_SERES.schema.json", expectedSchemaUpdates); + var expectedSchemaUpdates = @"{""properties"":{""rootType1"":{""$ref"":""#/definitions/rootType""}},""definitions"":{""rootType"":{""properties"":{""keyword"":{""type"":""string""}}}}}"; + await schemaModelService.UpdateSchema(editingContext, "App/models/HvemErHvem_SERES.schema.json", expectedSchemaUpdates); // Assert var altinnGitRepository = altinnGitRepositoryFactory.GetAltinnGitRepository(org, targetRepository, developer); @@ -144,6 +146,36 @@ public async Task UpdateSchema_AppRepo_ShouldUpdate() } } + [Fact] + public async Task UpdateSchema_InvalidJsonSchema_ShouldThrowException() + { + // Arrange + var org = "ttd"; + var sourceRepository = "hvem-er-hvem"; + var developer = "testUser"; + var targetRepository = TestDataHelper.GenerateTestRepoName(); + var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, targetRepository, developer); + + await TestDataHelper.CopyRepositoryForTest(org, sourceRepository, developer, targetRepository); + + var altinnGitRepositoryFactory = new AltinnGitRepositoryFactory(TestDataHelper.GetTestDataRepositoriesRootDirectory()); + + ISchemaModelService schemaModelService = new SchemaModelService(altinnGitRepositoryFactory, + TestDataHelper.LogFactory, TestDataHelper.ServiceRepositorySettings, + TestDataHelper.XmlSchemaToJsonSchemaConverter, TestDataHelper.JsonSchemaToXmlSchemaConverter, + TestDataHelper.ModelMetadataToCsharpConverter); + var invalidSchema = + @"{""properties"":{""root"":{""$ref"":""#/definitions/rootType""}},""definitions"":{""rootType"":{""properties"":{""keyword"":{""type"":""string""}}}}}"; + + var exception = await Assert.ThrowsAsync(async () => + { + await schemaModelService.UpdateSchema(editingContext, "App/models/HvemErHvem_SERES.schema.json", invalidSchema); + }); + + Assert.NotNull(exception.CustomErrorMessages); + Assert.Equal(new List() { "'root': member names cannot be the same as their enclosing type" }, exception.CustomErrorMessages); + } + [Theory] [InlineData("ttd", "apprepo", "test", "", "http://studio.localhost/repos")] [InlineData("ttd", "apprepo", "test", "/path/to/folder/", "http://studio.localhost/repos")] diff --git a/backend/tests/Designer.Tests/Utils/TestDataHelper.cs b/backend/tests/Designer.Tests/Utils/TestDataHelper.cs index f190a21fdff..4fe0f07324d 100644 --- a/backend/tests/Designer.Tests/Utils/TestDataHelper.cs +++ b/backend/tests/Designer.Tests/Utils/TestDataHelper.cs @@ -151,10 +151,10 @@ public static string GenerateTestRepoName(string suffix = null, int length = 28) return suffix == null ? nonSuffixName : $"{nonSuffixName[..^suffix.Length]}{suffix}"; } - public static async Task CopyRepositoryForTest(string org, string repository, string developer, string targetRepsository) + public static async Task CopyRepositoryForTest(string org, string repository, string developer, string targetRepository) { var sourceAppRepository = GetTestDataRepositoryDirectory(org, repository, developer); - var targetDirectory = Path.Combine(GetTestDataRepositoriesRootDirectory(), developer, org, targetRepsository); + var targetDirectory = Path.Combine(GetTestDataRepositoriesRootDirectory(), developer, org, targetRepository); await CopyDirectory(sourceAppRepository, targetDirectory); diff --git a/backend/tests/SharedResources.Tests/SharedResources.Tests.csproj b/backend/tests/SharedResources.Tests/SharedResources.Tests.csproj index d577bf39e30..a2f703de4be 100644 --- a/backend/tests/SharedResources.Tests/SharedResources.Tests.csproj +++ b/backend/tests/SharedResources.Tests/SharedResources.Tests.csproj @@ -36,10 +36,8 @@ - - diff --git a/frontend/app-development/features/dataModelling/DataModelling.test.tsx b/frontend/app-development/features/dataModelling/DataModelling.test.tsx index 5a27774b19b..2086fa96c92 100644 --- a/frontend/app-development/features/dataModelling/DataModelling.test.tsx +++ b/frontend/app-development/features/dataModelling/DataModelling.test.tsx @@ -1,6 +1,11 @@ import React from 'react'; import { DataModelling } from './DataModelling'; -import { render as rtlRender, screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { + act, + render as rtlRender, + screen, + waitForElementToBeRemoved, +} from '@testing-library/react'; import { textMock } from '../../../testing/mocks/i18nMock'; import { ServicesContextProps, ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import { queriesMock } from 'app-shared/mocks/queriesMock'; @@ -8,6 +13,8 @@ import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { QueryKey } from 'app-shared/types/QueryKey'; import { jsonMetadata1Mock } from '../../../packages/schema-editor/test/mocks/metadataMocks'; import { QueryClient } from '@tanstack/react-query'; +import userEvent from '@testing-library/user-event'; +import { createApiErrorMock } from 'app-shared/mocks/apiErrorMock'; // workaround for https://jestjs.io/docs/26.x/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom Object.defineProperty(window, 'matchMedia', { @@ -26,25 +33,31 @@ Object.defineProperty(window, 'matchMedia', { const org = 'org'; const app = 'app'; +const user = userEvent.setup(); // Mocks: const getDatamodel = jest.fn().mockImplementation(() => Promise.resolve({})); const getDatamodelsJson = jest.fn().mockImplementation(() => Promise.resolve([])); const getDatamodelsXsd = jest.fn().mockImplementation(() => Promise.resolve([])); +const generateModels = jest.fn().mockImplementation(() => Promise.resolve()); -const render = (queries: Partial = {}, queryClient: QueryClient = createQueryClientMock()) => { +const render = ( + queries: Partial = {}, + queryClient: QueryClient = createQueryClientMock(), +) => { const allQueries: ServicesContextProps = { ...queriesMock, getDatamodel, getDatamodelsJson, getDatamodelsXsd, + generateModels, ...queries, }; return rtlRender( - - + + , ); }; @@ -62,49 +75,101 @@ describe('DataModelling', () => { queryClient.setQueryData([QueryKey.DatamodelsJson, org, app], []); queryClient.setQueryData([QueryKey.DatamodelsXsd, org, app], []); render({}, queryClient); - const dialogHeader = screen.getByRole('heading', { name: textMock('app_data_modelling.landing_dialog_header') }); + const dialogHeader = screen.getByRole('heading', { + name: textMock('app_data_modelling.landing_dialog_header'), + }); expect(dialogHeader).toBeInTheDocument(); }); it('does not show start dialog when the models have not been loaded yet', () => { render(); expect(screen.getByTitle(textMock('general.loading'))).toBeInTheDocument(); - expect(screen.queryByRole('heading', { name: textMock('app_data_modelling.landing_dialog_header') })).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: textMock('app_data_modelling.landing_dialog_header') }), + ).not.toBeInTheDocument(); }); it('does not show start dialog when there are models present', async () => { getDatamodelsJson.mockImplementation(() => Promise.resolve([jsonMetadata1Mock])); render(); await waitForElementToBeRemoved(() => screen.queryByTitle(textMock('general.loading'))); - expect(screen.queryByRole('heading', { name: textMock('app_data_modelling.landing_dialog_header') })).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: textMock('app_data_modelling.landing_dialog_header') }), + ).not.toBeInTheDocument(); }); - it.each([ - 'getDatamodelsJson', - 'getDatamodelsXsd' - ])('shows an error message if an error occured on the %s query', async (queryName) => { - const errorMessage = 'error-message-test'; - render({ - [queryName]: () => Promise.reject({ message: errorMessage }), + it('shows schema errors panel first when "generate model" button is clicked and returns errors', async () => { + const queryClient = createQueryClientMock(); + generateModels.mockImplementation(() => + Promise.reject( + createApiErrorMock(400, 'DM_01', ['custom error message', 'another custom error message']), + ), + ); + queryClient.setQueryData([QueryKey.DatamodelsJson, org, app], [jsonMetadata1Mock]); + queryClient.setQueryData([QueryKey.DatamodelsXsd, org, app], []); + render({}, queryClient); + const errorsPanel = screen.queryByText(textMock('api_errors.DM_01')); + expect(errorsPanel).not.toBeInTheDocument(); + + const generateModelButton = screen.getByRole('button', { + name: textMock('schema_editor.generate_model_files'), }); - await waitForElementToBeRemoved(() => screen.queryByTitle(textMock('general.loading'))); - expect(screen.getByText(textMock('general.fetch_error_message'))).toBeInTheDocument(); - expect(screen.getByText(textMock('general.error_message_with_colon'))).toBeInTheDocument(); - expect(screen.getByText(errorMessage)).toBeInTheDocument(); + await act(() => user.click(generateModelButton)); + const errorsPanelWithErrors = screen.getByText(textMock('api_errors.DM_01')); + expect(errorsPanelWithErrors).toBeInTheDocument(); }); + it('closes schemaErrorsPanel when "close" button is clicked', async () => { + const queryClient = createQueryClientMock(); + generateModels.mockImplementation(() => + Promise.reject( + createApiErrorMock(400, 'DM_01', ['custom error message', 'another custom error message']), + ), + ); + queryClient.setQueryData([QueryKey.DatamodelsJson, org, app], [jsonMetadata1Mock]); + queryClient.setQueryData([QueryKey.DatamodelsXsd, org, app], []); + render({}, queryClient); + + const generateModelButton = screen.getByRole('button', { + name: textMock('schema_editor.generate_model_files'), + }); + await act(() => user.click(generateModelButton)); + const errorsPanelWithErrors = screen.getByText(textMock('api_errors.DM_01')); + expect(errorsPanelWithErrors).toBeInTheDocument(); + const closeSchemaErrorsPanelButton = screen.getByRole('button', { + name: textMock('general.close'), + }); + await act(() => user.click(closeSchemaErrorsPanelButton)); + const errorsPanel = screen.queryByText(textMock('api_errors.DM_01')); + expect(errorsPanel).not.toBeInTheDocument(); + }); + + it.each(['getDatamodelsJson', 'getDatamodelsXsd'])( + 'shows an error message if an error occured on the %s query', + async (queryName) => { + const errorMessage = 'error-message-test'; + render({ + [queryName]: () => Promise.reject({ message: errorMessage }), + }); + await waitForElementToBeRemoved(() => screen.queryByTitle(textMock('general.loading'))); + expect(screen.getByText(textMock('general.fetch_error_message'))).toBeInTheDocument(); + expect(screen.getByText(textMock('general.error_message_with_colon'))).toBeInTheDocument(); + expect(screen.getByText(errorMessage)).toBeInTheDocument(); + }, + ); + it('Shows a spinner when loading', () => { render(); expect(screen.getByTitle(textMock('general.loading'))).toBeInTheDocument(); }); - it.each([ - QueryKey.DatamodelsJson, - QueryKey.DatamodelsXsd - ])('Shows a spinner when only the "%s" query is loading', (queryKey) => { - const queryClient = createQueryClientMock(); - queryClient.setQueryData([queryKey, org, app], []); - render({}, queryClient); - expect(screen.getByTitle(textMock('general.loading'))).toBeInTheDocument(); - }); + it.each([QueryKey.DatamodelsJson, QueryKey.DatamodelsXsd])( + 'Shows a spinner when only the "%s" query is loading', + (queryKey) => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([queryKey, org, app], []); + render({}, queryClient); + expect(screen.getByTitle(textMock('general.loading'))).toBeInTheDocument(); + }, + ); }); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx index 6e0f3f8f464..f59e4255741 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaEditorWithToolbar.tsx @@ -5,6 +5,7 @@ import React, { useState } from 'react'; import { MetadataOption } from '../../../types/MetadataOption'; import { SelectedSchemaEditor } from './SelectedSchemaEditor'; import { DatamodelMetadata } from 'app-shared/types/DatamodelMetadata'; +import { SchemaGenerationErrorsPanel } from './SchemaGenerationErrorsPanel'; export interface SchemaEditorWithToolbarProps { createPathOption?: boolean; @@ -15,9 +16,9 @@ export const SchemaEditorWithToolbar = ({ createPathOption, datamodels, }: SchemaEditorWithToolbarProps) => { - const [createNewOpen, setCreateNewOpen] = useState(false); const [selectedOption, setSelectedOption] = useState(undefined); + const [schemaGenerationErrorMessages, setSchemaGenerationErrorMessages] = useState([]); const modelPath = selectedOption?.value.repositoryRelativeUrl; const modelName = selectedOption?.label; @@ -31,7 +32,16 @@ export const SchemaEditorWithToolbar = ({ selectedOption={selectedOption} setCreateNewOpen={setCreateNewOpen} setSelectedOption={setSelectedOption} + onSetSchemaGenerationErrorMessages={(errorMessages: string[]) => + setSchemaGenerationErrorMessages(errorMessages) + } /> + {schemaGenerationErrorMessages.length > 0 && ( + setSchemaGenerationErrorMessages([])} + schemaGenerationErrorMessages={schemaGenerationErrorMessages} + /> + )}
{!datamodels.length && setCreateNewOpen(true)} />} {modelPath && ( diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.module.css b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.module.css new file mode 100644 index 00000000000..7fbce86da04 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.module.css @@ -0,0 +1,5 @@ +.errorPanel { + display: flex; + flex-direction: row; + justify-content: space-between; +} diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.test.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.test.tsx new file mode 100644 index 00000000000..f20e9d06787 --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { renderWithMockStore } from '../../../test/mocks'; +import { + SchemaGenerationErrorsPanel, + SchemaGenerationErrorsPanelProps, +} from './SchemaGenerationErrorsPanel'; +import { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { textMock } from '../../../../testing/mocks/i18nMock'; + +const user = userEvent.setup(); +const schemaGenerationErrorMessages = ['custom error message', 'another custom error message']; +const defaultProps: SchemaGenerationErrorsPanelProps = { + onCloseErrorsPanel: jest.fn(), + schemaGenerationErrorMessages, +}; + +describe('SchemaGenerationErrorsPanel', () => { + it('Displays error title and list of errors when schemaGenerationErrorMessages contains errors', () => { + render({}); + const errorTitle = screen.getByText(textMock('api_errors.DM_01')); + expect(errorTitle).toBeInTheDocument(); + const errorMessage1 = screen.getByText(schemaGenerationErrorMessages[0]); + expect(errorMessage1).toBeInTheDocument(); + const errorMessage2 = screen.getByText(schemaGenerationErrorMessages[1]); + expect(errorMessage2).toBeInTheDocument(); + }); + + it('Displays list of text-mapped errors when schemaGenerationErrorMessages contains known errors', () => { + render( + {}, + { + ...defaultProps, + schemaGenerationErrorMessages: [ + "'SomeFieldInSchema' member names cannot be the same as their enclosing type", + ], + }, + ); + const errorTitle = screen.getByText(textMock('api_errors.DM_01')); + expect(errorTitle).toBeInTheDocument(); + const knownErrorMessage = screen.getByText( + textMock('api_errors.DM_CsharpCompiler_NameCollision'), + ); + expect(knownErrorMessage).toBeInTheDocument(); + }); + + it('Displays text-mapped known schemaErrors and plaintext unknown errors', () => { + const unknownErrorMessage = 'an unknown error'; + render( + {}, + { + ...defaultProps, + schemaGenerationErrorMessages: [ + unknownErrorMessage, + "'SomeFieldInSchema' member names cannot be the same as their enclosing type", + ], + }, + ); + const errorTitle = screen.getByText(textMock('api_errors.DM_01')); + expect(errorTitle).toBeInTheDocument(); + const errorMessage = screen.getByText(unknownErrorMessage); + expect(errorMessage).toBeInTheDocument(); + const knownErrorMessage = screen.getByText( + textMock('api_errors.DM_CsharpCompiler_NameCollision'), + ); + expect(knownErrorMessage).toBeInTheDocument(); + }); + + it('Calls onCloseErrorsPanel when close button is clicked', async () => { + const mockOnCloseErrorsPanel = jest.fn(); + render({}, { ...defaultProps, onCloseErrorsPanel: mockOnCloseErrorsPanel }); + const closeErrorPanelButton = screen.getByRole('button', { name: textMock('general.close') }); + await act(() => user.click(closeErrorPanelButton)); + expect(mockOnCloseErrorsPanel).toHaveBeenCalledTimes(1); + }); +}); + +const render = ( + queries: Partial = {}, + props: Partial = {}, +) => renderWithMockStore({}, queries)(); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.tsx new file mode 100644 index 00000000000..2a406a96bec --- /dev/null +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/SchemaGenerationErrorsPanel.tsx @@ -0,0 +1,63 @@ +import classes from './SchemaGenerationErrorsPanel.module.css'; +import React from 'react'; +import { Alert, Button, ErrorMessage, Paragraph } from '@digdir/design-system-react'; +import { Trans, useTranslation } from 'react-i18next'; +import { XMarkIcon } from '@navikt/aksel-icons'; + +export interface SchemaGenerationErrorsPanelProps { + onCloseErrorsPanel: () => void; + schemaGenerationErrorMessages: string[]; +} + +export const SchemaGenerationErrorsPanel = ({ + onCloseErrorsPanel, + schemaGenerationErrorMessages, +}: SchemaGenerationErrorsPanelProps) => { + const { t } = useTranslation(); + + const API_ERROR_MESSAGE_COMPILER_NAME_COLLISION = + 'member names cannot be the same as their enclosing type'; + + const isKnownErrorMessage = (errorMessage: string): boolean => + errorMessage.includes(API_ERROR_MESSAGE_COMPILER_NAME_COLLISION); + + const extractNodeNameFromError = (errorMessage: string): string => + errorMessage.match(/'([^']+)':/)?.[1]; + + return ( + +
+
+ {t('api_errors.DM_01')} +
    + {schemaGenerationErrorMessages?.map((errorMessage, index) => { + return ( +
  • + + {isKnownErrorMessage(errorMessage) ? ( + }} + /> + ) : ( + errorMessage + )} + +
  • + ); + })} +
+
+ +
+
+ ); +}; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/GenerateModelsButton.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/GenerateModelsButton.tsx index 57210965dd4..c0c2f81e721 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/GenerateModelsButton.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/GenerateModelsButton.tsx @@ -8,17 +8,30 @@ import { toast } from 'react-toastify'; export interface GenerateModelsButtonProps { modelPath: string; + onSetSchemaGenerationErrorMessages: (errorMessages: string[]) => void; } -export const GenerateModelsButton = ({ modelPath }: GenerateModelsButtonProps) => { +export const GenerateModelsButton = ({ + modelPath, + onSetSchemaGenerationErrorMessages, +}: GenerateModelsButtonProps) => { const { data } = useSchemaQuery(modelPath); - const { mutate, isPending } = useGenerateModelsMutation(modelPath); + const { mutate, isPending } = useGenerateModelsMutation(modelPath, { + hideDefaultError: (error) => error?.response?.data?.customErrorMessages ?? false, + }); const { t } = useTranslation(); const handleGenerateButtonClick = () => { mutate(data, { onSuccess: () => { toast.success(t('schema_editor.datamodel_generation_success_message')); + onSetSchemaGenerationErrorMessages([]); + }, + onError: (error) => { + const errorMessages = error?.response?.data?.customErrorMessages; + if (errorMessages) { + onSetSchemaGenerationErrorMessages(errorMessages); + } }, }); }; diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.test.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.test.tsx index 936dd44ae92..b6acabf2cd3 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.test.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.test.tsx @@ -32,6 +32,7 @@ const texts = { }; const setCreateNewOpen = jest.fn(); const setSelectedOption = jest.fn(); +const onSetSchemaGenerationErrorMessages = jest.fn(); const selectedOption: MetadataOption = convertMetadataToOption(jsonMetadata1Mock); const defaultProps: TopToolbarProps = { createNewOpen: false, @@ -39,6 +40,7 @@ const defaultProps: TopToolbarProps = { selectedOption, setCreateNewOpen, setSelectedOption, + onSetSchemaGenerationErrorMessages, }; const org = 'org'; const app = 'app'; @@ -51,7 +53,10 @@ const renderToolbar = ( ) => { const TopToolbarWithInitData = () => { const queryClient = useQueryClient(); - queryClient.setQueryData([QueryKey.JsonSchema, org, app, modelPath], buildJsonSchema(uiSchemaNodesMock)); + queryClient.setQueryData( + [QueryKey.JsonSchema, org, app, modelPath], + buildJsonSchema(uiSchemaNodesMock), + ); return ; }; @@ -97,9 +102,12 @@ describe('TopToolbar', () => { }); it('Shows error message when the "generate" button is clicked and a schema error is provided', async () => { - renderToolbar({}, { - generateModels: jest.fn().mockImplementation(() => Promise.reject()), - }); + renderToolbar( + {}, + { + generateModels: jest.fn().mockImplementation(() => Promise.reject()), + }, + ); await act(() => user.click(screen.getByRole('button', { name: generateText }))); expect(await screen.findByRole('alert')).toHaveTextContent(generalErrorMessage); }); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx index 8e90f5a5674..9ba58f2e756 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/TopToolbar.tsx @@ -7,7 +7,7 @@ import { DeleteWrapper } from './DeleteWrapper'; import { computeSelectedOption } from '../../../../utils/metadataUtils'; import { CreateDatamodelMutationArgs, - useCreateDatamodelMutation + useCreateDatamodelMutation, } from '../../../../hooks/mutations/useCreateDatamodelMutation'; import { MetadataOption } from '../../../../types/MetadataOption'; import { GenerateModelsButton } from './GenerateModelsButton'; @@ -21,6 +21,7 @@ export interface TopToolbarProps { selectedOption?: MetadataOption; setCreateNewOpen: (open: boolean) => void; setSelectedOption: (option?: MetadataOption) => void; + onSetSchemaGenerationErrorMessages: (errorMessages: string[]) => void; } export function TopToolbar({ @@ -30,6 +31,7 @@ export function TopToolbar({ selectedOption, setCreateNewOpen, setSelectedOption, + onSetSchemaGenerationErrorMessages, }: TopToolbarProps) { const modelPath = selectedOption?.value.repositoryRelativeUrl; @@ -55,17 +57,24 @@ export function TopToolbar({ handleCreateSchema={handleCreateSchema} createPathOption={createPathOption} /> - + - +
- {modelPath && } + {modelPath && ( + + onSetSchemaGenerationErrorMessages(errorMessages) + } + /> + )}
diff --git a/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts b/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts index 1dffa0f5c43..cc2383c58de 100644 --- a/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts +++ b/frontend/app-development/hooks/mutations/useGenerateModelsMutation.ts @@ -1,13 +1,15 @@ -import { useMutation, UseMutationResult, useQueryClient } from '@tanstack/react-query'; +import { useMutation, UseMutationResult, useQueryClient, QueryMeta } from '@tanstack/react-query'; import { QueryKey } from 'app-shared/types/QueryKey'; import { useServicesContext } from 'app-shared/contexts/ServicesContext'; import { AxiosError } from 'axios'; import { JsonSchema } from 'app-shared/types/JsonSchema'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { ApiError } from 'app-shared/types/api/ApiError'; export const useGenerateModelsMutation = ( modelPath: string, -): UseMutationResult => { + meta?: QueryMeta, +): UseMutationResult> => { const queryClient = useQueryClient(); const { org, app } = useStudioUrlParams(); const { generateModels } = useServicesContext(); @@ -18,5 +20,6 @@ export const useGenerateModelsMutation = ( queryClient.invalidateQueries({ queryKey: [QueryKey.DatamodelsJson, org, app] }), queryClient.invalidateQueries({ queryKey: [QueryKey.DatamodelsXsd, org, app] }), ]), + meta, }); }; diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 4688fcf39eb..29da40f8942 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -13,6 +13,8 @@ "access_control.test_what_header": "Hva kan du teste i Altinn Studio?", "address_component.validation_error_house_number": "Bolignummer er ugyldig", "address_component.validation_error_zipcode": "Postnummer er ugyldig", + "api_errors.DM_01": "Noe gikk galt under bygging av datamodellen.", + "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.", "api_errors.GT_01": "Deling av endringer mislyktes. Vennligst prøv igjen.", "api_errors.ResourceNotFound": "Fant ikke en fil applikasjonen din prøvde å få tak i.", "app_create_release.application_builds_based_on": "Applikasjonen bygges basert på", diff --git a/frontend/packages/shared/src/contexts/ServicesContext.test.tsx b/frontend/packages/shared/src/contexts/ServicesContext.test.tsx index 03f1993b47c..2ad7af74ea1 100644 --- a/frontend/packages/shared/src/contexts/ServicesContext.test.tsx +++ b/frontend/packages/shared/src/contexts/ServicesContext.test.tsx @@ -3,25 +3,21 @@ import { render, renderHook, screen, waitFor } from '@testing-library/react'; import { ServicesContextProps, ServicesContextProvider } from './ServicesContext'; import { queriesMock } from 'app-shared/mocks/queriesMock'; import { useQuery } from '@tanstack/react-query'; -import { mockUseTranslation } from '../../../../testing/mocks/i18nMock'; +import { textMock } from '../../../../testing/mocks/i18nMock'; import { createApiErrorMock } from 'app-shared/mocks/apiErrorMock'; +import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; -const texts = { - 'api_errors.DM_01': 'DM_01 error message', - 'api_errors.GT_01': 'Deling av endringer mislyktes. Vennligst prøv igjen.', - 'general.error_message': 'Something went wrong', - 'general.try_again': 'Try again', -}; - +const unknownErrorCode = 'unknownErrorCode'; // Mocks: jest.mock('react-i18next', () => ({ useTranslation: () => ({ - ...mockUseTranslation(texts), + t: (key: string, variables?: KeyValuePairs) => textMock(key, variables), i18n: { - exists: (key: string) => texts[key] !== undefined, + exists: (key: string) => + key !== `api_errors.${unknownErrorCode}` ? textMock(key) : undefined, }, }), - Trans: ({ i18nKey }: { i18nKey: any }) => texts[i18nKey], + Trans: ({ i18nKey }: { i18nKey: any }) => textMock(i18nKey), })); const wrapper = ({ @@ -76,7 +72,7 @@ describe('ServicesContext', () => { { wrapper }, ); await waitFor(() => result.current.isError); - expect(await screen.findByText(texts['api_errors.GT_01'])).toBeInTheDocument(); + expect(await screen.findByText(textMock('api_errors.GT_01'))).toBeInTheDocument(); }); it('displays a specific error message if API returns an error code and the error messages does exist', async () => { @@ -92,7 +88,7 @@ describe('ServicesContext', () => { await waitFor(() => result.current.isError); - expect(await screen.findByText(texts['api_errors.DM_01'])).toBeInTheDocument(); + expect(await screen.findByText(textMock('api_errors.DM_01'))).toBeInTheDocument(); }); it('displays a default error message if API returns an error code but the error message does not exist', async () => { @@ -100,7 +96,7 @@ describe('ServicesContext', () => { () => useQuery({ queryKey: ['fetchData'], - queryFn: () => Promise.reject(createApiErrorMock(500, 'DM_02')), + queryFn: () => Promise.reject(createApiErrorMock(500, unknownErrorCode)), retry: false, }), { wrapper }, @@ -108,7 +104,7 @@ describe('ServicesContext', () => { await waitFor(() => result.current.isError); - expect(await screen.findByText(texts['general.error_message'])).toBeInTheDocument(); + expect(await screen.findByText(textMock('general.error_message'))).toBeInTheDocument(); }); it('displays a default error message if an API call fails', async () => { @@ -119,7 +115,7 @@ describe('ServicesContext', () => { await waitFor(() => result.current.isError); - expect(await screen.findByText(texts['general.error_message'])).toBeInTheDocument(); + expect(await screen.findByText(textMock('general.error_message'))).toBeInTheDocument(); }); it('displays a default error message if a component throws an error while rendering', () => { @@ -130,8 +126,8 @@ describe('ServicesContext', () => { }; render(, { wrapper }); - expect(screen.getByText(texts['general.error_message'])).toBeInTheDocument(); - expect(screen.getByText(texts['general.try_again'])).toBeInTheDocument(); + expect(screen.getByText(textMock('general.error_message'))).toBeInTheDocument(); + expect(screen.getByText(textMock('general.try_again'))).toBeInTheDocument(); expect(mockConsoleError).toHaveBeenCalled(); }); }); diff --git a/frontend/packages/shared/src/mocks/apiErrorMock.ts b/frontend/packages/shared/src/mocks/apiErrorMock.ts index 3a91cdbee60..a93b5495469 100644 --- a/frontend/packages/shared/src/mocks/apiErrorMock.ts +++ b/frontend/packages/shared/src/mocks/apiErrorMock.ts @@ -1,11 +1,15 @@ import { ApiError } from 'app-shared/types/api/ApiError'; import { AxiosError, AxiosResponse } from 'axios'; -export const createApiErrorMock = (status?: number, errorCode?: string): AxiosError => { +export const createApiErrorMock = ( + status?: number, + errorCode?: string, + customErrorMessages?: string[], +): AxiosError => { const error = new AxiosError(); error.response = { status, - data: { errorCode }, + data: { errorCode, customErrorMessages }, } as AxiosResponse; return error; }; diff --git a/frontend/packages/shared/src/types/api/ApiError.ts b/frontend/packages/shared/src/types/api/ApiError.ts index ac3b74a4881..8019df08505 100644 --- a/frontend/packages/shared/src/types/api/ApiError.ts +++ b/frontend/packages/shared/src/types/api/ApiError.ts @@ -5,4 +5,5 @@ export interface ApiError { title?: string; type?: string; errorCode?: string; -}; + customErrorMessages?: string[]; +} From 633adb044c1a626a19892a07e6c43689eb5faa8c Mon Sep 17 00:00:00 2001 From: JamalAlabdullah <90609090+JamalAlabdullah@users.noreply.github.com> Date: Tue, 12 Dec 2023 12:47:02 +0100 Subject: [PATCH 02/21] 11809 fix size for text and icon in binding datamodell (#11818) * Fixed size for text and icon in binding datamodel --- .../editModal/EditDataModelBindings.module.css | 5 +++++ .../config/editModal/EditDataModelBindings.tsx | 12 ++++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.module.css index ecf73a8185d..57776af6483 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.module.css @@ -20,6 +20,11 @@ padding-block: 0.75rem; } +.linkedDatamodelIcon { + height: var(--fds-sizing-4); + width: var(--fds-sizing-4); +} + .selectedOption { gap: var(--fds-spacing-1); word-break: break-word; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.tsx index 88f12d6b26e..bca53e2f58a 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditDataModelBindings.tsx @@ -7,7 +7,7 @@ import { SelectDataModelComponent } from '../SelectDataModelComponent'; import { useDatamodelMetadataQuery } from '../../../hooks/queries/useDatamodelMetadataQuery'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { LinkIcon } from '@studio/icons'; -import { Button, Paragraph } from '@digdir/design-system-react'; +import { Button } from '@digdir/design-system-react'; import classes from './EditDataModelBindings.module.css'; import { InputActionWrapper } from 'app-shared/components/InputActionWrapper'; @@ -104,7 +104,11 @@ export const EditDataModelBindings = ({ helpText={helpText} /> ) : ( - selectedOption && + selectedOption && ( + + + + ) )} @@ -116,8 +120,8 @@ export const EditDataModelBindings = ({ const SelectedOption = ({ selectedOption }: { selectedOption: string }) => { return (
- - {selectedOption} + + {selectedOption}
); }; From 1307a9b4e3c05db57a47d617569f14dfe7061f7a Mon Sep 17 00:00:00 2001 From: Nina Kylstad Date: Tue, 12 Dec 2023 14:20:32 +0100 Subject: [PATCH 03/21] Revert 'refactoring studio modal to use designsystem modal (#11792)' (#11847) --- .../SettingsModal/SettingsModal.module.css | 2 +- .../SettingsModal/SettingsModal.test.tsx | 94 +++---- .../SettingsModal/SettingsModal.tsx | 204 +++++++------- .../DeleteModal/DeleteModal.test.tsx | 52 +--- .../DeleteModal/DeleteModal.tsx | 120 ++++---- .../Tabs/LocalChangesTab/LocalChangesTab.tsx | 18 +- .../SettingsModalButton.test.tsx | 2 +- .../SettingsModalButton.tsx | 22 +- frontend/language/src/nb.json | 1 + .../StudioModal/StudioModal.module.css | 45 ++- .../StudioModal/StudioModal.test.tsx | 62 ++--- .../components/StudioModal/StudioModal.tsx | 72 +++-- .../policy-editor/src/PolicyEditor.tsx | 13 +- .../VerificationModal.test.tsx | 55 ++-- .../VerificationModal/VerificationModal.tsx | 76 +++--- .../ImportResourceModal.test.tsx | 34 +-- .../ImportResourceModal.tsx | 164 +++++------ .../MergeConflictModal/MergeConflictModal.tsx | 101 ++++--- .../RemoveChangesModal/RemoveChangesModal.tsx | 139 ++++++---- .../resourceadm/components/Modal/Modal.tsx | 49 ++-- .../NavigationModal/NavigationModal.test.tsx | 50 +--- .../NavigationModal/NavigationModal.tsx | 65 +++-- .../NewResourceModal.test.tsx | 50 +--- .../NewResourceModal/NewResourceModal.tsx | 256 +++++++++--------- .../ResourceDashboardPage.tsx | 38 ++- .../pages/ResourcePage/ResourcePage.tsx | 73 +++-- frontend/testing/setupTests.ts | 8 - 27 files changed, 944 insertions(+), 921 deletions(-) diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.module.css b/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.module.css index d732718ab06..86859ff9bdd 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.module.css +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.module.css @@ -12,6 +12,7 @@ .leftNavigationBar { min-height: 200px; + border-bottom-left-radius: 20px; } .headingWrapper { @@ -19,7 +20,6 @@ align-items: center; padding-block: 10px; padding-inline: 20px; - border-bottom: 1px solid var(--fds-semantic-border-divider-default); } .headingWrapper > :first-child { diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.test.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.test.tsx index 944cb5e8d69..c054f5d70e2 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.test.tsx +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.test.tsx @@ -1,5 +1,10 @@ -import React, { useRef } from 'react'; -import { render as rtlRender, screen, act } from '@testing-library/react'; +import React from 'react'; +import { + render as rtlRender, + screen, + act, + waitForElementToBeRemoved, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { SettingsModal, SettingsModalProps } from './SettingsModal'; import { textMock } from '../../../../testing/mocks/i18nMock'; @@ -11,8 +16,6 @@ import { AppConfig } from 'app-shared/types/AppConfig'; import { useAppConfigMutation } from 'app-development/hooks/mutations'; import { MemoryRouter } from 'react-router-dom'; -const mockButtonText: string = 'Mock Button'; - const mockApp: string = 'app'; const mockOrg: string = 'org'; @@ -36,15 +39,8 @@ mockUpdateAppConfigMutation.mockReturnValue({ mutate: updateAppConfigMutation, } as unknown as UseMutationResult); -const mockOnClose = jest.fn(); - -const defaultProps: SettingsModalProps = { - onClose: mockOnClose, - org: mockOrg, - app: mockApp, -}; - describe('SettingsModal', () => { + const user = userEvent.setup(); beforeEach(() => { global.console = { ...console, @@ -56,8 +52,26 @@ describe('SettingsModal', () => { jest.clearAllMocks(); }); + const mockOnClose = jest.fn(); + + const defaultProps: SettingsModalProps = { + isOpen: true, + onClose: mockOnClose, + org: mockOrg, + app: mockApp, + }; + + it('closes the modal when the close button is clicked', async () => { + render(defaultProps); + + const closeButton = screen.getByRole('button', { name: textMock('modal.close_icon') }); + await act(() => user.click(closeButton)); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + it('displays left navigation bar when promises resolves', async () => { - await renderAndOpenModal(); + await resolveAndWaitForSpinnerToDisappear(); expect( screen.getByRole('tab', { name: textMock('settings_modal.left_nav_tab_about') }), @@ -77,7 +91,7 @@ describe('SettingsModal', () => { }); it('displays the about tab, and not the other tabs, when promises resolves first time', async () => { - await renderAndOpenModal(); + await resolveAndWaitForSpinnerToDisappear(); expect(screen.getByText(textMock('settings_modal.about_tab_heading'))).toBeInTheDocument(); expect( @@ -95,8 +109,7 @@ describe('SettingsModal', () => { }); it('changes the tab from "about" to "policy" when policy tab is clicked', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + await resolveAndWaitForSpinnerToDisappear(); expect( screen.queryByRole('heading', { @@ -123,8 +136,7 @@ describe('SettingsModal', () => { }); it('changes the tab from "policy" to "about" when about tab is clicked', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + await resolveAndWaitForSpinnerToDisappear(); const policyTab = screen.getByRole('tab', { name: textMock('settings_modal.left_nav_tab_policy'), @@ -145,9 +157,8 @@ describe('SettingsModal', () => { expect(screen.getByText(textMock('settings_modal.about_tab_heading'))).toBeInTheDocument(); }); - it('changes the tab from "about" to "localChanges" when local changes tab is clicked', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + it.only('changes the tab from "about" to "localChanges" when local changes tab is clicked', async () => { + await resolveAndWaitForSpinnerToDisappear(); expect( screen.queryByRole('heading', { @@ -173,8 +184,7 @@ describe('SettingsModal', () => { }); it('changes the tab from "about" to "accessControl" when access control tab is clicked', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + await resolveAndWaitForSpinnerToDisappear(); expect( screen.queryByRole('heading', { @@ -201,8 +211,7 @@ describe('SettingsModal', () => { }); it('changes the tab from "about" to "setup" when setup control tab is clicked', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + await resolveAndWaitForSpinnerToDisappear(); expect( screen.queryByRole('heading', { @@ -227,10 +236,22 @@ describe('SettingsModal', () => { screen.queryByText(textMock('settings_modal.about_tab_heading')), ).not.toBeInTheDocument(); }); + + /** + * Resolves the mocks, renders the component and waits for the spinner + * to be removed from the screen + */ + const resolveAndWaitForSpinnerToDisappear = async () => { + render(defaultProps); + + await waitForElementToBeRemoved(() => + screen.queryByTitle(textMock('settings_modal.loading_content')), + ); + }; }); const render = ( - props: Partial = {}, + props: SettingsModalProps, queries: Partial = {}, queryClient: QueryClient = createQueryClientMock(), ) => { @@ -241,27 +262,8 @@ const render = ( return rtlRender( - + , ); }; - -const renderAndOpenModal = async (props: Partial = {}) => { - const user = userEvent.setup(); - render(props); - - const openModalButton = screen.getByRole('button', { name: mockButtonText }); - await act(() => user.click(openModalButton)); -}; - -const TestComponentWithButton = (props: Partial = {}) => { - const modalRef = useRef(null); - - return ( - <> - - - - ); -}; diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.tsx index 4b965e2fa6a..fca929dbedc 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.tsx +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModal/SettingsModal.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useState } from 'react'; +import React, { ReactNode, useState } from 'react'; import classes from './SettingsModal.module.css'; import { Heading } from '@digdir/design-system-react'; import { @@ -22,6 +22,7 @@ import { AccessControlTab } from './components/Tabs/AccessControlTab'; import { SetupTab } from './components/Tabs/SetupTab'; export type SettingsModalProps = { + isOpen: boolean; onClose: () => void; org: string; app: string; @@ -31,111 +32,118 @@ export type SettingsModalProps = { * @component * Displays the settings modal. * - * @property {function}[onClose] - Function to execute on close + * @property {boolean}[isOpen] - Flag for if the modal is open + * @property {function}[onClose] - Function to be executed on close * @property {string}[org] - The org * @property {string}[app] - The app * - * @returns {JSX.Element} - The rendered component + * @returns {ReactNode} - The rendered component */ -export const SettingsModal = forwardRef( - ({ onClose, org, app }, ref): JSX.Element => { - const { t } = useTranslation(); +export const SettingsModal = ({ isOpen, onClose, org, app }: SettingsModalProps): ReactNode => { + const { t } = useTranslation(); - const [currentTab, setCurrentTab] = useState('about'); + const [currentTab, setCurrentTab] = useState('about'); - /** - * Ids for the navigation tabs - */ - const aboutTabId: SettingsModalTab = 'about'; - const setupTabId: SettingsModalTab = 'setup'; - const policyTabId: SettingsModalTab = 'policy'; - const localChangesTabId: SettingsModalTab = 'localChanges'; - const accessControlTabId: SettingsModalTab = 'accessControl'; + /** + * Ids for the navigation tabs + */ + const aboutTabId: SettingsModalTab = 'about'; + const setupTabId: SettingsModalTab = 'setup'; + const policyTabId: SettingsModalTab = 'policy'; + const localChangesTabId: SettingsModalTab = 'localChanges'; + const accessControlTabId: SettingsModalTab = 'accessControl'; - const leftNavigationTabs: LeftNavigationTab[] = [ - createNavigationTab( - , - aboutTabId, - () => changeTabTo(aboutTabId), - currentTab, - ), - createNavigationTab( - , - setupTabId, - () => changeTabTo(setupTabId), - currentTab, - ), - createNavigationTab( - , - policyTabId, - () => changeTabTo(policyTabId), - currentTab, - ), - createNavigationTab( - , - accessControlTabId, - () => changeTabTo(accessControlTabId), - currentTab, - ), - createNavigationTab( - , - localChangesTabId, - () => changeTabTo(localChangesTabId), - currentTab, - ), - ]; + /** + * The tabs to display in the navigation bar + */ + const leftNavigationTabs: LeftNavigationTab[] = [ + createNavigationTab( + , + aboutTabId, + () => changeTabTo(aboutTabId), + currentTab, + ), + createNavigationTab( + , + setupTabId, + () => changeTabTo(setupTabId), + currentTab, + ), + createNavigationTab( + , + policyTabId, + () => changeTabTo(policyTabId), + currentTab, + ), + createNavigationTab( + , + accessControlTabId, + () => changeTabTo(accessControlTabId), + currentTab, + ), + createNavigationTab( + , + localChangesTabId, + () => changeTabTo(localChangesTabId), + currentTab, + ), + ]; - const changeTabTo = (tabId: SettingsModalTab) => { - setCurrentTab(tabId); - }; + /** + * Changes the active tab + * @param tabId + */ + const changeTabTo = (tabId: SettingsModalTab) => { + setCurrentTab(tabId); + }; - const displayTabs = () => { - switch (currentTab) { - case 'about': { - return ; - } - case 'setup': { - return ; - } - case 'policy': { - return ; - } - case 'accessControl': { - return ; - } - case 'localChanges': { - return ; - } + /** + * Displays the currently selected tab and its content + * @returns + */ + const displayTabs = () => { + switch (currentTab) { + case 'about': { + return ; } - }; - - return ( - - - - {t('settings_modal.heading')} - - - } - content={ -
-
- -
- {displayTabs()} -
- } - /> - ); - }, -); + case 'setup': { + return ; + } + case 'policy': { + return ; + } + case 'accessControl': { + return ; + } + case 'localChanges': { + return ; + } + } + }; -SettingsModal.displayName = 'SettingsModal'; + return ( + + + + {t('settings_modal.heading')} + + + } + > +
+
+ +
+ {displayTabs()} +
+
+ ); +}; diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.test.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.test.tsx index d6c6c3fe37f..3af151ec653 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.test.tsx +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.test.tsx @@ -1,37 +1,35 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { render, screen, act } from '@testing-library/react'; import { DeleteModal, DeleteModalProps } from './DeleteModal'; import { textMock } from '../../../../../../../../testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; const mockAppName: string = 'TestApp'; -const mockButtonText: string = 'Mock Button'; - -const mockOnClose = jest.fn(); -const mockOnDelete = jest.fn(); - -const defaultProps: DeleteModalProps = { - onClose: mockOnClose, - onDelete: mockOnDelete, - appName: mockAppName, -}; describe('DeleteModal', () => { + const user = userEvent.setup(); afterEach(jest.clearAllMocks); + const mockOnClose = jest.fn(); + const mockOnDelete = jest.fn(); + + const defaultProps: DeleteModalProps = { + isOpen: true, + onClose: mockOnClose, + onDelete: mockOnDelete, + appName: mockAppName, + }; + it('calls the onClose function when the Cancel button is clicked', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const cancelButton = screen.getByRole('button', { name: textMock('general.cancel') }); await act(() => user.click(cancelButton)); - expect(mockOnClose).toHaveBeenCalledTimes(1); }); it('updates the value of the text field when typing', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const textfield = screen.getByLabelText( textMock('settings_modal.local_changes_tab_delete_modal_textfield_label'), @@ -47,8 +45,7 @@ describe('DeleteModal', () => { }); it('calls the onDelete function when the Delete button is clicked with a matching app name', async () => { - const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const deleteButton = screen.getByRole('button', { name: textMock('settings_modal.local_changes_tab_delete_modal_delete_button'), @@ -69,22 +66,3 @@ describe('DeleteModal', () => { expect(mockOnDelete).toHaveBeenCalledTimes(1); }); }); - -const renderAndOpenModal = async (props: Partial = {}) => { - const user = userEvent.setup(); - render(); - - const openModalButton = screen.getByRole('button', { name: mockButtonText }); - await act(() => user.click(openModalButton)); -}; - -const TestComponentWithButton = (props: Partial = {}) => { - const modalRef = useRef(null); - - return ( - <> - - - - ); -}; diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.tsx index b7ed941fcac..ee7b7a454e3 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.tsx +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/DeleteModal/DeleteModal.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useState } from 'react'; +import React, { ReactNode, useState } from 'react'; import classes from './DeleteModal.module.css'; import { useTranslation } from 'react-i18next'; import { StudioModal } from '@studio/components'; @@ -6,6 +6,7 @@ import { TrashIcon } from '@navikt/aksel-icons'; import { Button, Heading, Paragraph, Textfield } from '@digdir/design-system-react'; export type DeleteModalProps = { + isOpen: boolean; onClose: () => void; onDelete: () => void; appName: string; @@ -16,70 +17,71 @@ export type DeleteModalProps = { * Displays a Warning modal to the user to ensure they really want to * do an action. * + * @property {boolean}[isOpen] - If the modal is open or not * @property {function}[onClose] - Function to execute on close * @property {function}[onDelete] - Function to execute on click delete * @property {string}[appName] - The name of the app to delete changes on * - * @returns {JSX.Element} - The rendered component + * @returns {ReactNode} - The rendered component */ -export const DeleteModal = forwardRef( - ({ onClose, onDelete, appName }, ref): JSX.Element => { - const { t } = useTranslation(); +export const DeleteModal = ({ + isOpen, + onClose, + onDelete, + appName, +}: DeleteModalProps): ReactNode => { + const { t } = useTranslation(); - const [nameToDelete, setNameToDelete] = useState(''); + const [nameToDelete, setNameToDelete] = useState(''); - const handleClose = () => { - setNameToDelete(''); - onClose(); - }; + const handleClose = () => { + setNameToDelete(''); + onClose(); + }; - const handleDelete = () => { - setNameToDelete(''); - onDelete(); - }; + const handleDelete = () => { + setNameToDelete(''); + onDelete(); + }; - return ( - - - - {t('settings_modal.local_changes_tab_delete_modal_title')} - - - } - content={ -
- - {t('settings_modal.local_changes_tab_delete_modal_text')} - - setNameToDelete(e.target.value)} - /> -
- - -
-
- } - /> - ); - }, -); - -DeleteModal.displayName = 'DeleteModal'; + return ( + + + + {t('settings_modal.local_changes_tab_delete_modal_title')} + + + } + > +
+ + {t('settings_modal.local_changes_tab_delete_modal_text')} + + setNameToDelete(e.target.value)} + /> +
+ + +
+
+
+ ); +}; diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/LocalChangesTab.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/LocalChangesTab.tsx index fe748c8f9e2..105b98eaed2 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/LocalChangesTab.tsx +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModal/components/Tabs/LocalChangesTab/LocalChangesTab.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { ReactNode, useState } from 'react'; import classes from './LocalChangesTab.module.css'; import { useTranslation } from 'react-i18next'; import { TabHeader } from '../../TabHeader'; @@ -23,26 +23,24 @@ export type LocalChangesTabProps = { * @property {string}[org] - The org * @property {string}[app] - The app * - * @returns {JSX.Element} - The rendered component + * @returns {ReactNode} - The rendered component */ -export const LocalChangesTab = ({ org, app }: LocalChangesTabProps): JSX.Element => { +export const LocalChangesTab = ({ org, app }: LocalChangesTabProps): ReactNode => { const { t } = useTranslation(); const { mutate: deleteLocalChanges } = useResetRepositoryMutation(org, app); - const modalRef = useRef(null); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); const handleDelete = () => { deleteLocalChanges(undefined, { onSuccess: () => { - modalRef.current?.close(); + setDeleteModalOpen(false); toast.success(t('settings_modal.local_changes_tab_deleted_success')); }, }); }; - const handleCloseModal = () => modalRef.current?.close(); - return ( @@ -68,12 +66,12 @@ export const LocalChangesTab = ({ org, app }: LocalChangesTabProps): JSX.Element color='danger' icon={} text={t('settings_modal.local_changes_tab_delete_button')} - action={{ type: 'button', onClick: () => modalRef.current?.showModal() }} + action={{ type: 'button', onClick: () => setDeleteModalOpen(true) }} /> setDeleteModalOpen(false)} onDelete={handleDelete} appName={app} /> diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx index 7f36a01b5ac..c3c2a60c261 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.test.tsx @@ -68,7 +68,7 @@ describe('SettingsModal', () => { }); expect(modalHeading).toBeInTheDocument(); - const closeButton = screen.getByRole('button', { name: 'close modal' }); + const closeButton = screen.getByRole('button', { name: textMock('modal.close_icon') }); await act(() => user.click(closeButton)); const modalHeadingAfter = screen.queryByRole('heading', { diff --git a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx index 74bd19efd1e..10096531273 100644 --- a/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx +++ b/frontend/app-development/layout/SettingsModalButton/SettingsModalButton.tsx @@ -1,17 +1,23 @@ -import React, { ReactNode, useRef } from 'react'; +import React, { ReactNode, useState } from 'react'; import { Button } from '@digdir/design-system-react'; import { CogIcon } from '@navikt/aksel-icons'; import { useTranslation } from 'react-i18next'; import { SettingsModal } from './SettingsModal'; export type SettingsModalButtonProps = { + /** + * The org + */ org: string; + /** + * The app + */ app: string; }; /** * @component - * Displays a button to open the Settings modal and the Settings modal + * Displays a button to open the Settings modal * * @property {string}[org] - The org * @property {string}[app] - The app @@ -20,13 +26,12 @@ export type SettingsModalButtonProps = { */ export const SettingsModalButton = ({ org, app }: SettingsModalButtonProps): ReactNode => { const { t } = useTranslation(); - - const modalRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); return ( <> - modalRef.current?.close()} org={org} app={app} /> + { + // Done to prevent API calls to be executed before the modal is open + isOpen && ( + setIsOpen(false)} org={org} app={app} /> + ) + } ); }; diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 29da40f8942..eefd2bfea08 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -514,6 +514,7 @@ "merge_conflict.download_zip_file": "Last ned zip-fil", "merge_conflict.headline": "Det er en konflikt i applikasjonen", "merge_conflict.remove_my_changes": "Fjern mine endringer", + "modal.close_icon": "Lukk modalen", "not_found_page.heading": "Vi finner ikke siden", "not_found_page.redirect_to_dashboard": "Gå tilbake til Dashboard", "not_found_page.text": "Vi finner ikke siden du leter etter. Har du skrevet inn riktig URL? \nDu kan prøve å gå tilbake til dashboardet for å sjekke eller snakke med <0>Altinn Servicedesk om du ønsker hjelp.", diff --git a/frontend/libs/studio-components/src/components/StudioModal/StudioModal.module.css b/frontend/libs/studio-components/src/components/StudioModal/StudioModal.module.css index b5a58958fdd..69246720d98 100644 --- a/frontend/libs/studio-components/src/components/StudioModal/StudioModal.module.css +++ b/frontend/libs/studio-components/src/components/StudioModal/StudioModal.module.css @@ -3,6 +3,12 @@ --modal-min-height: 100px; --modal-max-height: 80vh; --modal-max-width: 80%; + --modal-position: 50%; + --modal-translate: calc(var(--modal-position) * -1); + --heading-height: 60px; + --modal-border-size: 1px; + --modal-content-max-height: calc(var(--modal-max-height) - var(--heading-height)) - + var(--modal-border-size); max-width: var(--modal-max-width); min-width: var(--modal-min-width); @@ -10,20 +16,41 @@ max-height: var(--modal-max-height); min-height: var(--modal-min-height); height: max-content; + border-radius: 20px; + position: absolute; + top: var(--modal-position); + left: var(--modal-position); + transform: translate(var(--modal-translate), var(--modal-translate)); + padding: 0; padding-top: 20px; + background-color: var(--fds-semantic-background-default); + border: var(--modal-border-size) solid var(--fds-semantic-border-neutral-subtle); } -.header { - margin: 0; - padding: 0; +.modalOverlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background-color: rgba(30, 43, 60, 0.5); + z-index: 1000; } -.content { - margin: 0; - padding: 0; +.headingWrapper { + border-bottom: 1px solid var(--fds-semantic-border-divider-default); + height: var(--heading-height); + position: sticky; + top: 0; + background-color: var(--fds-semantic-background-default); } -.footer { - margin: 0; - padding: 0; +.contentWrapper { + max-height: var(--modal-content-max-height); +} + +.closeButtonWrapper { + position: absolute; + top: -0.5rem; + right: 0.5rem; } diff --git a/frontend/libs/studio-components/src/components/StudioModal/StudioModal.test.tsx b/frontend/libs/studio-components/src/components/StudioModal/StudioModal.test.tsx index 9b3ecf18b4f..73f206c3b6e 100644 --- a/frontend/libs/studio-components/src/components/StudioModal/StudioModal.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioModal/StudioModal.test.tsx @@ -1,58 +1,46 @@ import React, { ReactNode } from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { StudioModal, StudioModalProps } from './StudioModal'; +import { textMock } from '../../../../../testing/mocks/i18nMock'; -const mockHeaderText: string = 'Title'; -const mockContentText: string = 'Modal test'; -const mockFooterText: string = 'Footer content'; - -const MockHeader: ReactNode = ( -
-

{mockHeaderText}

-
-); - -const MockContent: ReactNode = ( +const mockTitle: ReactNode = (
-

{mockContentText}

+

Title

); -const MockFooter: ReactNode = ( +const mockChildren = (
-

{mockFooterText}

+

Modal test

); -const mockOnClose = jest.fn(); - -const defaultProps: StudioModalProps = { - onClose: mockOnClose, - header: MockHeader, - content: MockContent, - open: true, -}; - -describe('StudioModal', () => { +describe('Modal', () => { afterEach(jest.clearAllMocks); - it('shows the header and content components correctly, and hides the footer when not present', () => { - render(); + const mockOnClose = jest.fn(); - const header = screen.getByRole('heading', { name: 'Title', level: 3 }); - expect(header).toBeInTheDocument(); + const defaultProps: StudioModalProps = { + isOpen: true, + onClose: mockOnClose, + title: mockTitle, + children: mockChildren, + }; - const content = screen.getByText(mockContentText); - expect(content).toBeInTheDocument(); + it('calls onClose when the close button is clicked', async () => { + const user = userEvent.setup(); + render(); - const footer = screen.queryByText(mockFooterText); - expect(footer).not.toBeInTheDocument(); + const closeButton = screen.getByRole('button', { name: textMock('modal.close_icon') }); + await act(() => user.click(closeButton)); + expect(mockOnClose).toHaveBeenCalledTimes(1); }); - it('shows the footer component when it is present', () => { - render(); + it('does not show content when modal is clsoed', () => { + render(); - const footer = screen.getByText(mockFooterText); - expect(footer).toBeInTheDocument(); + const closeButton = screen.queryByRole('button', { name: textMock('modal.close_icon') }); + expect(closeButton).not.toBeInTheDocument(); }); }); diff --git a/frontend/libs/studio-components/src/components/StudioModal/StudioModal.tsx b/frontend/libs/studio-components/src/components/StudioModal/StudioModal.tsx index ba43b4c2d6f..b7a7b4a7953 100644 --- a/frontend/libs/studio-components/src/components/StudioModal/StudioModal.tsx +++ b/frontend/libs/studio-components/src/components/StudioModal/StudioModal.tsx @@ -1,12 +1,16 @@ import React, { ReactNode, forwardRef } from 'react'; import classes from './StudioModal.module.css'; -import { Modal, ModalProps } from '@digdir/design-system-react'; +import ReactModal from 'react-modal'; // TODO - Replace with component from Designsystemet. Issue: +import { Button } from '@digdir/design-system-react'; +import { useTranslation } from 'react-i18next'; +import { MultiplyIcon } from '@studio/icons'; export type StudioModalProps = { - header: ReactNode; - content: ReactNode; - footer?: ReactNode; -} & Omit; + isOpen: boolean; + onClose: () => void; + title: ReactNode; + children: ReactNode; +}; /** * @component @@ -14,26 +18,54 @@ export type StudioModalProps = { * * @example * } - * content={} - * footer={} - * /> + * isOpen={isOpen} + * onClose={() => setIsOpen(false)} + * title={ + *
+ * + * Some name + *
+ * } + * > + *
+ * + *
+ *
* - * @property {ReactNode}[header] - Header of the modal - * @property {ReactNode}[content] - Content in the mdoal - * @property {ReactNode}[footer] - Optioanl footer in the modal + * @property {boolean}[isOpen] - Flag for if the modal is open + * @property {function}[onClose] - Fucntion to execute when closing modal + * @property {ReactNode}[title] - Title of the modal + * @property {ReactNode}[children] - Content in the modal * - * @returns {JSX.Element} - The rendered component + * @returns {ReactNode} - The rendered component */ export const StudioModal = forwardRef( - ({ header, content, footer, ...rest }: StudioModalProps, ref): JSX.Element => { + ({ isOpen, onClose, title, children, ...rest }: StudioModalProps, ref): ReactNode => { + const { t } = useTranslation(); + return ( - - {header} - {content} - {footer && {footer}} - + +
+ {title} +
+
+
+
{children}
+
); }, ); diff --git a/frontend/packages/policy-editor/src/PolicyEditor.tsx b/frontend/packages/policy-editor/src/PolicyEditor.tsx index be424be6bd1..87657cb9c08 100644 --- a/frontend/packages/policy-editor/src/PolicyEditor.tsx +++ b/frontend/packages/policy-editor/src/PolicyEditor.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useState } from 'react'; import { Alert, Heading, Paragraph } from '@digdir/design-system-react'; import type { PolicyAction, @@ -57,7 +57,6 @@ export const PolicyEditor = ({ usageType, }: PolicyEditorProps): React.ReactNode => { const { t } = useTranslation(); - const modalRef = useRef(null); // TODO - Find out how this should be set. Issue: #10880 const resourceType = usageType === 'app' ? 'urn:altinn' : 'urn:altinn:resource'; @@ -69,6 +68,8 @@ export const PolicyEditor = ({ // Handle the new updated IDs of the rules when a rule is deleted / duplicated const [lastRuleId, setLastRuleId] = useState((policy?.rules?.length ?? 0) + 1); + const [verificationModalOpen, setVerificationModalOpen] = useState(false); + // To keep track of which rule to delete const [ruleIdToDelete, setRuleIdToDelete] = useState('0'); const [showErrorsOnAllRulesAboveNew, setShowErrorsOnAllRulesAboveNew] = useState(false); @@ -89,7 +90,7 @@ export const PolicyEditor = ({ resourceType={resourceType} handleCloneRule={() => handleCloneRule(i)} handleDeleteRule={() => { - modalRef.current?.showModal(); + setVerificationModalOpen(true); setRuleIdToDelete(pr.ruleId); }} showErrors={ @@ -167,7 +168,7 @@ export const PolicyEditor = ({ setPolicyRules(updatedRules); // Reset - modalRef.current?.close(); + setVerificationModalOpen(false); setRuleIdToDelete('0'); handleSavePolicy(updatedRules); @@ -215,8 +216,8 @@ export const PolicyEditor = ({ modalRef.current?.close()} + isOpen={verificationModalOpen} + onClose={() => setVerificationModalOpen(false)} text={t('policy_editor.verification_modal_text')} closeButtonText={t('policy_editor.verification_modal_close_button')} actionButtonText={t('policy_editor.verification_modal_action_button')} diff --git a/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.test.tsx b/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.test.tsx index 091420ff692..70dea523b32 100644 --- a/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.test.tsx +++ b/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.test.tsx @@ -1,31 +1,31 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { render, screen } from '@testing-library/react'; import { VerificationModal, VerificationModalProps } from './VerificationModal'; import { textMock } from '../../../../../testing/mocks/i18nMock'; import { act } from 'react-dom/test-utils'; import userEvent from '@testing-library/user-event'; -const mockButtonText: string = 'Mock Button'; const mockModalText: string = 'Mock modal text'; const mockCloseButtonText: string = 'Close'; const mockActionButtonText: string = 'Confirm'; -const mockOnClose = jest.fn(); -const mockOnPerformAction = jest.fn(); - -const defaultProps: VerificationModalProps = { - onClose: mockOnClose, - text: mockModalText, - closeButtonText: mockCloseButtonText, - actionButtonText: mockActionButtonText, - onPerformAction: mockOnPerformAction, -}; - describe('VerificationModal', () => { afterEach(jest.clearAllMocks); - it('does render the modal when it is open', async () => { - await renderAndOpenModal(); + const mockOnClose = jest.fn(); + const mockOnPerformAction = jest.fn(); + + const defaultProps: VerificationModalProps = { + isOpen: true, + onClose: mockOnClose, + text: mockModalText, + closeButtonText: mockCloseButtonText, + actionButtonText: mockActionButtonText, + onPerformAction: mockOnPerformAction, + }; + + it('does render the modal when it is open', () => { + render(); const modalHeader = screen.getByRole('heading', { name: textMock('policy_editor.verification_modal_heading'), @@ -36,7 +36,7 @@ describe('VerificationModal', () => { }); it('does not render the modal when it is closed', () => { - render(); + render(); const modalHeader = screen.queryByRole('heading', { name: textMock('policy_editor.verification_modal_heading'), @@ -48,7 +48,7 @@ describe('VerificationModal', () => { it('calls "onClose" when the close button is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const closeButton = screen.getByText(mockCloseButtonText); await act(() => user.click(closeButton)); @@ -58,7 +58,7 @@ describe('VerificationModal', () => { it('calls "onPerformAction" when the action button is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const actionButton = screen.getByText(mockActionButtonText); await act(() => user.click(actionButton)); @@ -66,22 +66,3 @@ describe('VerificationModal', () => { expect(mockOnPerformAction).toHaveBeenCalledTimes(1); }); }); - -const renderAndOpenModal = async (props: Partial = {}) => { - const user = userEvent.setup(); - render(); - - const openModalButton = screen.getByRole('button', { name: mockButtonText }); - await act(() => user.click(openModalButton)); -}; - -const TestComponentWithButton = (props: Partial = {}) => { - const modalRef = useRef(null); - - return ( - <> - - - - ); -}; diff --git a/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.tsx b/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.tsx index fac88e6b0b6..437090de7a6 100644 --- a/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.tsx +++ b/frontend/packages/policy-editor/src/components/VerificationModal/VerificationModal.tsx @@ -1,10 +1,11 @@ -import React, { forwardRef } from 'react'; +import React from 'react'; import classes from './VerificationModal.module.css'; import { Button, Heading, Paragraph } from '@digdir/design-system-react'; import { useTranslation } from 'react-i18next'; import { StudioModal } from '@studio/components'; export type VerificationModalProps = { + isOpen: boolean; onClose: () => void; text: string; closeButtonText: string; @@ -17,47 +18,50 @@ export type VerificationModalProps = { * Displays a verification modal. To be used when the user needs one extra level * of chekcing if they really want to perform an action. * + * @property {boolean}[isOpen] - Boolean for if the modal is open or not * @property {function}[onClose] - Function to be executed when closing the modal * @property {string}[text] -The text to display in the modal * @property {string}[closeButtonText] - The text to display on the close button * @property {string}[actionButtonText] - The text to display on the action button * @property {function}[onPerformAction] - Function to be executed when the action button is clicked * - * @returns {JSX.Element} - The rendered component + * @returns {React.ReactNode} - The rendered component */ -export const VerificationModal = forwardRef( - ({ onClose, text, closeButtonText, actionButtonText, onPerformAction }, ref): JSX.Element => { - const { t } = useTranslation(); +export const VerificationModal = ({ + isOpen, + onClose, + text, + closeButtonText, + actionButtonText, + onPerformAction, +}: VerificationModalProps): React.ReactNode => { + const { t } = useTranslation(); - return ( - - - {t('policy_editor.verification_modal_heading')} - + return ( + + + {t('policy_editor.verification_modal_heading')} + + + } + > +
+ {text} +
+
+
- } - content={ -
- {text} -
-
- -
- -
-
- } - /> - ); - }, -); - -VerificationModal.displayName = 'VerificationModal'; + +
+
+
+ ); +}; diff --git a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx index cc423cdfbc0..2e44b02dbb1 100644 --- a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx +++ b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { render as rtlRender, screen, waitForElementToBeRemoved } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ImportResourceModal, ImportResourceModalProps } from './ImportResourceModal'; @@ -10,8 +10,6 @@ import { queriesMock } from 'app-shared/mocks/queriesMock'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { Altinn2LinkService } from 'app-shared/types/Altinn2LinkService'; -const mockButtonText: string = 'Mock Button'; - const mockAltinn2LinkService: Altinn2LinkService = { externalServiceCode: 'code1', externalServiceEditionCode: 'edition1', @@ -27,6 +25,7 @@ const getAltinn2LinkServices = jest .mockImplementation(() => Promise.resolve(mockAltinn2LinkServices)); const defaultProps: ImportResourceModalProps = { + isOpen: true, onClose: mockOnClose, }; @@ -35,7 +34,7 @@ describe('ImportResourceModal', () => { it('selects environment and service, then checks if import button exists', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const importButtonText = textMock('resourceadm.dashboard_import_modal_import_button'); const importButton = screen.queryByRole('button', { name: importButtonText }); @@ -66,7 +65,7 @@ describe('ImportResourceModal', () => { it('calls onClose function when close button is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const closeButton = screen.getByRole('button', { name: textMock('general.cancel') }); await act(() => user.click(closeButton)); @@ -75,7 +74,7 @@ describe('ImportResourceModal', () => { }); it('should be closed by default', () => { - render(); + render({ isOpen: false }); const closeButton = screen.queryByRole('button', { name: textMock('general.cancel') }); expect(closeButton).not.toBeInTheDocument(); @@ -83,7 +82,7 @@ describe('ImportResourceModal', () => { it('calls import resource from Altinn 2 when import is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const [, environmentSelect] = screen.getAllByLabelText( textMock('resourceadm.dashboard_import_modal_select_env'), @@ -118,27 +117,8 @@ const render = (props: Partial = {}) => { return rtlRender( - + , ); }; - -const renderAndOpenModal = async (props: Partial = {}) => { - const user = userEvent.setup(); - render(props); - - const openModalButton = screen.getByRole('button', { name: mockButtonText }); - await act(() => user.click(openModalButton)); -}; - -const TestComponentWithButton = (props: Partial = {}) => { - const modalRef = useRef(null); - - return ( - <> - - - - ); -}; diff --git a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.tsx b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.tsx index d24fe7d6ce2..c29bc929a28 100644 --- a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.tsx +++ b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useState } from 'react'; +import React, { useState } from 'react'; import classes from './ImportResourceModal.module.css'; import { Modal } from '../Modal'; import { Button, Select } from '@digdir/design-system-react'; @@ -16,6 +16,7 @@ import { ServerCodes } from 'app-shared/enums/ServerCodes'; const environmentOptions = ['AT21', 'AT22', 'AT23', 'AT24', 'TT02', 'PROD']; export type ImportResourceModalProps = { + isOpen: boolean; onClose: () => void; }; @@ -28,96 +29,99 @@ export type ImportResourceModalProps = { * When the environment and service is selected, the button to start planning the * importing will be available. * + * @property {boolean}[isOpen] - Boolean for if the modal is open * @property {function}[onClose] - Function to handle close * - * @returns {JSX.Element} - The rendered component + * @returns {React.ReactNode} - The rendered component */ -export const ImportResourceModal = forwardRef( - ({ onClose }, ref): JSX.Element => { - const { t } = useTranslation(); +export const ImportResourceModal = ({ + isOpen, + onClose, +}: ImportResourceModalProps): React.ReactNode => { + const { t } = useTranslation(); - const { selectedContext } = useParams(); - const repo = `${selectedContext}-resources`; + const { selectedContext } = useParams(); + const repo = `${selectedContext}-resources`; - const navigate = useNavigate(); + const navigate = useNavigate(); - const [selectedEnv, setSelectedEnv] = useState(); - const [selectedService, setSelectedService] = useState(); + const [selectedEnv, setSelectedEnv] = useState(); + const [selectedService, setSelectedService] = useState(); - const [resourceIdExists, setResourceIdExists] = useState(false); + const [resourceIdExists, setResourceIdExists] = useState(false); - const { mutate: importResourceFromAltinn2Mutation } = - useImportResourceFromAltinn2Mutation(selectedContext); + const { mutate: importResourceFromAltinn2Mutation } = + useImportResourceFromAltinn2Mutation(selectedContext); - const handleClose = () => { - onClose(); - setSelectedEnv(undefined); - }; + /** + * Reset fields on close + */ + const handleClose = () => { + onClose(); + setSelectedEnv(undefined); + }; - /** - * Import the resource from Altinn 2, and navigate to about page on success - */ - const handleImportResource = () => { - importResourceFromAltinn2Mutation( - { - environment: selectedEnv, - serviceCode: selectedService.externalServiceCode, - serviceEdition: selectedService.externalServiceEditionCode, + /** + * Import the resource from Altinn 2, and navigate to about page on success + */ + const handleImportResource = () => { + importResourceFromAltinn2Mutation( + { + environment: selectedEnv, + serviceCode: selectedService.externalServiceCode, + serviceEdition: selectedService.externalServiceEditionCode, + }, + { + onSuccess: (resource: Resource) => { + navigate(getResourcePageURL(selectedContext, repo, resource.identifier, 'about')); }, - { - onSuccess: (resource: Resource) => { - navigate(getResourcePageURL(selectedContext, repo, resource.identifier, 'about')); - }, - onError: (error: AxiosError) => { - if (error.response.status === ServerCodes.Conflict) { - setResourceIdExists(true); - } - }, + onError: (error: AxiosError) => { + if (error.response.status === ServerCodes.Conflict) { + setResourceIdExists(true); + } }, - ); - }; - - return ( - -
- ({ value: e, label: e }))} + onChange={(e: EnvironmentType) => setSelectedEnv(e)} + value={selectedEnv} + label={t('resourceadm.dashboard_import_modal_select_env')} + /> +
+ {selectedEnv && ( + + setSelectedService(altinn2LinkService) + } + resourceIdExists={resourceIdExists} + /> + )} +
+ + {selectedEnv && selectedService && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/frontend/resourceadm/components/MergeConflictModal/MergeConflictModal.tsx b/frontend/resourceadm/components/MergeConflictModal/MergeConflictModal.tsx index 247d1e6d98b..6b321b8ae6e 100644 --- a/frontend/resourceadm/components/MergeConflictModal/MergeConflictModal.tsx +++ b/frontend/resourceadm/components/MergeConflictModal/MergeConflictModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, forwardRef } from 'react'; +import React, { useState } from 'react'; import classes from './MergeConflictModal.module.css'; import { useTranslation } from 'react-i18next'; import { Button, Link, Paragraph, Label } from '@digdir/design-system-react'; @@ -8,8 +8,22 @@ import { get } from 'app-shared/utils/networking'; import { Modal } from '../Modal'; type MergeConflictModalProps = { + /** + * Boolean for if the modal is open + */ + isOpen: boolean; + /** + * Function to be executed when the merge is solved + * @returns void + */ handleSolveMerge: () => void; + /** + * The name of the organisation + */ org: string; + /** + * The name of the repo + */ repo: string; }; @@ -17,54 +31,59 @@ type MergeConflictModalProps = { * @component * Displays the modal telling the user that there is a merge conflict * + * @property {boolean}[isOpen] - Boolean for if the modal is open * @property {function}[handleSolveMerge] - Function to be executed when the merge is solved * @property {string}[org] - The name of the organisation * @property {string}[repo] - The name of the repo * - * @returns {JSX.Element} - The rendered component + * @returns {React.ReactNode} - The rendered component */ -export const MergeConflictModal = forwardRef( - ({ handleSolveMerge, org, repo }, ref): JSX.Element => { - const { t } = useTranslation(); +export const MergeConflictModal = ({ + isOpen, + handleSolveMerge, + org, + repo, +}: MergeConflictModalProps): React.ReactNode => { + const { t } = useTranslation(); - const removeChangesModalRef = useRef(null); + const [resetModalOpen, setResetModalOpen] = useState(false); - const handleClickResetRepo = () => { - get(repoResetPath(org, repo)); - handleSolveMerge(); - }; + /** + * Function that resets the repo + */ + const handleClickResetRepo = () => { + get(repoResetPath(org, repo)); + handleSolveMerge(); + }; - return ( - - {t('merge_conflict.body1')} - {t('merge_conflict.body2')} -
-
- - - {t('merge_conflict.download_edited_files')} + return ( + + {t('merge_conflict.body1')} + {t('merge_conflict.body2')} +
+
+ + + {t('merge_conflict.download_edited_files')} + +
+ + {t('merge_conflict.download_entire_repo')} -
- - {t('merge_conflict.download_entire_repo')} - -
- - removeChangesModalRef.current?.close()} - handleClickResetRepo={handleClickResetRepo} - repo={repo} - />
- - ); - }, -); - -MergeConflictModal.displayName = 'MergeConflictModal'; + + setResetModalOpen(false)} + handleClickResetRepo={handleClickResetRepo} + repo={repo} + /> +
+
+ ); +}; diff --git a/frontend/resourceadm/components/MergeConflictModal/RemoveChangesModal/RemoveChangesModal.tsx b/frontend/resourceadm/components/MergeConflictModal/RemoveChangesModal/RemoveChangesModal.tsx index 126125e7a9c..2a70abcb259 100644 --- a/frontend/resourceadm/components/MergeConflictModal/RemoveChangesModal/RemoveChangesModal.tsx +++ b/frontend/resourceadm/components/MergeConflictModal/RemoveChangesModal/RemoveChangesModal.tsx @@ -1,13 +1,28 @@ -import React, { forwardRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; +import React, { useState } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; import { Button, Textfield, Paragraph } from '@digdir/design-system-react'; import classes from './RemoveChangesModal.module.css'; import { Modal } from 'resourceadm/components/Modal'; import { ScreenReaderSpan } from 'resourceadm/components/ScreenReaderSpan'; type RemoveChangesModalProps = { + /** + * Boolean for if the modal is open + */ + isOpen: boolean; + /** + * Function to handle close + * @returns void + */ onClose: () => void; + /** + * Function to be executed when the reset repo is clicked + * @returns void + */ handleClickResetRepo: () => void; + /** + * The name of the repo + */ repo: string; }; @@ -15,66 +30,78 @@ type RemoveChangesModalProps = { * @Component * Content to be displayed inside the modal where the user removes their changes in a merge conflict * + * @property {boolean}[isOpen] - Boolean for if the modal is open * @property {function}[onClose] - Function to handle close * @property {function}[handleClickResetRepo] - Function to be executed when the reset repo is clicked * @property {string}[repo] - The name of the repo * - * @returns {JSX.Element} - The rendered component + * @returns {React.ReactNode} - The rendered component */ -export const RemoveChangesModal = forwardRef( - ({ onClose, handleClickResetRepo, repo }, ref): JSX.Element => { - const { t } = useTranslation(); +export const RemoveChangesModal = ({ + isOpen, + onClose, + handleClickResetRepo, + repo, +}: RemoveChangesModalProps): React.ReactNode => { + const { t } = useTranslation(); - const [deleteRepoName, setDeleteRepoName] = useState(''); + const [deleteRepoName, setDeleteRepoName] = useState(''); - const handleClose = () => { - setDeleteRepoName(''); - onClose(); - }; + /** + * Handles the closing of the modal + */ + const handleClose = () => { + setDeleteRepoName(''); + onClose(); + }; - const handleDelete = () => { - handleClose(); - handleClickResetRepo(); - }; + /** + * Handles the deletion of the changes + */ + const handleDelete = () => { + handleClose(); + handleClickResetRepo(); + }; - return ( - - - {t('settings_modal.local_changes_tab_delete_modal_text')} - -
- setDeleteRepoName(e.target.value)} - aria-labelledby='delete-changes' - /> - -
-
- - -
-
- ); - }, -); - -RemoveChangesModal.displayName = 'RemoveChangesModal'; + return ( + + + }} + /> + +
+ setDeleteRepoName(e.target.value)} + aria-labelledby='delete-changes' + /> + +
+
+ + +
+
+ ); +}; diff --git a/frontend/resourceadm/components/Modal/Modal.tsx b/frontend/resourceadm/components/Modal/Modal.tsx index 2a1e9f05601..90c91b34d3d 100644 --- a/frontend/resourceadm/components/Modal/Modal.tsx +++ b/frontend/resourceadm/components/Modal/Modal.tsx @@ -1,10 +1,11 @@ -import React, { ReactNode, forwardRef } from 'react'; +import React, { ReactNode } from 'react'; import classes from './Modal.module.css'; import cn from 'classnames'; import { Heading } from '@digdir/design-system-react'; import { StudioModal } from '@studio/components'; type ModalProps = { + isOpen: boolean; title: string; onClose?: () => void; children: ReactNode; @@ -16,10 +17,11 @@ type ModalProps = { * Modal component implementing the react-modal. * * @example - * + * *
...
*
* + * @property {boolean}[isOpen] - Boolean for if the modal is open * @property {string}[title] - Title to be displayed in the modal * @property {function}[onClose] - Function to handle close of the modal * @property {ReactNode}[children] - React components inside the Modal @@ -27,23 +29,26 @@ type ModalProps = { * * @returns {React.ReactNode} - The rendered component */ -export const Modal = forwardRef( - ({ title, onClose, children, contentClassName }, ref): React.ReactNode => { - return ( - - - {title} - -
- } - content={
{children}
} - /> - ); - }, -); - -Modal.displayName = 'Modal'; +export const Modal = ({ + isOpen, + title, + onClose, + children, + contentClassName, +}: ModalProps): React.ReactNode => { + return ( + + + {title} + +
+ } + > +
{children}
+
+ ); +}; diff --git a/frontend/resourceadm/components/NavigationModal/NavigationModal.test.tsx b/frontend/resourceadm/components/NavigationModal/NavigationModal.test.tsx index 59d520db553..ca420c0ec6a 100644 --- a/frontend/resourceadm/components/NavigationModal/NavigationModal.test.tsx +++ b/frontend/resourceadm/components/NavigationModal/NavigationModal.test.tsx @@ -1,33 +1,32 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { NavigationModal, NavigationModalProps } from './NavigationModal'; import { textMock } from '../../../testing/mocks/i18nMock'; import { act } from 'react-dom/test-utils'; -const mockButtonText: string = 'Mock Button'; - -const mockOnClose = jest.fn(); -const mockOnNavigate = jest.fn(); - -const defaultProps: NavigationModalProps = { - onClose: mockOnClose, - onNavigate: mockOnNavigate, - title: textMock('resourceadm.resource_navigation_modal_title_policy'), -}; - describe('NavigationModal', () => { - afterEach(jest.clearAllMocks); + const mockOnClose = jest.fn(); + const mockOnNavigate = jest.fn(); + + const defaultProps: NavigationModalProps = { + isOpen: true, + onClose: mockOnClose, + onNavigate: mockOnNavigate, + title: textMock('resourceadm.resource_navigation_modal_title_policy'), + }; it('should be closed by default', () => { - render(); + render( + {}} onNavigate={mockOnNavigate} title='tit' />, + ); const closeButton = screen.queryByRole('button', { name: textMock('general.cancel') }); expect(closeButton).not.toBeInTheDocument(); }); it('calls onClose function when close button is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const closeButton = screen.getByRole('button', { name: textMock('resourceadm.resource_navigation_modal_button_stay'), @@ -39,7 +38,7 @@ describe('NavigationModal', () => { it('calls onNavigate function when navigate button is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(); const navigateButton = screen.getByRole('button', { name: textMock('resourceadm.resource_navigation_modal_button_move_on'), @@ -49,22 +48,3 @@ describe('NavigationModal', () => { expect(mockOnNavigate).toHaveBeenCalled(); }); }); - -const renderAndOpenModal = async (props: Partial = {}) => { - const user = userEvent.setup(); - render(); - - const openModalButton = screen.getByRole('button', { name: mockButtonText }); - await act(() => user.click(openModalButton)); -}; - -const TestComponentWithButton = (props: Partial = {}) => { - const modalRef = useRef(null); - - return ( - <> - - - - ); -}; diff --git a/frontend/resourceadm/components/NavigationModal/NavigationModal.tsx b/frontend/resourceadm/components/NavigationModal/NavigationModal.tsx index 796246c29af..75f8d40a777 100644 --- a/frontend/resourceadm/components/NavigationModal/NavigationModal.tsx +++ b/frontend/resourceadm/components/NavigationModal/NavigationModal.tsx @@ -1,12 +1,27 @@ -import React, { forwardRef } from 'react'; +import React from 'react'; import classes from './NavigationModal.module.css'; import { Button, Paragraph } from '@digdir/design-system-react'; import { Modal } from '../Modal'; import { useTranslation } from 'react-i18next'; export type NavigationModalProps = { + /** + * Boolean for if the modal is open + */ + isOpen: boolean; + /** + * Function to handle close + * @returns void + */ onClose: () => void; + /** + * Function to be executed when navigating + * @returns void + */ onNavigate: () => void; + /** + * The title in the modal + */ title: string; }; @@ -14,34 +29,36 @@ export type NavigationModalProps = { * @component * Displays the modal telling the user that there is a merge conflict * + * @property {boolean}[isOpen] - Boolean for if the modal is open * @property {function}[onClose] - Function to handle close * @property {function}[onNavigate] - Function to be executed when navigating * @property {string}[title] - The title in the modal * - * @returns {JSX.Element} - The rendered component + * @returns {React.ReactNode} - The rendered component */ -export const NavigationModal = forwardRef( - ({ onClose, onNavigate, title }, ref): JSX.Element => { - const { t } = useTranslation(); +export const NavigationModal = ({ + isOpen, + onClose, + onNavigate, + title, +}: NavigationModalProps): React.ReactNode => { + const { t } = useTranslation(); - return ( - - - {t('resourceadm.resource_navigation_modal_text')} - -
-
- -
-
-
- ); - }, -); - -NavigationModal.displayName = 'NavigationModal'; + + + + ); +}; diff --git a/frontend/resourceadm/components/NewResourceModal/NewResourceModal.test.tsx b/frontend/resourceadm/components/NewResourceModal/NewResourceModal.test.tsx index a1d0b750a8f..dd2a2534600 100644 --- a/frontend/resourceadm/components/NewResourceModal/NewResourceModal.test.tsx +++ b/frontend/resourceadm/components/NewResourceModal/NewResourceModal.test.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React from 'react'; import { render as rtlRender, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { NewResourceModal, NewResourceModalProps } from './NewResourceModal'; @@ -9,26 +9,23 @@ import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import { queriesMock } from 'app-shared/mocks/queriesMock'; -const mockButtonText: string = 'Mock Button'; - -const mockOnClose = jest.fn(); - -const defaultProps: NewResourceModalProps = { - onClose: mockOnClose, -}; - describe('NewResourceModal', () => { - afterEach(jest.clearAllMocks); + const mockOnClose = jest.fn(); + + const defaultProps: NewResourceModalProps = { + isOpen: true, + onClose: mockOnClose, + }; it('should be closed by default', () => { - render(); + render({ isOpen: false, onClose: () => {} }); const closeButton = screen.queryByRole('button', { name: textMock('general.cancel') }); expect(closeButton).not.toBeInTheDocument(); }); it('calls onClose function when close button is clicked', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(defaultProps); const closeButton = screen.getByRole('button', { name: textMock('general.cancel') }); await act(() => user.click(closeButton)); @@ -36,8 +33,8 @@ describe('NewResourceModal', () => { expect(mockOnClose).toHaveBeenCalled(); }); - test('that create button should be disabled until the form is valid', async () => { - await renderAndOpenModal(); + test('that create button should be disabled until the form is valid', () => { + render(defaultProps); const createButton = screen.getByRole('button', { name: textMock('resourceadm.dashboard_create_modal_create_button'), @@ -47,7 +44,7 @@ describe('NewResourceModal', () => { test('that create button should be enabled when the form is valid', async () => { const user = userEvent.setup(); - await renderAndOpenModal(); + render(defaultProps); const titleInput = screen.getByLabelText( textMock('resourceadm.dashboard_resource_name_and_id_resource_name'), @@ -61,31 +58,12 @@ describe('NewResourceModal', () => { }); }); -const render = (props: Partial = {}) => { +const render = (props: NewResourceModalProps) => { return rtlRender( - + , ); }; - -const renderAndOpenModal = async (props: Partial = {}) => { - const user = userEvent.setup(); - render(props); - - const openModalButton = screen.getByRole('button', { name: mockButtonText }); - await act(() => user.click(openModalButton)); -}; - -const TestComponentWithButton = (props: Partial = {}) => { - const modalRef = useRef(null); - - return ( - <> - - - - ); -}; diff --git a/frontend/resourceadm/components/NewResourceModal/NewResourceModal.tsx b/frontend/resourceadm/components/NewResourceModal/NewResourceModal.tsx index 2b931259d1f..962392db0e3 100644 --- a/frontend/resourceadm/components/NewResourceModal/NewResourceModal.tsx +++ b/frontend/resourceadm/components/NewResourceModal/NewResourceModal.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useState } from 'react'; +import React, { useState } from 'react'; import classes from './NewResourceModal.module.css'; import { Button } from '@digdir/design-system-react'; import { Modal } from '../Modal'; @@ -12,6 +12,7 @@ import { replaceWhiteSpaceWithHyphens } from 'resourceadm/utils/stringUtils'; import { ServerCodes } from 'app-shared/enums/ServerCodes'; export type NewResourceModalProps = { + isOpen: boolean; onClose: () => void; }; @@ -19,148 +20,145 @@ export type NewResourceModalProps = { * @component * Displays the modal telling the user that there is a merge conflict * + * @property {boolean}[isOpen] - Boolean for if the modal is open * @property {function}[onClose] - Function to handle close * - * @returns {JSX.Element} - The rendered component + * @returns {React.ReactNode} - The rendered component */ -export const NewResourceModal = forwardRef( - ({ onClose }, ref): JSX.Element => { - const { t } = useTranslation(); +export const NewResourceModal = ({ isOpen, onClose }: NewResourceModalProps): React.ReactNode => { + const { t } = useTranslation(); - const navigate = useNavigate(); + const navigate = useNavigate(); - const { selectedContext } = useParams(); - const repo = `${selectedContext}-resources`; + const { selectedContext } = useParams(); + const repo = `${selectedContext}-resources`; - const [id, setId] = useState(''); - const [title, setTitle] = useState(''); - const [editIdFieldOpen, setEditIdFieldOpen] = useState(false); - const [resourceIdExists, setResourceIdExists] = useState(false); - const [bothFieldsHaveSameValue, setBothFieldsHaveSameValue] = useState(true); + const [id, setId] = useState(''); + const [title, setTitle] = useState(''); + const [editIdFieldOpen, setEditIdFieldOpen] = useState(false); + const [resourceIdExists, setResourceIdExists] = useState(false); + const [bothFieldsHaveSameValue, setBothFieldsHaveSameValue] = useState(true); - // Mutation function to create new resource - const { mutate: createNewResource } = useCreateResourceMutation(selectedContext); + // Mutation function to create new resource + const { mutate: createNewResource } = useCreateResourceMutation(selectedContext); - /** - * Creates a new resource in backend, and navigates if success - */ - const handleCreateNewResource = () => { - const idAndTitle: NewResource = { - identifier: id, - title: { - nb: title, - nn: '', - en: '', - }, - }; - - createNewResource(idAndTitle, { - onSuccess: () => - navigate(getResourcePageURL(selectedContext, repo, idAndTitle.identifier, 'about')), - onError: (error: any) => { - if (error.response.status === ServerCodes.Conflict) { - setResourceIdExists(true); - setEditIdFieldOpen(true); - } - }, - }); + /** + * Creates a new resource in backend, and navigates if success + */ + const handleCreateNewResource = () => { + const idAndTitle: NewResource = { + identifier: id, + title: { + nb: title, + nn: '', + en: '', + }, }; - /** - * Replaces the spaces in the value typed with '-'. - */ - const handleIDInput = (val: string) => { - setId(replaceWhiteSpaceWithHyphens(val)); - setResourceIdExists(false); - }; + createNewResource(idAndTitle, { + onSuccess: () => + navigate(getResourcePageURL(selectedContext, repo, idAndTitle.identifier, 'about')), + onError: (error: any) => { + if (error.response.status === ServerCodes.Conflict) { + setResourceIdExists(true); + setEditIdFieldOpen(true); + } + }, + }); + }; - /** - * Updates the value of the title. If the edit field is not open, - * then it updates the ID to the same as the title. - * - * @param val the title value typed - */ - const handleEditTitle = (val: string) => { - if (!editIdFieldOpen && bothFieldsHaveSameValue) { - setId(replaceWhiteSpaceWithHyphens(val)); - } - setTitle(val); - }; + /** + * Replaces the spaces in the value typed with '-'. + */ + const handleIDInput = (val: string) => { + setId(replaceWhiteSpaceWithHyphens(val)); + setResourceIdExists(false); + }; - /** - * Handles the click of the edit button. If we click the edit button - * so that it closes the edit field, the id is set to the title. - * - * @param isOpened the value of the button when it is pressed - * @param saveChanges if the save button is pressed, keep id and title separate - */ - const handleClickEditButton = (isOpened: boolean, saveChanges: boolean) => { - setEditIdFieldOpen(isOpened); - if (saveChanges) { - setBothFieldsHaveSameValue(false); - return; - } - if (!isOpened) { - setBothFieldsHaveSameValue(true); - const shouldSetTitleToId = title !== id; - if (shouldSetTitleToId) { - setId(replaceWhiteSpaceWithHyphens(title)); - } + /** + * Updates the value of the title. If the edit field is not open, + * then it updates the ID to the same as the title. + * + * @param val the title value typed + */ + const handleEditTitle = (val: string) => { + if (!editIdFieldOpen && bothFieldsHaveSameValue) { + setId(replaceWhiteSpaceWithHyphens(val)); + } + setTitle(val); + }; + + /** + * Handles the click of the edit button. If we click the edit button + * so that it closes the edit field, the id is set to the title. + * + * @param isOpened the value of the button when it is pressed + * @param saveChanges if the save button is pressed, keep id and title separate + */ + const handleClickEditButton = (isOpened: boolean, saveChanges: boolean) => { + setEditIdFieldOpen(isOpened); + if (saveChanges) { + setBothFieldsHaveSameValue(false); + return; + } + if (!isOpened) { + setBothFieldsHaveSameValue(true); + const shouldSetTitleToId = title !== id; + if (shouldSetTitleToId) { + setId(replaceWhiteSpaceWithHyphens(title)); } - }; + } + }; - /** - * Closes the modal and resets the fields - */ - const handleClose = () => { - onClose(); - setId(''); - setTitle(''); - setEditIdFieldOpen(false); - setResourceIdExists(false); - }; + /** + * Closes the modal and resets the fields + */ + const handleClose = () => { + onClose(); + setId(''); + setTitle(''); + setEditIdFieldOpen(false); + setResourceIdExists(false); + }; - return ( - - - handleClickEditButton(!editIdFieldOpen, saveChanges) - } - resourceIdExists={resourceIdExists} - bothFieldsHaveSameValue={bothFieldsHaveSameValue} - className={classes.resourceNameAndId} - /> -
-
- -
-
-
- ); - }, -); - -NewResourceModal.displayName = 'NewResourceModal'; + + + + ); +}; diff --git a/frontend/resourceadm/pages/ResourceDashboardPage/ResourceDashboardPage.tsx b/frontend/resourceadm/pages/ResourceDashboardPage/ResourceDashboardPage.tsx index 7a33895f9dc..76156b3522c 100644 --- a/frontend/resourceadm/pages/ResourceDashboardPage/ResourceDashboardPage.tsx +++ b/frontend/resourceadm/pages/ResourceDashboardPage/ResourceDashboardPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import classes from './ResourceDashboardPage.module.css'; import { Button, Spinner, Heading } from '@digdir/design-system-react'; @@ -33,9 +33,8 @@ export const ResourceDashboardPage = (): React.ReactNode => { const [searchValue, setSearchValue] = useState(''); const [hasMergeConflict, setHasMergeConflict] = useState(false); - const importModalRef = useRef(null); - const newResourceModalRef = useRef(null); - const mergeConflictModalRef = useRef(null); + const [newResourceModalOpen, setNewResourceModalOpen] = useState(false); + const [importModalOpen, setImportModalOpen] = useState(false); // Get metadata with queries const { data: repoStatus, refetch } = useRepoStatusQuery(selectedContext, repo); @@ -54,13 +53,6 @@ export const ResourceDashboardPage = (): React.ReactNode => { } }, [repoStatus]); - // Open the modal when there is a merge conflict - useEffect(() => { - if (hasMergeConflict && mergeConflictModalRef.current) { - mergeConflictModalRef.current.showModal(); - } - }, [hasMergeConflict]); - const filteredResourceList = filterTableData(searchValue, resourceListData ?? []); const handleNavigateToResource = (id: string) => { @@ -111,7 +103,7 @@ export const ResourceDashboardPage = (): React.ReactNode => { color='second' icon={} iconPlacement='right' - onClick={() => importModalRef.current?.showModal()} + onClick={() => setImportModalOpen(true)} size='medium' > {t('resourceadm.dashboard_import_resource')} @@ -122,7 +114,7 @@ export const ResourceDashboardPage = (): React.ReactNode => { color='second' icon={} iconPlacement='right' - onClick={() => newResourceModalRef.current?.showModal()} + onClick={() => setNewResourceModalOpen(true)} size='medium' > {t('resourceadm.dashboard_create_resource')} @@ -131,17 +123,19 @@ export const ResourceDashboardPage = (): React.ReactNode => {
{displayContent()}
- + {hasMergeConflict && ( + + )} newResourceModalRef.current?.close()} + isOpen={newResourceModalOpen} + onClose={() => setNewResourceModalOpen(false)} /> - importModalRef.current?.close()} /> + setImportModalOpen(false)} />
); }; diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx index ae24346a025..2ced58b5a69 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import type { NavigationBarPage } from 'resourceadm/types/global'; import classes from './ResourcePage.module.css'; @@ -53,6 +53,8 @@ export const ResourcePage = (): React.ReactNode => { // Handle the state of resource and policy errors const [showResourceErrors, setShowResourceErrors] = useState(false); const [showPolicyErrors, setShowPolicyErrors] = useState(false); + const [resourceErrorModalOpen, setResourceErrorModalOpen] = useState(false); + const [policyErrorModalOpen, setPolicyErrorModalOpen] = useState(false); // Get the metadata for Gitea const { data: repoStatus, refetch } = useRepoStatusQuery(selectedContext, repo); @@ -79,10 +81,6 @@ export const ResourcePage = (): React.ReactNode => { // Mutation function for editing a resource const { mutate: editResource } = useEditResourceMutation(selectedContext, repo, resourceId); - const resourceErrorModalRef = useRef(null); - const policyErrorModalRef = useRef(null); - const mergeConflictModalRef = useRef(null); - /** * If repostatus is not undefined, set the flags for if the repo has merge * conflict and if the repo is in sync @@ -100,13 +98,6 @@ export const ResourcePage = (): React.ReactNode => { setCurrentPage(pageType as NavigationBarPage); }, [pageType]); - // Open the modal when there is a merge conflict - useEffect(() => { - if (hasMergeConflict && mergeConflictModalRef.current) { - mergeConflictModalRef.current.showModal(); - } - }, [hasMergeConflict]); - /** * Navigates to the selected page */ @@ -125,7 +116,7 @@ export const ResourcePage = (): React.ReactNode => { } else { setShowResourceErrors(true); setNextPage(page); - resourceErrorModalRef.current?.showModal(); + setResourceErrorModalOpen(true); } } // Validate Ppolicy and display errors + modal @@ -139,7 +130,7 @@ export const ResourcePage = (): React.ReactNode => { } else { setShowPolicyErrors(true); setNextPage(page); - policyErrorModalRef.current?.showModal(); + setPolicyErrorModalOpen(true); } } // Else navigate @@ -154,8 +145,8 @@ export const ResourcePage = (): React.ReactNode => { */ const handleNavigation = (newPage: NavigationBarPage) => { setCurrentPage(newPage); - policyErrorModalRef.current?.close(); - resourceErrorModalRef.current?.close(); + setPolicyErrorModalOpen(false); + setResourceErrorModalOpen(false); refetch(); navigate(getResourcePageURL(selectedContext, repo, resourceId, newPage)); }; @@ -300,28 +291,34 @@ export const ResourcePage = (): React.ReactNode => { )} )} - - { - policyErrorModalRef.current?.close(); - }} - onNavigate={() => handleNavigation(nextPage)} - title={t('resourceadm.resource_navigation_modal_title_policy')} - /> - { - resourceErrorModalRef.current?.close(); - }} - onNavigate={() => handleNavigation(nextPage)} - title={t('resourceadm.resource_navigation_modal_title_resource')} - /> + {hasMergeConflict && ( + + )} + {policyErrorModalOpen && ( + { + setPolicyErrorModalOpen(false); + }} + onNavigate={() => handleNavigation(nextPage)} + title={t('resourceadm.resource_navigation_modal_title_policy')} + /> + )} + {resourceErrorModalOpen && ( + { + setResourceErrorModalOpen(false); + }} + onNavigate={() => handleNavigation(nextPage)} + title={t('resourceadm.resource_navigation_modal_title_resource')} + /> + )} ); }; diff --git a/frontend/testing/setupTests.ts b/frontend/testing/setupTests.ts index e1b6614b2c4..167290ee76c 100644 --- a/frontend/testing/setupTests.ts +++ b/frontend/testing/setupTests.ts @@ -40,14 +40,6 @@ class ResizeObserver { } window.ResizeObserver = ResizeObserver; -// Workaround for the known issue. For more info, see this: https://github.com/jsdom/jsdom/issues/3294#issuecomment-1268330372 -HTMLDialogElement.prototype.showModal = jest.fn(function mock(this: HTMLDialogElement) { - this.open = true; -}); -HTMLDialogElement.prototype.close = jest.fn(function mock(this: HTMLDialogElement) { - this.open = false; -}); - // I18next mocks. The useTranslation and Trans mocks apply the textMock function on the text key, so that it can be used to address the texts in the tests. jest.mock('i18next', () => ({ use: () => ({ init: jest.fn() }) })); jest.mock('react-i18next', () => ({ From 5bc0e1c01f99475e27f201b546928ebd8c7a29cf Mon Sep 17 00:00:00 2001 From: Nina Kylstad Date: Tue, 12 Dec 2023 16:36:37 +0100 Subject: [PATCH 04/21] use Element as superclass and uppercase types (#11849) --- .../src/bpmnProviders/SupportedPaletteProvider.js | 4 ++-- .../process-editor/src/extensions/altinnCustomTasks.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/packages/process-editor/src/bpmnProviders/SupportedPaletteProvider.js b/frontend/packages/process-editor/src/bpmnProviders/SupportedPaletteProvider.js index ea04760e6ca..d52659a103c 100644 --- a/frontend/packages/process-editor/src/bpmnProviders/SupportedPaletteProvider.js +++ b/frontend/packages/process-editor/src/bpmnProviders/SupportedPaletteProvider.js @@ -20,7 +20,7 @@ class SupportedPaletteProvider { const extensionElements = bpmnFactory.create('bpmn:ExtensionElements', { values: [ - bpmnFactory.create('altinn:taskExtension', { + bpmnFactory.create('altinn:TaskExtension', { taskType: taskType, }), ], @@ -42,7 +42,7 @@ class SupportedPaletteProvider { const extensionElements = bpmnFactory.create('bpmn:ExtensionElements', { values: [ - bpmnFactory.create('altinn:taskExtension', { + bpmnFactory.create('altinn:TaskExtension', { taskType: taskType, actions: bpmnFactory.create('altinn:Actions', { action: ['sign', 'reject'], diff --git a/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts b/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts index 9b3d3d04fb9..365cd684c64 100644 --- a/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts +++ b/frontend/packages/process-editor/src/extensions/altinnCustomTasks.ts @@ -7,8 +7,8 @@ export const altinnCustomTasks = { }, types: [ { - name: 'taskExtension', - superClass: ['bpmn:ExtensionElements'], + name: 'TaskExtension', + superClass: ['Element'], properties: [ { name: 'taskType', From f343b22ae95608d2cac193e0cc4eb0134015a98d Mon Sep 17 00:00:00 2001 From: Nina Kylstad Date: Tue, 12 Dec 2023 21:13:07 +0100 Subject: [PATCH 05/21] css ellipsis for text overflow (#11850) * css ellipsis for text overflow * use min-width: 0 --- .../TreeView/TreeViewItem/TreeViewItem.module.css | 6 ++++++ .../src/components/TreeView/TreeViewItem/TreeViewItem.tsx | 2 +- .../src/containers/DesignView/DesignView.module.css | 1 - .../FormItem/FormItemTitle/FormItemTitle.module.css | 1 + 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.module.css b/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.module.css index e5767c1b377..62feb4b9b88 100644 --- a/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.module.css +++ b/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.module.css @@ -40,3 +40,9 @@ margin-left: var(--vertical-line-left-spacing); box-shadow: var(--vertical-line-colour) var(--vertical-line-width) 0 inset; } + +.label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.tsx b/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.tsx index 30669c511ee..1d256848d48 100644 --- a/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.tsx +++ b/frontend/packages/shared/src/components/TreeView/TreeViewItem/TreeViewItem.tsx @@ -123,7 +123,7 @@ export const TreeViewItem = ({ type='button' variant='tertiary' > - {label} +
{label}
); diff --git a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css index 1a94552a923..83a0b8a5d24 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css +++ b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css @@ -25,7 +25,6 @@ } .wrapper { - display: flex; align-items: flex-start; justify-content: space-between; overflow-wrap: anywhere; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css index 7e3ced478ea..e0083ff0da6 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css @@ -4,6 +4,7 @@ .label { flex: 1; + min-width: 0; } .root:hover .label { From 4b59daf5ad371365b3813f863f7510a100207235 Mon Sep 17 00:00:00 2001 From: Nina Kylstad Date: Tue, 12 Dec 2023 21:45:32 +0100 Subject: [PATCH 06/21] chore(deps): update labeler config (#11834) * update labeler config * test for pr labeler - REVERT BEFORE MERGE * revert test --- .github/labeler.yml | 70 ++++++++++++++++---------------- .github/workflows/pr-labeler.yml | 2 +- 2 files changed, 35 insertions(+), 37 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 164265069b0..d28539b007a 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,72 +1,70 @@ # Labels for solutions solution/platform: - - 'src/Altinn.Platform/**/*' +- changed-files: + - any-glob-to-any-file: 'src/Altinn.Platform/**/*' solution/app-backend: - - 'src/studio/AppTemplates/**/*' +- changed-files: + - any-glob-to-any-file: 'src/studio/AppTemplates/**/*' solution/studio/designer: - - 'backend/**/*' - - 'frontend/**/*' +- changed-files: + - any-glob-to-any-file: ['backend/**/*', 'frontend/**/*'] solution/studio/repos: - - 'gitea/**/*' +- changed-files: + - any-glob-to-any-file: 'gitea/**/*' # Labels for areas area/app-deploy: - - 'frontend/app-development/features/appPublish/**/*' - - 'frontend/app-development/sharedResources/appDeployment/**/*' - - 'backend/src/Designer/Controllers/DeploymentsController.cs' - - 'src/studio/AppTemplates/AspNet/deployment/**/*' +- changed-files: + - any-glob-to-any-file: ['frontend/app-development/features/appPublish/**/*', 'frontend/app-development/sharedResources/appDeployment/**/*', 'backend/src/Designer/Controllers/DeploymentsController.cs', 'src/studio/AppTemplates/AspNet/deployment/**/*'] area/dashboard: - - 'frontend/dashboard/**/*' +- changed-files: + - any-glob-to-any-file: 'frontend/dashboard/**/*' area/data-modeling: - - 'frontend/packages/schema-editor/**/*' - - 'frontend/packages/schema-model/**/*' - - 'frontend/app-development/features/dataModelling/**/*' - - 'backend/src/DataModeling/**/*' +- changed-files: + - any-glob-to-any-file: ['frontend/packages/schema-editor/**/*', 'frontend/packages/schema-model/**/*', 'frontend/app-development/features/dataModelling/**/*', 'backend/src/DataModeling/**/*'] area/pdf: - - 'src/Altinn.Platform/Altinn.Platform.PDF/**/*' +- changed-files: + - any-glob-to-any-file: 'src/Altinn.Platform/Altinn.Platform.PDF/**/*' area/resource-registry: - - 'frontend/resourceAdm/**/*' +- changed-files: + - any-glob-to-any-file: 'frontend/resourceAdm/**/*' area/test: - - 'src/test/**/*' +- changed-files: + - any-glob-to-any-file: 'src/test/**/*' area/text-editor: - - 'frontend/packages/text-editor/**' - - 'frontend/app-development/features/textEditor/**/*' +- changed-files: + - any-glob-to-any-file: ['frontend/packages/text-editor/**', 'frontend/app-development/features/textEditor/**/*'] area/process: - - 'frontend/app-development/features/processEditor/**/*' - - 'frontend/packages/process-editor/**/*' - - 'backend/src/Designer/**/*ProcessesModeling*.cs' +- changed-files: + - any-glob-to-any-file: ['frontend/app-development/features/processEditor/**/*', 'frontend/packages/process-editor/**/*', 'backend/src/Designer/**/*ProcessesModeling*.cs'] area/ui-editor: - - 'frontend/packages/ux-editor/**/*' +- changed-files: + - any-glob-to-any-file: 'frontend/packages/ux-editor/**/*' area/studio-root: - - 'frontend/studio-root/**/*' +- changed-files: + - any-glob-to-any-file: 'frontend/studio-root/**/*' ## Other labels kind/dependencies: - - 'backend/packagegroups/NuGet.props' - - 'frontend/**/package.json' +- changed-files: + - any-glob-to-any-file: ['backend/packagegroups/NuGet.props', 'frontend/**/package.json'] quality/testing: - - 'frontend/testing/**/*' - - 'backend/tests/**/*' - - 'testdata/**/*' +- changed-files: + - any-glob-to-any-file: ['frontend/testing/**/*', 'backend/tests/**/*', 'testdata/**/*'] skip-releasenotes: - - '.github/**/*' - - '.husky/**/*' - - '.vscode/**/*' - - '.yarn/**/*' - - 'testdata/**/*' - - 'development/**/*' - - 'src/**/*' +- changed-files: + - any-glob-to-any-file: ['.github/**/*', '.husky/**/*', '.vscode/**/*', '.yarn/**/*', 'testdata/**/*', 'development/**/*', 'src/**/*'] diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 37036e56b53..ce1d83f86f9 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -14,6 +14,6 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/labeler@v4 + - uses: actions/labeler@v5 with: repo-token: "${{ secrets.GITHUB_TOKEN }}" From 3bda0a4dc99c10f76662b482c127f37378c832f6 Mon Sep 17 00:00:00 2001 From: WilliamThorenfeldt <133344438+WilliamThorenfeldt@users.noreply.github.com> Date: Wed, 13 Dec 2023 07:27:02 +0100 Subject: [PATCH 07/21] adding task icons to studio/icons (#11838) * adding task icons to studio/icons * removing usage --- .../src/react/icons/ConfirmationTask.tsx | 38 +++++++++++++++ .../studio-icons/src/react/icons/DataTask.tsx | 48 +++++++++++++++++++ .../src/react/icons/FeedbackTask.tsx | 25 ++++++++++ .../src/react/icons/PaymentTask.tsx | 37 ++++++++++++++ .../studio-icons/src/react/icons/SignTask.tsx | 38 +++++++++++++++ .../studio-icons/src/react/icons/index.ts | 5 ++ 6 files changed, 191 insertions(+) create mode 100644 frontend/libs/studio-icons/src/react/icons/ConfirmationTask.tsx create mode 100644 frontend/libs/studio-icons/src/react/icons/DataTask.tsx create mode 100644 frontend/libs/studio-icons/src/react/icons/FeedbackTask.tsx create mode 100644 frontend/libs/studio-icons/src/react/icons/PaymentTask.tsx create mode 100644 frontend/libs/studio-icons/src/react/icons/SignTask.tsx diff --git a/frontend/libs/studio-icons/src/react/icons/ConfirmationTask.tsx b/frontend/libs/studio-icons/src/react/icons/ConfirmationTask.tsx new file mode 100644 index 00000000000..1222486c39b --- /dev/null +++ b/frontend/libs/studio-icons/src/react/icons/ConfirmationTask.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { IconProps } from '../types'; +import { SvgTemplate } from './SvgTemplate'; + +export const ConfirmationTask = (props: IconProps): JSX.Element => { + return ( + + + + + + + ); +}; diff --git a/frontend/libs/studio-icons/src/react/icons/DataTask.tsx b/frontend/libs/studio-icons/src/react/icons/DataTask.tsx new file mode 100644 index 00000000000..771f0bd4ec3 --- /dev/null +++ b/frontend/libs/studio-icons/src/react/icons/DataTask.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { IconProps } from '../types'; +import { SvgTemplate } from './SvgTemplate'; + +export const DataTask = (props: IconProps): JSX.Element => { + return ( + + + + + + + + ); +}; diff --git a/frontend/libs/studio-icons/src/react/icons/FeedbackTask.tsx b/frontend/libs/studio-icons/src/react/icons/FeedbackTask.tsx new file mode 100644 index 00000000000..16386281b35 --- /dev/null +++ b/frontend/libs/studio-icons/src/react/icons/FeedbackTask.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { IconProps } from '../types'; +import { SvgTemplate } from './SvgTemplate'; + +export const FeedbackTask = (props: IconProps): JSX.Element => { + return ( + + + + + ); +}; diff --git a/frontend/libs/studio-icons/src/react/icons/PaymentTask.tsx b/frontend/libs/studio-icons/src/react/icons/PaymentTask.tsx new file mode 100644 index 00000000000..fd60ad2d67a --- /dev/null +++ b/frontend/libs/studio-icons/src/react/icons/PaymentTask.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { IconProps } from '../types'; +import { SvgTemplate } from './SvgTemplate'; + +export const PaymentTask = (props: IconProps): JSX.Element => { + return ( + + + + + + + + ); +}; diff --git a/frontend/libs/studio-icons/src/react/icons/SignTask.tsx b/frontend/libs/studio-icons/src/react/icons/SignTask.tsx new file mode 100644 index 00000000000..9cfdfc9b320 --- /dev/null +++ b/frontend/libs/studio-icons/src/react/icons/SignTask.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { IconProps } from '../types'; +import { SvgTemplate } from './SvgTemplate'; + +export const SignTask = (props: IconProps): JSX.Element => { + return ( + + + + + + + ); +}; diff --git a/frontend/libs/studio-icons/src/react/icons/index.ts b/frontend/libs/studio-icons/src/react/icons/index.ts index 704ee521e7a..5ea59d0b678 100644 --- a/frontend/libs/studio-icons/src/react/icons/index.ts +++ b/frontend/libs/studio-icons/src/react/icons/index.ts @@ -1,11 +1,16 @@ export { Accordion } from './Accordion'; export { Checkbox } from './Checkbox'; +export { ConfirmationTask } from './ConfirmationTask'; +export { DataTask } from './DataTask'; +export { FeedbackTask } from './FeedbackTask'; export { Group } from './Group'; export { Likert } from './Likert'; export { LongText } from './LongText'; export { NavBar } from './NavBar'; +export { PaymentTask } from './PaymentTask'; export { RadioButton } from './RadioButton'; export { Select } from './Select'; export { ShortText } from './ShortText'; +export { SignTask } from './SignTask'; export { Paragraph } from './Paragraph'; export { Title } from './Title'; From c7aaf35720657f2782641bf906d1b11b47e08936 Mon Sep 17 00:00:00 2001 From: WilliamThorenfeldt <133344438+WilliamThorenfeldt@users.noreply.github.com> Date: Wed, 13 Dec 2023 08:09:03 +0100 Subject: [PATCH 08/21] Fixing delete error on gyldige verdier list (#11810) * Fixing delete error on gyldige verdier list * refactoring validatiin logic on datamodel list of gyldige verdier * adding test for enumList * adding delete enum from list test * fixing feedback from PR * fixing tests and feedback * fixing some tests * adding more tests * fixing broken test * fixing final test --- frontend/language/src/nb.json | 2 +- .../src/components/SchemaInspector.test.tsx | 27 +- .../SchemaInspector/ItemRestrictions.tsx | 155 ---------- .../ArrayRestrictions.module.css | 0 .../ArrayRestrictions.test.tsx | 16 +- .../ArrayRestrictions}/ArrayRestrictions.tsx | 0 .../ArrayRestrictions/index.ts | 1 + .../EnumList/EnumField}/EnumField.module.css | 0 .../EnumList/EnumField}/EnumField.test.tsx | 40 +-- .../EnumList/EnumField}/EnumField.tsx | 42 ++- .../EnumList/EnumField/index.ts | 1 + .../EnumList/EnumList.module.css | 0 .../EnumList/EnumList.test.tsx | 150 +++++++++ .../ItemRestrictions/EnumList/EnumList.tsx | 89 ++++++ .../ItemRestrictions/EnumList/index.ts | 1 + .../ItemRestrictions/EnumList/utils.test.ts | 20 ++ .../ItemRestrictions/EnumList/utils.ts | 34 +++ .../ItemRestrictions.module.css | 0 .../ItemRestrictions.test.tsx | 6 +- .../ItemRestrictions/ItemRestrictions.tsx | 83 +++++ .../NumberRestrictions.module.css | 20 ++ .../NumberRestrictions.test.tsx | 6 +- .../NumberRestrictions.tsx | 2 +- .../NumberRestrictionsReducer.test.tsx | 0 .../NumberRestrictionsReducer.tsx | 0 .../NumberRestrictions/index.ts | 1 + .../ObjectRestrictions.test.tsx | 2 +- .../ObjectRestrictions.tsx | 0 .../ObjectRestrictions/index.ts | 1 + .../RestrictionField}/RestrictionField.tsx | 2 +- .../RestrictionField/index.ts | 1 + .../StringRestrictions.module.css | 4 - .../StringRestrictions.test.tsx | 58 ++-- .../StringRestrictions.tsx | 2 +- .../StringRestrictionsReducer.test.ts | 30 +- .../StringRestrictionsReducer.ts | 2 +- .../StringRestrictions/index.ts | 1 + .../SchemaInspector/ItemRestrictions/index.ts | 1 + .../lib/mutations/ui-schema-reducers.test.ts | 56 +--- .../src/lib/mutations/ui-schema-reducers.ts | 288 ++++++++---------- .../shared/src/utils/arrayUtils.test.ts | 37 +++ .../packages/shared/src/utils/arrayUtils.ts | 22 ++ 42 files changed, 713 insertions(+), 490 deletions(-) delete mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.tsx rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/ArrayRestrictions}/ArrayRestrictions.module.css (100%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/ArrayRestrictions}/ArrayRestrictions.test.tsx (94%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/ArrayRestrictions}/ArrayRestrictions.tsx (100%) create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/index.ts rename frontend/packages/schema-editor/src/components/SchemaInspector/{ => ItemRestrictions/EnumList/EnumField}/EnumField.module.css (100%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{ => ItemRestrictions/EnumList/EnumField}/EnumField.test.tsx (64%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{ => ItemRestrictions/EnumList/EnumField}/EnumField.tsx (61%) create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/index.ts create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.module.css create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/index.ts create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.test.ts create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts rename frontend/packages/schema-editor/src/components/SchemaInspector/{ => ItemRestrictions}/ItemRestrictions.module.css (100%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{ => ItemRestrictions}/ItemRestrictions.test.tsx (84%) create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.tsx create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.module.css rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/NumberRestrictions}/NumberRestrictions.test.tsx (97%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/NumberRestrictions}/NumberRestrictions.tsx (99%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/NumberRestrictions}/NumberRestrictionsReducer.test.tsx (100%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/NumberRestrictions}/NumberRestrictionsReducer.tsx (100%) create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/index.ts rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/ObjectRestrictions}/ObjectRestrictions.test.tsx (98%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/ObjectRestrictions}/ObjectRestrictions.tsx (100%) create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/index.ts rename frontend/packages/schema-editor/src/components/SchemaInspector/{ => ItemRestrictions/RestrictionField}/RestrictionField.tsx (93%) create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/RestrictionField/index.ts rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/StringRestrictions}/StringRestrictions.module.css (96%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/StringRestrictions}/StringRestrictions.test.tsx (93%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/StringRestrictions}/StringRestrictions.tsx (99%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/StringRestrictions}/StringRestrictionsReducer.test.ts (97%) rename frontend/packages/schema-editor/src/components/SchemaInspector/{restrictions => ItemRestrictions/StringRestrictions}/StringRestrictionsReducer.ts (98%) create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/index.ts create mode 100644 frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/index.ts diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index eefd2bfea08..8b35166816e 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -965,6 +965,7 @@ "schema_editor.enum_empty": "Listen er tom - alle verdier vil bli godtatt.", "schema_editor.enum_error_duplicate": "Verdiene må være unike.", "schema_editor.enum_legend": "Liste med gyldige verdier", + "schema_editor.enum_value": "Gyldig verdi nummer {{index}}", "schema_editor.error_model_name_exists": "Modellnavnet {{newModelName}} er allerede i bruk.", "schema_editor.field": "Objekt", "schema_editor.field_name": "Navn på felt", @@ -1052,7 +1053,6 @@ "schema_editor.textRow-deletion-confirm": "Ja, slett raden", "schema_editor.textRow-deletion-text": "Er du sikker på at du vil slette denne raden?", "schema_editor.textRow-title-confirmCancel-popover": "Er du sikker på at du vil slette denne raden?", - "schema_editor.textfield_label": "Rad i listen med gyldige verdier med id {{ id }}", "schema_editor.title": "Tittel", "schema_editor.type": "Type", "schema_editor.types": "Typer", diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx index baa318d8cfc..2a21bc9bfea 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx @@ -6,12 +6,7 @@ import { dataMock } from '../mockData'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { FieldNode, UiSchemaNode, UiSchemaNodes } from '@altinn/schema-model'; -import { - buildUiSchema, - FieldType, - SchemaModel, - validateTestUiSchema, -} from '@altinn/schema-model'; +import { buildUiSchema, FieldType, SchemaModel, validateTestUiSchema } from '@altinn/schema-model'; import { mockUseTranslation } from '../../../../testing/mocks/i18nMock'; import { renderWithProviders } from '../../test/renderWithProviders'; import { getSavedModel } from '../../test/test-utils'; @@ -35,8 +30,7 @@ Object.defineProperty(window, 'matchMedia', { }); const mockUiSchema = buildUiSchema(dataMock); const model = SchemaModel.fromArray(mockUiSchema); -const getMockSchemaByPath = (selectedId: string): UiSchemaNode => - model.getNode(selectedId); +const getMockSchemaByPath = (selectedId: string): UiSchemaNode => model.getNode(selectedId); const texts = { 'schema_editor.maxLength': 'Maksimal lengde', @@ -178,9 +172,20 @@ describe('SchemaInspector', () => { const testUiSchema: UiSchemaNodes = [rootNode, item]; validateTestUiSchema(testUiSchema); renderSchemaInspector(testUiSchema, item); - await act(() => user.click(screen.queryAllByRole('tab')[1])); - await act(() => user.click(screen.getByDisplayValue(enumValue))); + + const enumField = screen.getAllByRole('textbox', { + name: 'schema_editor.enum_value', + }); + expect(enumField).toHaveLength(item.enum.length); + + await act(() => user.click(enumField[0])); await act(() => user.keyboard('{Enter}')); - expect(saveDatamodel).toHaveBeenCalledTimes(1); + + const enumFieldAfter = screen.getAllByRole('textbox', { + name: 'schema_editor.enum_value', + }); + expect(enumFieldAfter).toHaveLength(item.enum.length + 1); + + expect(saveDatamodel).not.toHaveBeenCalled(); }); }); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.tsx deleted file mode 100644 index e8f1ba897de..00000000000 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { MouseEvent, useState } from 'react'; -import React from 'react'; -import { isField, isReference, pointerIsDefinition, UiSchemaNode } from '@altinn/schema-model'; -import { FieldType } from '@altinn/schema-model'; -import { EnumField } from './EnumField'; -import { - addEnumValue, - deleteEnumValue, - setRequired, - setRestriction, - setRestrictions, -} from '@altinn/schema-model'; -import { ArrayRestrictions } from './restrictions/ArrayRestrictions'; -import { NumberRestrictions } from './restrictions/NumberRestrictions'; -import { ObjectRestrictions } from './restrictions/ObjectRestrictions'; -import { StringRestrictions } from './restrictions/StringRestrictions'; -import classes from './ItemRestrictions.module.css'; -import { Button, Fieldset, ErrorMessage, Switch } from '@digdir/design-system-react'; -import { Divider } from 'app-shared/primitives'; -import { PlusIcon } from '@navikt/aksel-icons'; -import { useTranslation } from 'react-i18next'; -import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; -import { useSchemaEditorAppContext } from '@altinn/schema-editor/hooks/useSchemaEditorAppContext'; -import { makeDomFriendlyID } from '@altinn/schema-editor/utils/ui-schema-utils'; - -export interface RestrictionItemProps { - restrictions: any; - readonly: boolean; - path: string; - onChangeRestrictionValue: (id: string, key: string, value?: string | boolean) => void; - onChangeRestrictions: (id: string, restrictions: KeyValuePairs) => void; -} - -export type ItemRestrictionsProps = { - schemaNode: UiSchemaNode; -}; - -export const ItemRestrictions = ({ schemaNode }: ItemRestrictionsProps) => { - const { - pointer, - isRequired, - isArray, - restrictions, - } = schemaNode; - const { schemaModel, save } = useSchemaEditorAppContext(); - - const [enumError, setEnumError] = useState(null); - - const handleRequiredChanged = (e: any) => { - const { checked } = e.target; - if (checked !== isRequired) { - save(setRequired(schemaModel, { path: pointer, required: checked })); - } - }; - - const onChangeRestrictionValue = (path: string, key: string, value?: string | boolean) => - save(setRestriction(schemaModel, { path, key, value })); - - const onChangeRestrictions = (path: string, changedRestrictions: KeyValuePairs) => - save(setRestrictions(schemaModel, { path, restrictions: changedRestrictions })); - - const onChangeEnumValue = (value: string, oldValue?: string) => { - if (!isField(schemaNode) || value === oldValue) return; - - if (schemaNode.enum.includes(value)) { - setEnumError(value); - } else { - setEnumError(null); - save(addEnumValue(schemaModel, { path: pointer, value, oldValue })); - } - }; - - const onDeleteEnumClick = (path: string, value: string) => - save(deleteEnumValue(schemaModel, { path, value })); - - const dispatchAddEnum = () => save(addEnumValue(schemaModel, { path: pointer, value: 'value' })); - - const onAddEnumButtonClick = (event: MouseEvent) => { - event.preventDefault(); - dispatchAddEnum(); - }; - - const { t } = useTranslation(); - const restrictionProps: RestrictionItemProps = { - restrictions: restrictions ?? {}, - readonly: isReference(schemaNode), - path: pointer ?? '', - onChangeRestrictionValue, - onChangeRestrictions, - }; - return ( - <> - {!pointerIsDefinition(pointer) && ( - - {t('schema_editor.required')} - - )} - {isField(schemaNode) && - { - [FieldType.Integer]: , - [FieldType.Number]: , - [FieldType.Object]: , - [FieldType.String]: , - }[schemaNode.fieldType]} - {isArray && } - {isField(schemaNode) && [FieldType.String, FieldType.Integer, FieldType.Number].includes(schemaNode.fieldType) && ( - <> - -
- {!schemaNode.enum?.length && ( -

{t('schema_editor.enum_empty')}

- )} - {enumError !== null && ( - -

{t('schema_editor.enum_error_duplicate')}

-
- )} - {schemaNode.enum?.map((value: string, index) => ( - - ))} -
- -
-
- - )} - - ); -}; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ArrayRestrictions.module.css b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/ArrayRestrictions.module.css similarity index 100% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ArrayRestrictions.module.css rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/ArrayRestrictions.module.css diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ArrayRestrictions.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/ArrayRestrictions.test.tsx similarity index 94% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ArrayRestrictions.test.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/ArrayRestrictions.test.tsx index 352232d3252..c8f50643cac 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ArrayRestrictions.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/ArrayRestrictions.test.tsx @@ -4,7 +4,7 @@ import type { RestrictionItemProps } from '../ItemRestrictions'; import { ArrayRestrictions } from './ArrayRestrictions'; import { ArrRestrictionKey } from '@altinn/schema-model'; import userEvent from '@testing-library/user-event'; -import { textMock } from '../../../../../../testing/mocks/i18nMock'; +import { textMock } from '../../../../../../../testing/mocks/i18nMock'; // Test data: const onChangeRestrictionValueMock = jest.fn(); @@ -48,8 +48,8 @@ describe('ArrayRestrictions', () => { expect(onChangeRestrictionValueMock).toHaveBeenCalledWith( pathMock, ArrRestrictionKey.minItems, - '12' - ) + '12', + ), ); }); @@ -67,8 +67,8 @@ describe('ArrayRestrictions', () => { expect(onChangeRestrictionValueMock).toHaveBeenCalledWith( pathMock, ArrRestrictionKey.maxItems, - '12' - ) + '12', + ), ); }); @@ -81,15 +81,15 @@ describe('ArrayRestrictions', () => { }; render(props); const uniqueItems = screen.getByLabelText( - textMock('schema_editor.' + ArrRestrictionKey.uniqueItems) + textMock('schema_editor.' + ArrRestrictionKey.uniqueItems), ); await act(() => user.click(uniqueItems)); await waitFor(() => expect(onChangeRestrictionValueMock).toHaveBeenCalledWith( pathMock, ArrRestrictionKey.uniqueItems, - true - ) + true, + ), ); }); }); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ArrayRestrictions.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/ArrayRestrictions.tsx similarity index 100% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ArrayRestrictions.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/ArrayRestrictions.tsx diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/index.ts new file mode 100644 index 00000000000..044ced827c4 --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ArrayRestrictions/index.ts @@ -0,0 +1 @@ +export { ArrayRestrictions } from './ArrayRestrictions'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.module.css b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.module.css similarity index 100% rename from frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.module.css rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.module.css diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.test.tsx similarity index 64% rename from frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.test.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.test.tsx index c046c4e09ed..7f0f32d5711 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.test.tsx @@ -2,25 +2,23 @@ import React from 'react'; import { render, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { EnumField, EnumFieldProps } from './EnumField'; -import { textMock } from '../../../../../testing/mocks/i18nMock'; +import { textMock } from '../../../../../../../../testing/mocks/i18nMock'; -const mockPath: string = 'mockPath'; const mockValue: string = 'test'; -const mockBaseId: string = 'id123'; -const mockId: string = `${mockBaseId}-enum-${mockValue}`; +const mockIndex: number = 0; const mockOnChange = jest.fn(); const mockOnDelete = jest.fn(); const mockOnEnterKeyPress = jest.fn(); const defaultProps: EnumFieldProps = { - path: mockPath, value: mockValue, readOnly: false, isValid: true, onChange: mockOnChange, + onDelete: mockOnDelete, onEnterKeyPress: mockOnEnterKeyPress, - baseId: mockBaseId, + index: mockIndex, }; describe('EnumField', () => { @@ -30,9 +28,9 @@ describe('EnumField', () => { const user = userEvent.setup(); render(); - const textField = screen.getByLabelText( - textMock('schema_editor.textfield_label', { id: mockId }), - ); + const textField = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: mockIndex }), + }); expect(textField).toHaveValue(mockValue); const newValue: string = '1'; @@ -43,21 +41,12 @@ describe('EnumField', () => { const updatedValue: string = `${mockValue}${newValue}`; expect(mockOnChange).toHaveBeenCalledTimes(1); - expect(mockOnChange).toHaveBeenCalledWith(updatedValue, mockValue); + expect(mockOnChange).toHaveBeenCalledWith(updatedValue); - const textFieldAfter = screen.getByLabelText( - textMock('schema_editor.textfield_label', { id: mockId }), - ); - expect(textFieldAfter).toHaveValue(updatedValue); - }); - - it('hides delete button when onDelete is not present', () => { - render(); - - const deleteButton = screen.queryByRole('button', { - name: textMock('schema_editor.delete_field'), + const textFieldAfter = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: mockIndex }), }); - expect(deleteButton).not.toBeInTheDocument(); + expect(textFieldAfter).toHaveValue(updatedValue); }); it('calls onDelete when delete button is clicked', async () => { @@ -72,16 +61,15 @@ describe('EnumField', () => { await act(() => user.click(deleteButton)); expect(mockOnDelete).toHaveBeenCalledTimes(1); - expect(mockOnDelete).toHaveBeenCalledWith(mockPath, mockValue); }); it('calls onEnterKeyPress when "Enter" key is pressed', async () => { const user = userEvent.setup(); render(); - const textField = screen.getByLabelText( - textMock('schema_editor.textfield_label', { id: mockId }), - ); + const textField = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: mockIndex }), + }); const newValue: string = '1'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.tsx similarity index 61% rename from frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.tsx index c0b2fe27fbc..da68819abfb 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/EnumField.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/EnumField.tsx @@ -6,25 +6,23 @@ import { useTranslation } from 'react-i18next'; import { TrashIcon } from '@studio/icons'; export type EnumFieldProps = { - path: string; value: string; readOnly?: boolean; isValid?: boolean; - onChange: (value: string, oldValue?: string) => void; - onDelete?: (path: string, key: string) => void; + onChange: (value: string) => void; + onDelete: () => void; onEnterKeyPress?: () => void; - baseId: string; + index: number; }; export const EnumField = ({ - path, value, readOnly, isValid, onChange, onDelete, onEnterKeyPress, - baseId, + index, }: EnumFieldProps) => { const [inputValue, setInputValue] = useState(value); useEffect(() => { @@ -32,19 +30,17 @@ export const EnumField = ({ }, [value]); const { t } = useTranslation(); - const onBlur = () => { - onChange(inputValue, value); - }; - const handleChange = (event: ChangeEvent) => { event.stopPropagation(); - setInputValue(event.target.value); + const newValue: string = event.target.value; + setInputValue(newValue); + onChange(newValue); }; const onKeyDown = (e: KeyboardEvent) => e?.key === 'Enter' && onEnterKeyPress && onEnterKeyPress(); - const label = t('schema_editor.textfield_label', { id: `${baseId}-enum-${value}` }); + const label = t('schema_editor.enum_value', { index }); return (
@@ -54,22 +50,18 @@ export const EnumField = ({ disabled={readOnly} value={inputValue} onChange={handleChange} - onBlur={onBlur} onKeyDown={onKeyDown} error={!isValid} /> - {onDelete && ( -
); }; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/index.ts new file mode 100644 index 00000000000..c3a8d3442df --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumField/index.ts @@ -0,0 +1 @@ +export { EnumField } from './EnumField'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.module.css b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.module.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx new file mode 100644 index 00000000000..d356dce38bf --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { screen, act } from '@testing-library/react'; +import { EnumList, EnumListProps } from './EnumList'; +import { fieldNode1Mock, uiSchemaNodesMock } from '../../../../../test/mocks/uiSchemaMock'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../../../../test/renderWithProviders'; +import { FieldNode, SchemaModel } from '../../../../../../schema-model'; +import { textMock } from '../../../../../../../testing/mocks/i18nMock'; + +const mockEnums: string[] = ['a', 'b', 'c']; + +const defaultProps: EnumListProps = { + schemaNode: fieldNode1Mock, +}; +const mockSaveDataModel = jest.fn(); + +describe('EnumList', () => { + beforeEach(jest.clearAllMocks); + + it('renders the description about enum being empty when there is no enums on the field node', () => { + renderEnumList(); + + expect(screen.getByText(textMock('schema_editor.enum_empty'))).toBeInTheDocument(); + }); + + it('renders EnumList component with existing enum values', () => { + renderEnumList({ schemaNode: { ...fieldNode1Mock, enum: mockEnums } }); + + expect(screen.queryByText(textMock('schema_editor.enum_empty'))).not.toBeInTheDocument(); + + const enumLabelA = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: 0 }), + }); + expect(enumLabelA).toBeInTheDocument(); + const enumLabelB = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: 1 }), + }); + expect(enumLabelB).toBeInTheDocument(); + const enumLabelC = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: 2 }), + }); + expect(enumLabelC).toBeInTheDocument(); + }); + + it('handles adding a new enum value', async () => { + const user = userEvent.setup(); + renderEnumList(); + + const addEnumButton = screen.getByRole('button', { name: textMock('schema_editor.add_enum') }); + await act(() => user.click(addEnumButton)); + + const enumLabel = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: 0 }), + }); + expect(enumLabel).toBeInTheDocument(); + expect(mockSaveDataModel).not.toHaveBeenCalled(); + }); + + it('handles deleting an enum value correctly', async () => { + const user = userEvent.setup(); + const schemaModel = SchemaModel.fromArray(uiSchemaNodesMock).deepClone(); + renderEnumList({ schemaNode: { ...fieldNode1Mock, enum: mockEnums } }, schemaModel); + + const allDeleteButtons = screen.getAllByRole('button', { + name: textMock('schema_editor.delete_field'), + }); + expect(allDeleteButtons).toHaveLength(3); + + const [, deleteEnumButtonB] = screen.getAllByRole('button', { + name: textMock('schema_editor.delete_field'), + }); + await act(() => user.click(deleteEnumButtonB)); + + const allDeleteButtonsAfter = screen.getAllByRole('button', { + name: textMock('schema_editor.delete_field'), + }); + expect(allDeleteButtonsAfter).toHaveLength(2); + }); + + it('displays error message when having duplicates, and removes error message when error is fixed and saves the schema model', async () => { + const user = userEvent.setup(); + const schemaModel = SchemaModel.fromArray(uiSchemaNodesMock).deepClone(); + const mockSchemaNode = { schemaNode: { ...fieldNode1Mock, enum: mockEnums } }; + renderEnumList(mockSchemaNode, schemaModel); + + const addEnumButton = screen.getByRole('button', { name: textMock('schema_editor.add_enum') }); + await act(() => user.click(addEnumButton)); + expect(mockSaveDataModel).not.toHaveBeenCalled(); + + const newEnumInput = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: 3 }), + }); + + await act(() => user.type(newEnumInput, 'a')); + + const errorMessage = screen.getByText(textMock('schema_editor.enum_error_duplicate')); + expect(errorMessage).toBeInTheDocument(); + expect(mockSaveDataModel).not.toHaveBeenCalled(); + + await act(() => user.type(newEnumInput, 'a')); + + const errorMessageAfter = screen.queryByText(textMock('schema_editor.enum_error_duplicate')); + expect(errorMessageAfter).not.toBeInTheDocument(); + + expect(mockSaveDataModel).toHaveBeenCalledTimes(1); + expect(mockSaveDataModel).toHaveBeenCalledWith(schemaModel); + + const updatedNode: FieldNode = schemaModel.getNode(fieldNode1Mock.pointer) as FieldNode; + const updatedEnum: string[] = updatedNode.enum; + + const expectedEnum: string[] = ['a', 'b', 'c', 'aa']; + expect(updatedEnum).toEqual(expectedEnum); + }); + + it('updates an enum correctly when values are changed', async () => { + const user = userEvent.setup(); + const schemaModel = SchemaModel.fromArray(uiSchemaNodesMock).deepClone(); + const mockSchemaNode = { schemaNode: { ...fieldNode1Mock, enum: mockEnums } }; + renderEnumList(mockSchemaNode, schemaModel); + + const enumFieldB = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: 1 }), + }); + expect(enumFieldB).toHaveValue('b'); + + await act(() => user.type(enumFieldB, 'x')); + + expect(mockSaveDataModel).toHaveBeenCalledTimes(1); + expect(mockSaveDataModel).toHaveBeenCalledWith(schemaModel); + + const updatedNode: FieldNode = schemaModel.getNode(fieldNode1Mock.pointer) as FieldNode; + const updatedEnum: string[] = updatedNode.enum; + + const expectedEnum: string[] = ['a', 'bx', 'c']; + expect(updatedEnum).toEqual(expectedEnum); + + const enumFieldBAfter = screen.getByRole('textbox', { + name: textMock('schema_editor.enum_value', { index: 1 }), + }); + expect(enumFieldBAfter).toHaveValue('bx'); + }); +}); + +const renderEnumList = (props?: Partial, schemaModel?: SchemaModel) => + renderWithProviders({ + appContextProps: { + schemaModel: schemaModel, + save: mockSaveDataModel, + }, + })(); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx new file mode 100644 index 00000000000..d2fbfc9cf53 --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/EnumList.tsx @@ -0,0 +1,89 @@ +import React, { useState } from 'react'; +import classes from './EnumList.module.css'; +import { FieldNode } from '@altinn/schema-model'; +import { deepCopy } from 'app-shared/pure'; +import { EnumField } from './EnumField'; +import { Button, ErrorMessage, Fieldset } from '@digdir/design-system-react'; +import { useTranslation } from 'react-i18next'; +import { PlusIcon } from '@studio/icons'; +import { findDuplicateValues } from './utils'; +import { useSchemaEditorAppContext } from '@altinn/schema-editor/hooks/useSchemaEditorAppContext'; +import { removeEmptyStrings, removeItemByIndex, replaceByIndex } from 'app-shared/utils/arrayUtils'; + +export type EnumListProps = { + schemaNode: FieldNode; +}; + +export const EnumList = ({ schemaNode }: EnumListProps): JSX.Element => { + const { t } = useTranslation(); + const { schemaModel, save } = useSchemaEditorAppContext(); + + const [enumList, setEnumList] = useState( + schemaNode?.enum ? deepCopy(schemaNode.enum) : [], + ); + + const [duplicateValues, setDuplicateValues] = useState(null); + + const handleChange = (index: number, newEnum: string) => { + const newEnumList = replaceByIndex(enumList, index, newEnum); + update(newEnumList); + }; + + const handleDelete = (index: number) => { + const newEnumList = removeItemByIndex(enumList, index); + update(newEnumList); + }; + + const handleAddEnum = () => { + const newEnumList = [...enumList, '']; + setEnumList(newEnumList); + }; + + const update = (newEnumList: string[]) => { + const duplicates: string[] = findDuplicateValues(newEnumList); + + if (duplicates === null) { + const newNode = { ...schemaNode, enum: removeEmptyStrings(newEnumList) }; + save(schemaModel.updateNode(newNode.pointer, newNode)); + } + + setEnumList(newEnumList); + setDuplicateValues(duplicates); + }; + + return ( +
+ {duplicateValues !== null && ( + {t('schema_editor.enum_error_duplicate')} + )} + {enumList.map((value: string, index: number) => ( + handleChange(index, newValue)} + onDelete={() => handleDelete(index)} + onEnterKeyPress={handleAddEnum} + value={value} + isValid={!duplicateValues?.includes(value)} + index={index} + /> + ))} +
+ +
+
+ ); +}; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/index.ts new file mode 100644 index 00000000000..a8a232e152a --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/index.ts @@ -0,0 +1 @@ +export { EnumList } from './EnumList'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.test.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.test.ts new file mode 100644 index 00000000000..5d1e85bca06 --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.test.ts @@ -0,0 +1,20 @@ +import { findDuplicateValues } from './utils'; + +describe('utils', () => { + describe('findDuplicateValues', () => { + it('returns "null" when all values are unique', () => { + const array: string[] = ['a', 'b', 'c']; + expect(findDuplicateValues(array)).toBeNull(); + }); + + it('returns list of duplicateValues when some values are not unique', () => { + const array: string[] = ['a', 'b', 'a', 'b', 'c']; + expect(findDuplicateValues(array)).toEqual(['a', 'b']); + }); + + it('does not return any empty strings when list has duplicate empty strings', () => { + const array: string[] = ['a', 'b', '', 'a', 'b', 'c', '']; + expect(findDuplicateValues(array)).toEqual(['a', 'b']); + }); + }); +}); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts new file mode 100644 index 00000000000..c5e92bb0219 --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/EnumList/utils.ts @@ -0,0 +1,34 @@ +import { areItemsUnique, removeEmptyStrings } from 'app-shared/utils/arrayUtils'; + +export const findDuplicateValues = (array: string[]): string[] | null => { + const arrayWithoutEmptyStrings: string[] = removeEmptyStrings(array); + + if (areItemsUnique(arrayWithoutEmptyStrings)) return null; + + return findDuplicates(arrayWithoutEmptyStrings); +}; + +type CountMap = { [key: string]: number }; + +const findDuplicates = (array: string[]): string[] => { + const countMap: CountMap = createCountMap(array); + return findGreaterThanOneEntries(countMap); +}; + +const createCountMap = (array: string[]): CountMap => { + const countMap: CountMap = {}; + array.forEach((element) => { + countMap[element] = (countMap[element] || 0) + 1; + }); + return countMap; +}; + +const findGreaterThanOneEntries = (countMap: CountMap) => { + const duplicates: string[] = []; + for (const key in countMap) { + if (countMap[key] > 1) { + duplicates.push(key); + } + } + return duplicates; +}; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.module.css b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.module.css similarity index 100% rename from frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.module.css rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.module.css diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.test.tsx similarity index 84% rename from frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.test.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.test.tsx index 6e0338458e6..6691245e3a8 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.test.tsx @@ -2,10 +2,10 @@ import React from 'react'; import { act, screen } from '@testing-library/react'; import type { ItemRestrictionsProps } from './ItemRestrictions'; import { ItemRestrictions } from './ItemRestrictions'; -import { renderWithProviders } from '../../../test/renderWithProviders'; +import { renderWithProviders } from '../../../../test/renderWithProviders'; import userEvent from '@testing-library/user-event'; -import { fieldNode1Mock, uiSchemaNodesMock } from '../../../test/mocks/uiSchemaMock'; -import { SchemaModel } from '../../../../schema-model'; +import { fieldNode1Mock, uiSchemaNodesMock } from '../../../../test/mocks/uiSchemaMock'; +import { SchemaModel } from '../../../../../schema-model'; const user = userEvent.setup(); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.tsx new file mode 100644 index 00000000000..8dd0ef726bb --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ItemRestrictions.tsx @@ -0,0 +1,83 @@ +import React, { ChangeEvent } from 'react'; +import { isField, isReference, pointerIsDefinition, UiSchemaNode } from '@altinn/schema-model'; +import { FieldType } from '@altinn/schema-model'; +import { setRequired, setRestriction, setRestrictions } from '@altinn/schema-model'; +import { ArrayRestrictions } from './ArrayRestrictions'; +import { NumberRestrictions } from './NumberRestrictions'; +import { ObjectRestrictions } from './ObjectRestrictions'; +import { StringRestrictions } from './StringRestrictions'; +import classes from './ItemRestrictions.module.css'; +import { Switch } from '@digdir/design-system-react'; +import { Divider } from 'app-shared/primitives'; +import { useTranslation } from 'react-i18next'; +import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import { useSchemaEditorAppContext } from '@altinn/schema-editor/hooks/useSchemaEditorAppContext'; +import { EnumList } from './EnumList'; + +export interface RestrictionItemProps { + restrictions: any; + readonly: boolean; + path: string; + onChangeRestrictionValue: (id: string, key: string, value?: string | boolean) => void; + onChangeRestrictions: (id: string, restrictions: KeyValuePairs) => void; +} + +export type ItemRestrictionsProps = { + schemaNode: UiSchemaNode; +}; + +export const ItemRestrictions = ({ schemaNode }: ItemRestrictionsProps) => { + const { t } = useTranslation(); + const { pointer, isRequired, isArray, restrictions } = schemaNode; + const { schemaModel, save } = useSchemaEditorAppContext(); + + const handleRequiredChanged = (event: ChangeEvent) => { + const { checked } = event.target; + if (checked !== isRequired) { + save(setRequired(schemaModel, { path: pointer, required: checked })); + } + }; + + const onChangeRestrictionValue = (path: string, key: string, value?: string | boolean) => + save(setRestriction(schemaModel, { path, key, value })); + + const onChangeRestrictions = (path: string, changedRestrictions: KeyValuePairs) => + save(setRestrictions(schemaModel, { path, restrictions: changedRestrictions })); + + const restrictionProps: RestrictionItemProps = { + restrictions: restrictions ?? {}, + readonly: isReference(schemaNode), + path: pointer ?? '', + onChangeRestrictionValue, + onChangeRestrictions, + }; + return ( + <> + {!pointerIsDefinition(pointer) && ( + + {t('schema_editor.required')} + + )} + {isField(schemaNode) && + { + [FieldType.Integer]: , + [FieldType.Number]: , + [FieldType.Object]: , + [FieldType.String]: , + }[schemaNode.fieldType]} + {isArray && } + {isField(schemaNode) && + [FieldType.String, FieldType.Integer, FieldType.Number].includes(schemaNode.fieldType) && ( + <> + + + + )} + + ); +}; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.module.css b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.module.css new file mode 100644 index 00000000000..18cc19b31b6 --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.module.css @@ -0,0 +1,20 @@ +.formatFieldsRowContent { + align-items: flex-end; + display: flex; + gap: 1rem; +} + +.formatFieldsRowContent :first-child { + flex: 1; +} + +.minNumberErrorMassage { + margin-top: 5px; +} + +.lengthFields { + display: flex; + flex-direction: row; + gap: 2rem; + justify-content: space-between; +} diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictions.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.test.tsx similarity index 97% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictions.test.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.test.tsx index f163a7001d5..84111243574 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictions.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { act, render, screen, waitFor } from '@testing-library/react'; import { NumberRestrictions } from './NumberRestrictions'; -import { textMock } from '../../../../../../testing/mocks/i18nMock'; +import { textMock } from '../../../../../../../testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; import { IntRestrictionKey } from '@altinn/schema-model'; @@ -112,7 +112,7 @@ describe('NumberRestrictions component', () => { }; await waitFor(() => - expect(onChangeRestrictions).toHaveBeenCalledWith('', expectedRestrictions) + expect(onChangeRestrictions).toHaveBeenCalledWith('', expectedRestrictions), ); }); @@ -141,7 +141,7 @@ describe('NumberRestrictions component', () => { }; await waitFor(() => - expect(onChangeRestrictions).toHaveBeenCalledWith('', expectedRestrictions) + expect(onChangeRestrictions).toHaveBeenCalledWith('', expectedRestrictions), ); }); }); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictions.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.tsx similarity index 99% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictions.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.tsx index 435b2bc5415..1af53c1af32 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictions.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictions.tsx @@ -4,7 +4,7 @@ import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { IntRestrictionKey } from '@altinn/schema-model'; import { Divider } from 'app-shared/primitives'; import { useTranslation } from 'react-i18next'; -import classes from './StringRestrictions.module.css'; +import classes from './NumberRestrictions.module.css'; import { ErrorMessage, LegacyTextField, Switch, Label } from '@digdir/design-system-react'; import { numberRestrictionsReducer, diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictionsReducer.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictionsReducer.test.tsx similarity index 100% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictionsReducer.test.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictionsReducer.test.tsx diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictionsReducer.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictionsReducer.tsx similarity index 100% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/NumberRestrictionsReducer.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/NumberRestrictionsReducer.tsx diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/index.ts new file mode 100644 index 00000000000..2419921469b --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/NumberRestrictions/index.ts @@ -0,0 +1 @@ +export { NumberRestrictions } from './NumberRestrictions'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ObjectRestrictions.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/ObjectRestrictions.test.tsx similarity index 98% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ObjectRestrictions.test.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/ObjectRestrictions.test.tsx index 50375a82621..94edf78afbd 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ObjectRestrictions.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/ObjectRestrictions.test.tsx @@ -12,7 +12,7 @@ test('ObjectRestrictions should redner correctly', async () => { readonly={false} restrictions={[]} onChangeRestrictions={() => undefined} - /> + />, ); expect(screen.queryAllByRole('textbox')).toHaveLength(0); }); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ObjectRestrictions.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/ObjectRestrictions.tsx similarity index 100% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/ObjectRestrictions.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/ObjectRestrictions.tsx diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/index.ts new file mode 100644 index 00000000000..f74039aa4af --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/ObjectRestrictions/index.ts @@ -0,0 +1 @@ +export { ObjectRestrictions } from './ObjectRestrictions'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/RestrictionField.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/RestrictionField/RestrictionField.tsx similarity index 93% rename from frontend/packages/schema-editor/src/components/SchemaInspector/RestrictionField.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/RestrictionField/RestrictionField.tsx index ea42b100e91..f728ed21045 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/RestrictionField.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/RestrictionField/RestrictionField.tsx @@ -1,7 +1,7 @@ import type { BaseSyntheticEvent, ChangeEvent } from 'react'; import React from 'react'; import { Textfield } from '@digdir/design-system-react'; -import { makeDomFriendlyID } from '../../utils/ui-schema-utils'; +import { makeDomFriendlyID } from '../../../../utils/ui-schema-utils'; export interface IRestrictionFieldProps { className?: string; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/RestrictionField/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/RestrictionField/index.ts new file mode 100644 index 00000000000..3fbcde73b4b --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/RestrictionField/index.ts @@ -0,0 +1 @@ +export { RestrictionField } from './RestrictionField'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.module.css b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.module.css similarity index 96% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.module.css rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.module.css index 7a4d4c8982d..da814202eff 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.module.css +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.module.css @@ -8,10 +8,6 @@ flex: 1; } -.minNumberErrorMassage { - margin-top: 5px; -} - .lengthFields { display: flex; flex-direction: row; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.test.tsx similarity index 93% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.test.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.test.tsx index 9fe6cee7441..054115ac982 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.test.tsx @@ -4,7 +4,7 @@ import { StringRestrictions } from './StringRestrictions'; import { StringFormat, StrRestrictionKey } from '@altinn/schema-model'; import type { RestrictionItemProps } from '../ItemRestrictions'; import userEvent from '@testing-library/user-event'; -import { mockUseTranslation } from '../../../../../../testing/mocks/i18nMock'; +import { mockUseTranslation } from '../../../../../../../testing/mocks/i18nMock'; // Test data const texts = { @@ -52,10 +52,7 @@ const defaultProps: RestrictionItemProps = { }; // Mocks: -jest.mock( - 'react-i18next', - () => ({ useTranslation: () => mockUseTranslation(texts) }), -); +jest.mock('react-i18next', () => ({ useTranslation: () => mockUseTranslation(texts) })); const user = userEvent.setup(); @@ -85,11 +82,11 @@ describe('StringRestrictions', () => { await act(() => user.click(select)); Object.values(StringFormat).forEach((format) => { expect( - screen.getByRole('option', { name: texts[`schema_editor.format_${format}`] }) + screen.getByRole('option', { name: texts[`schema_editor.format_${format}`] }), ).toHaveAttribute('value', format); }); expect( - screen.getByRole('option', { name: texts['schema_editor.format_none'] }) + screen.getByRole('option', { name: texts['schema_editor.format_none'] }), ).toHaveAttribute('value', ''); }); @@ -98,7 +95,7 @@ describe('StringRestrictions', () => { const select = screen.getByRole('combobox', { name: texts[`schema_editor.format`] }); await act(() => user.click(select)); expect( - screen.getByRole('option', { name: texts['schema_editor.format_none'] }) + screen.getByRole('option', { name: texts['schema_editor.format_none'] }), ).toHaveAttribute('aria-selected', 'true'); }); @@ -112,22 +109,26 @@ describe('StringRestrictions', () => { const { rerender } = renderStringRestrictions(); const formatSelect = await getFormatSelect(); await act(() => user.click(formatSelect)); - await act(() => user.click( - screen.getByRole('option', { name: texts[`schema_editor.format_${StringFormat.Date}`] }) - )); + await act(() => + user.click( + screen.getByRole('option', { name: texts[`schema_editor.format_${StringFormat.Date}`] }), + ), + ); expect(onChangeRestrictions).toHaveBeenCalledTimes(1); expect(onChangeRestrictions).toHaveBeenCalledWith( path, - expect.objectContaining({ [StrRestrictionKey.format]: StringFormat.Date }) + expect.objectContaining({ [StrRestrictionKey.format]: StringFormat.Date }), ); onChangeRestrictions.mockReset(); rerender(); await act(() => user.click(formatSelect)); - await act(() => user.click(screen.getByRole('option', { name: texts[`schema_editor.format_none`] }))); + await act(() => + user.click(screen.getByRole('option', { name: texts[`schema_editor.format_none`] })), + ); expect(onChangeRestrictions).toHaveBeenCalledTimes(1); expect(onChangeRestrictions).toHaveBeenCalledWith( path, - expect.objectContaining({ [StrRestrictionKey.format]: '' }) + expect.objectContaining({ [StrRestrictionKey.format]: '' }), ); }); @@ -137,7 +138,7 @@ describe('StringRestrictions', () => { expect(screen.getByLabelText(texts['schema_editor.format_date_after_incl'])).toBeTruthy(); expect(screen.getByLabelText(texts['schema_editor.format_date_before_incl'])).toBeTruthy(); expect(screen.getAllByLabelText(texts['schema_editor.format_date_inclusive'])).toHaveLength( - 2 + 2, ); unmount(); }); @@ -211,22 +212,26 @@ describe('StringRestrictions', () => { test('onChangeRestrictions is called with correct arguments when "earliest" field is changed', async () => { renderStringRestrictions({ restrictions: { format: StringFormat.Date } }); const input = '2'; - await act(() => user.type(screen.getByLabelText(texts['schema_editor.format_date_after_incl']), input)); + await act(() => + user.type(screen.getByLabelText(texts['schema_editor.format_date_after_incl']), input), + ); expect(onChangeRestrictions).toHaveBeenCalledTimes(1); expect(onChangeRestrictions).toHaveBeenCalledWith( path, - expect.objectContaining({ [StrRestrictionKey.formatMinimum]: input }) + expect.objectContaining({ [StrRestrictionKey.formatMinimum]: input }), ); }); test('onChangeRestrictions is called with correct arguments when "latest" field is changed', async () => { renderStringRestrictions({ restrictions: { format: StringFormat.Date } }); const input = '2'; - await act(() => user.type(screen.getByLabelText(texts['schema_editor.format_date_before_incl']), input)); + await act(() => + user.type(screen.getByLabelText(texts['schema_editor.format_date_before_incl']), input), + ); expect(onChangeRestrictions).toHaveBeenCalledTimes(1); expect(onChangeRestrictions).toHaveBeenCalledWith( path, - expect.objectContaining({ [StrRestrictionKey.formatMaximum]: input }) + expect.objectContaining({ [StrRestrictionKey.formatMaximum]: input }), ); }); @@ -241,7 +246,7 @@ describe('StringRestrictions', () => { expect.objectContaining({ formatMinimum: undefined, formatExclusiveMinimum: formatMinimum, - }) + }), ); }); @@ -256,7 +261,7 @@ describe('StringRestrictions', () => { expect.objectContaining({ formatMaximum: undefined, formatExclusiveMaximum: formatMaximum, - }) + }), ); }); @@ -271,7 +276,7 @@ describe('StringRestrictions', () => { expect.objectContaining({ formatMinimum: formatExclusiveMinimum, formatExclusiveMinimum: undefined, - }) + }), ); }); @@ -286,7 +291,7 @@ describe('StringRestrictions', () => { expect.objectContaining({ formatMaximum: formatExclusiveMaximum, formatExclusiveMaximum: undefined, - }) + }), ); }); @@ -303,7 +308,7 @@ describe('StringRestrictions', () => { expect(onChangeRestrictions).toHaveBeenCalledTimes(1); expect(onChangeRestrictions).toHaveBeenCalledWith( path, - expect.objectContaining({ [StrRestrictionKey.minLength]: '12' }) + expect.objectContaining({ [StrRestrictionKey.minLength]: '12' }), ); }); @@ -320,7 +325,7 @@ describe('StringRestrictions', () => { expect(onChangeRestrictions).toHaveBeenCalledTimes(1); expect(onChangeRestrictions).toHaveBeenCalledWith( path, - expect.objectContaining({ [StrRestrictionKey.maxLength]: '144' }) + expect.objectContaining({ [StrRestrictionKey.maxLength]: '144' }), ); }); @@ -339,7 +344,8 @@ describe('StringRestrictions', () => { }); }); -const getFormatSelect = () => screen.findByRole('combobox', { name: texts['schema_editor.format'] }); +const getFormatSelect = () => + screen.findByRole('combobox', { name: texts['schema_editor.format'] }); const getInclusiveCheckboxes = () => screen.getAllByLabelText(texts['schema_editor.format_date_inclusive']); const getMinimumInclusiveCheckbox = () => getInclusiveCheckboxes()[0]; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.tsx similarity index 99% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.tsx rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.tsx index d2ede4bd1e7..46588f3bdce 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictions.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictions.tsx @@ -14,7 +14,7 @@ import { import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { StringFormat, StrRestrictionKey } from '@altinn/schema-model'; import { Divider } from 'app-shared/primitives'; -import { makeDomFriendlyID } from '../../../utils/ui-schema-utils'; +import { makeDomFriendlyID } from '../../../../utils/ui-schema-utils'; import type { StringRestrictionsReducerAction } from './StringRestrictionsReducer'; import { stringRestrictionsReducer, diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictionsReducer.test.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictionsReducer.test.ts similarity index 97% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictionsReducer.test.ts rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictionsReducer.test.ts index 0bfd7b8358a..c6ac344557a 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictionsReducer.test.ts +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictionsReducer.test.ts @@ -31,11 +31,11 @@ const changeCallback = jest.fn(); const dispatchAction = ( action: StringRestrictionsReducerAction, - state?: Partial + state?: Partial, ) => stringRestrictionsReducer( { ...defaultState, restrictions: { ...defaultRestrictions }, ...state }, - action + action, ); describe('stringRestrictionsReducer', () => { @@ -52,7 +52,7 @@ describe('stringRestrictionsReducer', () => { expect(state.restrictions.format).toEqual(StringFormat.DateTime); expect(changeCallback).toHaveBeenCalledTimes(1); expect(changeCallback).toHaveBeenCalledWith( - expect.objectContaining({ format: StringFormat.DateTime }) + expect.objectContaining({ format: StringFormat.DateTime }), ); }); }); @@ -79,7 +79,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: minDate, formatMaximum: maxDate, formatExclusiveMaximum: undefined, - }) + }), ); }); @@ -95,7 +95,7 @@ describe('stringRestrictionsReducer', () => { value: true, changeCallback, }, - { earliestIsInclusive: false, restrictions: initialRestrictions } + { earliestIsInclusive: false, restrictions: initialRestrictions }, ); expect(state.earliestIsInclusive).toBe(true); expect(state.earliest).toBe(minDate); @@ -110,7 +110,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: undefined, formatMaximum: maxDate, formatExclusiveMaximum: undefined, - }) + }), ); }); }); @@ -133,7 +133,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: undefined, formatMaximum: undefined, formatExclusiveMaximum: maxDate, - }) + }), ); }); @@ -145,7 +145,7 @@ describe('stringRestrictionsReducer', () => { }; const state = dispatchAction( { type, value: true, changeCallback }, - { latestIsInclusive: false, restrictions: initialRestrictions } + { latestIsInclusive: false, restrictions: initialRestrictions }, ); expect(state.latestIsInclusive).toBe(true); expect(state.latest).toBe(maxDate); @@ -160,7 +160,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: undefined, formatMaximum: maxDate, formatExclusiveMaximum: undefined, - }) + }), ); }); }); @@ -184,7 +184,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: undefined, formatMaximum: maxDate, formatExclusiveMaximum: undefined, - }) + }), ); }); @@ -197,7 +197,7 @@ describe('stringRestrictionsReducer', () => { const value = '2020-02-02'; const state = dispatchAction( { type, value, changeCallback }, - { earliestIsInclusive: false, restrictions: initialRestrictions } + { earliestIsInclusive: false, restrictions: initialRestrictions }, ); expect(state.earliestIsInclusive).toBe(false); expect(state.earliest).toBe(value); @@ -212,7 +212,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: value, formatMaximum: maxDate, formatExclusiveMaximum: undefined, - }) + }), ); }); }); @@ -236,7 +236,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: undefined, formatMaximum: value, formatExclusiveMaximum: undefined, - }) + }), ); }); @@ -249,7 +249,7 @@ describe('stringRestrictionsReducer', () => { const value = '2020-02-02'; const state = dispatchAction( { type, value, changeCallback }, - { latestIsInclusive: false, restrictions: initialRestrictions } + { latestIsInclusive: false, restrictions: initialRestrictions }, ); expect(state.latestIsInclusive).toBe(false); expect(state.latest).toBe(value); @@ -264,7 +264,7 @@ describe('stringRestrictionsReducer', () => { formatExclusiveMinimum: undefined, formatMaximum: undefined, formatExclusiveMaximum: value, - }) + }), ); }); }); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictionsReducer.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictionsReducer.ts similarity index 98% rename from frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictionsReducer.ts rename to frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictionsReducer.ts index 65cf21fa530..a415606a4dd 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/restrictions/StringRestrictionsReducer.ts +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/StringRestrictionsReducer.ts @@ -106,7 +106,7 @@ const setRestriction = (state: StringRestricionsReducerState, action: SetRestric export const stringRestrictionsReducer = ( state: StringRestricionsReducerState, - action: StringRestrictionsReducerAction + action: StringRestrictionsReducerAction, ) => { switch (action.type) { case StringRestrictionsReducerActionType.setMinIncl: diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/index.ts new file mode 100644 index 00000000000..af72d532d77 --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/StringRestrictions/index.ts @@ -0,0 +1 @@ +export { StringRestrictions } from './StringRestrictions'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/index.ts b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/index.ts new file mode 100644 index 00000000000..e1c6c953e45 --- /dev/null +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemRestrictions/index.ts @@ -0,0 +1 @@ +export { ItemRestrictions } from './ItemRestrictions'; diff --git a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts index cba16e2255a..0aa1831eb34 100644 --- a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts +++ b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.test.ts @@ -1,8 +1,6 @@ import { addCombinationItem, - addEnumValue, changeChildrenOrder, - deleteEnumValue, deleteNode, promoteProperty, setCombinationType, @@ -20,9 +18,7 @@ import { } from './ui-schema-reducers'; import type { AddCombinationItemArgs, - AddEnumValueArgs, ChangeChildrenOrderArgs, - DeleteEnumValueArgs, SetCombinationTypeArgs, SetDescriptionArgs, SetPropertyNameArgs, @@ -35,7 +31,6 @@ import type { import { allOfNodeMock, arrayNodeMock, - enumNodeMock, numberNodeMock, optionalNodeMock, parentNodeMock, @@ -43,17 +38,13 @@ import { stringNodeMock, uiSchemaMock, simpleParentNodeMock, - simpleArrayMock, referenceNodeMock, unusedDefinitionMock, + simpleArrayMock, + referenceNodeMock, + unusedDefinitionMock, } from '../../../test/uiSchemaMock'; import { getChildNodesByFieldPointer } from '../selectors'; import { expect } from '@jest/globals'; -import { - CombinationKind, - FieldType, - Keyword, - ObjectKind, - StrRestrictionKey, -} from '../../types'; +import { CombinationKind, FieldType, Keyword, ObjectKind, StrRestrictionKey } from '../../types'; import { ROOT_POINTER } from '../constants'; import { getPointers } from '../mappers/getPointers'; import { substringAfterLast, substringBeforeLast } from 'app-shared/utils/stringUtils'; @@ -78,45 +69,6 @@ describe('ui-schema-reducers', () => { jest.clearAllMocks(); }); - describe('addEnumValue', () => { - const { pointer } = enumNodeMock; - const value = 'val4'; - - it('Adds an enum value to the given node if oldValue is not given', () => { - const args: AddEnumValueArgs = { path: pointer, value }; - result = addEnumValue(createNewModelMock(), args); - const updatedNode = result.getNode(pointer) as FieldNode; - expect(updatedNode.enum).toEqual([...enumNodeMock.enum, value]); - }); - - it('Adds an enum value to the given node if oldValue does not exist', () => { - const oldValue = 'val5'; - const args: AddEnumValueArgs = { path: pointer, value, oldValue }; - result = addEnumValue(createNewModelMock(), args); - const updatedNode = result.getNode(pointer) as FieldNode; - expect(updatedNode.enum).toEqual([...enumNodeMock.enum, value]); - }); - - it('Replaces oldValue if it exists', () => { - const oldValue = enumNodeMock.enum[0]; - const args: AddEnumValueArgs = { path: pointer, value, oldValue }; - result = addEnumValue(createNewModelMock(), args); - const updatedNode = result.getNode(pointer) as FieldNode; - expect(updatedNode.enum).toEqual([value, ...enumNodeMock.enum.slice(1)]); - }); - }); - - describe('deleteEnumValue', () => { - it('Deletes the given enum value from the given node', () => { - const path = enumNodeMock.pointer; - const value = enumNodeMock.enum[0]; - const args: DeleteEnumValueArgs = { path, value }; - result = deleteEnumValue(createNewModelMock(), args); - const updatedNode = result.getNode(path) as FieldNode; - expect(updatedNode.enum).toEqual(enumNodeMock.enum.slice(1)); - }); - }); - describe('promoteProperty', () => { it('Converts a property to a root level definition', () => { const { pointer } = stringNodeMock; diff --git a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts index 7f65f7c33f0..7abaca74582 100644 --- a/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts +++ b/frontend/packages/schema-model/src/lib/mutations/ui-schema-reducers.ts @@ -3,214 +3,189 @@ import { isField, isReference, splitPointerInBaseAndName } from '../utils'; import { convertPropToType } from './convert-node'; import { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; import { castRestrictionType } from '../restrictions'; -import { removeItemByValue, swapArrayElements } from 'app-shared/utils/arrayUtils'; +import { swapArrayElements } from 'app-shared/utils/arrayUtils'; import { changeNameInPointer } from '../pointerUtils'; -import { SchemaModel } from '../SchemaModel'; -export type AddEnumValueArgs = { - path: string; - value: string; - oldValue?: string; -}; -export const addEnumValue: UiSchemaReducer = - (uiSchema: SchemaModel, { path, value, oldValue }) => { - const newSchema = uiSchema.deepClone(); - const node = newSchema.getNode(path); - if (isField(node)) { - node.enum = node.enum ?? []; - if (oldValue === null || oldValue === undefined) node.enum.push(value); - if (node.enum.includes(oldValue)) node.enum[node.enum.indexOf(oldValue)] = value; - if (!node.enum.includes(value)) node.enum.push(value); - return newSchema; - } - }; - -export type DeleteEnumValueArgs = { - path: string; - value: string; -}; -export const deleteEnumValue: UiSchemaReducer = - (uiSchema: SchemaModel, { path, value }) => { - const newSchema = uiSchema.deepClone(); - const enumItem = newSchema.getNode(path); - if (isField(enumItem)) { - enumItem.enum = removeItemByValue(enumItem.enum, value); - } - return newSchema; - }; - -export const promoteProperty: UiSchemaReducer = - (uiSchema, path) => { +export const promoteProperty: UiSchemaReducer = (uiSchema, path) => { const newSchema = uiSchema.deepClone(); return convertPropToType(newSchema, path); }; -export const deleteNode: UiSchemaReducer = - (uiSchema, path) => { - const newSchema = uiSchema.deepClone(); - newSchema.deleteNode(path); - return newSchema; - }; +export const deleteNode: UiSchemaReducer = (uiSchema, path) => { + const newSchema = uiSchema.deepClone(); + newSchema.deleteNode(path); + return newSchema; +}; export type SetRestrictionArgs = { path: string; key: string; value?: string | boolean; }; -export const setRestriction: UiSchemaReducer = - (uiSchema, { path, key, value }) => { - const newSchema = uiSchema.deepClone(); - const schemaItem = newSchema.getNode(path); - const restrictions = { ...schemaItem.restrictions }; - restrictions[key] = castRestrictionType(key, value); - Object.keys(restrictions).forEach((k) => { - if (restrictions[k] === undefined) { - delete restrictions[k]; - } - }); - schemaItem.restrictions = restrictions; - return newSchema; - }; +export const setRestriction: UiSchemaReducer = ( + uiSchema, + { path, key, value }, +) => { + const newSchema = uiSchema.deepClone(); + const schemaItem = newSchema.getNode(path); + const restrictions = { ...schemaItem.restrictions }; + restrictions[key] = castRestrictionType(key, value); + Object.keys(restrictions).forEach((k) => { + if (restrictions[k] === undefined) { + delete restrictions[k]; + } + }); + schemaItem.restrictions = restrictions; + return newSchema; +}; export type SetRestrictionsArgs = { path: string; restrictions: KeyValuePairs; }; -export const setRestrictions: UiSchemaReducer = - (uiSchema, { path, restrictions }) => { - const newSchema = uiSchema.deepClone(); - const schemaItem = newSchema.getNode(path); - const schemaItemRestrictions = { ...schemaItem.restrictions }; - Object.keys(restrictions).forEach((key) => { - schemaItemRestrictions[key] = castRestrictionType(key, restrictions[key]); - }); - Object.keys(schemaItemRestrictions).forEach((k) => { - if (schemaItemRestrictions[k] === undefined) { - delete schemaItemRestrictions[k]; - } - }); - schemaItem.restrictions = schemaItemRestrictions; - return newSchema; - }; +export const setRestrictions: UiSchemaReducer = ( + uiSchema, + { path, restrictions }, +) => { + const newSchema = uiSchema.deepClone(); + const schemaItem = newSchema.getNode(path); + const schemaItemRestrictions = { ...schemaItem.restrictions }; + Object.keys(restrictions).forEach((key) => { + schemaItemRestrictions[key] = castRestrictionType(key, restrictions[key]); + }); + Object.keys(schemaItemRestrictions).forEach((k) => { + if (schemaItemRestrictions[k] === undefined) { + delete schemaItemRestrictions[k]; + } + }); + schemaItem.restrictions = schemaItemRestrictions; + return newSchema; +}; export type SetRefArgs = { path: string; ref: string; }; -export const setRef: UiSchemaReducer = - (uiSchema, { path, ref }) => { - const newSchema = uiSchema.deepClone(); - const uiSchemaNode = newSchema.getNode(path); - if (isReference(uiSchemaNode)) { - uiSchemaNode.reference = ref; - uiSchemaNode.implicitType = true; - } - return newSchema; - }; +export const setRef: UiSchemaReducer = (uiSchema, { path, ref }) => { + const newSchema = uiSchema.deepClone(); + const uiSchemaNode = newSchema.getNode(path); + if (isReference(uiSchemaNode)) { + uiSchemaNode.reference = ref; + uiSchemaNode.implicitType = true; + } + return newSchema; +}; export type SetTypeArgs = { path: string; type: FieldType; }; -export const setType: UiSchemaReducer = - (uiSchema, { path, type }) => { - const newSchema = uiSchema.deepClone(); - const uiSchemaNode = newSchema.getNode(path); - if (isField(uiSchemaNode)) { - uiSchemaNode.children = []; - uiSchemaNode.fieldType = type; - uiSchemaNode.implicitType = false; - } - return newSchema; - }; +export const setType: UiSchemaReducer = (uiSchema, { path, type }) => { + const newSchema = uiSchema.deepClone(); + const uiSchemaNode = newSchema.getNode(path); + if (isField(uiSchemaNode)) { + uiSchemaNode.children = []; + uiSchemaNode.fieldType = type; + uiSchemaNode.implicitType = false; + } + return newSchema; +}; export type SetTitleArgs = { path: string; title: string; }; -export const setTitle: UiSchemaReducer = - (uiSchema, { path, title }) => { - const newSchema = uiSchema.deepClone(); - newSchema.getNode(path).title = title; - return newSchema; - }; +export const setTitle: UiSchemaReducer = (uiSchema, { path, title }) => { + const newSchema = uiSchema.deepClone(); + newSchema.getNode(path).title = title; + return newSchema; +}; export type SetDescriptionArgs = { path: string; description: string; }; -export const setDescription: UiSchemaReducer = - (uiSchema, { path, description }) => { - const newSchema = uiSchema.deepClone(); - newSchema.getNode(path).description = description; - return newSchema; - }; +export const setDescription: UiSchemaReducer = ( + uiSchema, + { path, description }, +) => { + const newSchema = uiSchema.deepClone(); + newSchema.getNode(path).description = description; + return newSchema; +}; export type SetRequiredArgs = { path: string; required: boolean; }; -export const setRequired: UiSchemaReducer = - (uiSchema, { path, required }) => { - const newSchema = uiSchema.deepClone(); - newSchema.getNode(path).isRequired = required; - return newSchema; - }; +export const setRequired: UiSchemaReducer = (uiSchema, { path, required }) => { + const newSchema = uiSchema.deepClone(); + newSchema.getNode(path).isRequired = required; + return newSchema; +}; export type SetCustomPropertiesArgs = { path: string; properties: KeyValuePairs; }; -export const setCustomProperties: UiSchemaReducer = - (uiSchema, { path, properties }) => { - const newSchema = uiSchema.deepClone(); - newSchema.getNode(path).custom = properties; - return newSchema; - }; +export const setCustomProperties: UiSchemaReducer = ( + uiSchema, + { path, properties }, +) => { + const newSchema = uiSchema.deepClone(); + newSchema.getNode(path).custom = properties; + return newSchema; +}; export type SetCombinationTypeArgs = { path: string; type: CombinationKind; }; -export const setCombinationType: UiSchemaReducer = - (uiSchema, { path, type }) => { - const newSchema = uiSchema.deepClone(); - newSchema.changeCombinationType(path, type); - return newSchema; - }; +export const setCombinationType: UiSchemaReducer = ( + uiSchema, + { path, type }, +) => { + const newSchema = uiSchema.deepClone(); + newSchema.changeCombinationType(path, type); + return newSchema; +}; export type AddCombinationItemArgs = { pointer: string; callback: (pointer: string) => void; }; -export const addCombinationItem: UiSchemaReducer = - (uiSchema, { pointer, callback }) => { - const newSchema = uiSchema.deepClone(); - const name = newSchema.generateUniqueChildName(pointer); - const target: NodePosition = { parentPointer: pointer, index: -1 }; - const newNode = newSchema.addField(name, FieldType.Null, target); - callback(newNode.pointer); - return newSchema; - }; +export const addCombinationItem: UiSchemaReducer = ( + uiSchema, + { pointer, callback }, +) => { + const newSchema = uiSchema.deepClone(); + const name = newSchema.generateUniqueChildName(pointer); + const target: NodePosition = { parentPointer: pointer, index: -1 }; + const newNode = newSchema.addField(name, FieldType.Null, target); + callback(newNode.pointer); + return newSchema; +}; export type SetPropertyNameArgs = { path: string; name: string; callback?: (pointer: string) => void; }; -export const setPropertyName: UiSchemaReducer = - (uiSchema, { path, name, callback }) => { - if (!name || name.length === 0) { - return uiSchema; - } - const newSchema = uiSchema.deepClone(); - const nodeToRename = newSchema.getNode(path); - const newPointer = changeNameInPointer(path, name); - const newNode = { ...nodeToRename, pointer: newPointer }; - newSchema.updateNode(path, newNode); - callback?.(newPointer); - return newSchema; - }; +export const setPropertyName: UiSchemaReducer = ( + uiSchema, + { path, name, callback }, +) => { + if (!name || name.length === 0) { + return uiSchema; + } + const newSchema = uiSchema.deepClone(); + const nodeToRename = newSchema.getNode(path); + const newPointer = changeNameInPointer(path, name); + const newNode = { ...nodeToRename, pointer: newPointer }; + newSchema.updateNode(path, newNode); + callback?.(newPointer); + return newSchema; +}; export const toggleArrayField: UiSchemaReducer = (uiSchema, pointer) => { const newSchema = uiSchema.deepClone(); @@ -222,14 +197,15 @@ export type ChangeChildrenOrderArgs = { pointerA: string; pointerB: string; }; -export const changeChildrenOrder: UiSchemaReducer = - (uiSchema, { pointerA, pointerB }) => { - const { base: baseA } = splitPointerInBaseAndName(pointerA); - const { base: baseB } = splitPointerInBaseAndName(pointerB); - if (baseA !== baseB) return uiSchema; - const newSchema = uiSchema.deepClone(); - const parentNode = newSchema.getParentNode(pointerA); - if (parentNode) parentNode.children = swapArrayElements(parentNode.children, pointerA, pointerB); - return newSchema; - }; - +export const changeChildrenOrder: UiSchemaReducer = ( + uiSchema, + { pointerA, pointerB }, +) => { + const { base: baseA } = splitPointerInBaseAndName(pointerA); + const { base: baseB } = splitPointerInBaseAndName(pointerB); + if (baseA !== baseB) return uiSchema; + const newSchema = uiSchema.deepClone(); + const parentNode = newSchema.getParentNode(pointerA); + if (parentNode) parentNode.children = swapArrayElements(parentNode.children, pointerA, pointerB); + return newSchema; +}; diff --git a/frontend/packages/shared/src/utils/arrayUtils.test.ts b/frontend/packages/shared/src/utils/arrayUtils.test.ts index e91166be3f0..3bee5d660e3 100644 --- a/frontend/packages/shared/src/utils/arrayUtils.test.ts +++ b/frontend/packages/shared/src/utils/arrayUtils.test.ts @@ -7,7 +7,10 @@ import { mapByKey, moveArrayItem, prepend, + removeEmptyStrings, + removeItemByIndex, removeItemByValue, + replaceByIndex, replaceByPredicate, replaceItemsByValue, swapArrayElements, @@ -42,6 +45,15 @@ describe('arrayUtils', () => { }); }); + describe('removeItemByIndex', () => { + it('Deletes item from array by value', () => { + expect(removeItemByIndex([1, 2, 3], 1)).toEqual([1, 3]); + expect(removeItemByIndex(['a', 'b', 'c'], 1)).toEqual(['a', 'c']); + expect(removeItemByIndex(['a', 'b', 'c'], 3)).toEqual(['a', 'b', 'c']); + expect(removeItemByIndex([], 1)).toEqual([]); + }); + }); + describe('areItemsUnique', () => { it('Returns true if all items are unique', () => { expect(areItemsUnique([1, 2, 3])).toBe(true); @@ -169,4 +181,29 @@ describe('arrayUtils', () => { expect(generateUniqueStringWithNumber(array)).toBe('3'); }); }); + + describe('removeEmptyStrings', () => { + it('Removes empty strings from an array', () => { + const array = ['0', '1', '', '2', '']; + expect(removeEmptyStrings(array)).toEqual(['0', '1', '2']); + }); + }); + + describe('replaceByIndex', () => { + it('Replaces element in array with new value', () => { + const array1 = ['0', '1', '2']; + expect(replaceByIndex(array1, 0, '1')).toEqual(['1', '1', '2']); + + const array2 = [0, 1, 2]; + expect(replaceByIndex(array2, 1, 2)).toEqual([0, 2, 2]); + + const array3 = [true, false, true]; + expect(replaceByIndex(array3, 2, false)).toEqual([true, false, false]); + }); + + it('Returns intial array if index is invalid', () => { + const array = [0, 1, 2]; + expect(replaceByIndex(array, 4, 2)).toEqual(array); + }); + }); }); diff --git a/frontend/packages/shared/src/utils/arrayUtils.ts b/frontend/packages/shared/src/utils/arrayUtils.ts index 50569958b26..18d05897663 100644 --- a/frontend/packages/shared/src/utils/arrayUtils.ts +++ b/frontend/packages/shared/src/utils/arrayUtils.ts @@ -33,6 +33,15 @@ export const replaceLastItem = (array: T[], replaceWith: T): T[] => { export const removeItemByValue = (array: T[], value: T): T[] => array.filter((item) => item !== value); +/** + * Removes item from array by index. + * @param array Array to delete item from. + * @param indexToRemove Index of element to remove. + * @returns Array without the element at the given index. + */ +export const removeItemByIndex = (array: T[], indexToRemove: number): T[] => + array.filter((_, index) => index !== indexToRemove); + /** * Checks if all items in the given array are unique. * @param array The array of interest. @@ -137,3 +146,16 @@ export const generateUniqueStringWithNumber = (array: string[], prefix: string = } return uniqueString; }; + +/** Removes empty strings from a string array */ +export const removeEmptyStrings = (array: string[]): string[] => removeItemByValue(array, ''); + +/** Replaces an element in an array with a new value */ +export const replaceByIndex = (array: T[], index: number, newValue: T): T[] => { + if (index < 0 || index >= array.length) return array; + + const newArray = [...array]; + newArray[index] = newValue; + + return newArray; +}; From c37740c34e14cfb2f9160da1b08c9c683f9df93c Mon Sep 17 00:00:00 2001 From: WilliamThorenfeldt <133344438+WilliamThorenfeldt@users.noreply.github.com> Date: Wed, 13 Dec 2023 08:10:23 +0100 Subject: [PATCH 09/21] Bug fix/11366 replace altinn menu with dropdown menu at lage page (#11779) * replacing old component with new DropdownMenu and solving issue * adding tests * refactor to use popover correctly * fixing broken code * trying to fix broken tests * replacing popover with confirm * fixing feedback from PR * fixing comment --- frontend/language/src/nb.json | 5 +- .../InputPopover/InputPopover.test.tsx | 78 ++++++-- .../InputPopover/InputPopover.tsx | 166 ++++++++---------- .../NavigationMenu/NavigationMenu.test.tsx | 48 ++--- .../NavigationMenu/NavigationMenu.tsx | 164 ++++++----------- .../PageAccordion/PageAccordion.test.tsx | 32 +++- .../PageAccordion/PageAccordion.tsx | 42 ++++- .../PageAccordion/useDeleteLayout.ts | 11 ++ 8 files changed, 278 insertions(+), 268 deletions(-) create mode 100644 frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/useDeleteLayout.ts diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 8b35166816e..ceea614040b 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1516,10 +1516,7 @@ "ux_editor.no_components_selected": "Velg en side for å se forhåndsvisningen", "ux_editor.no_text": "Ingen tekst", "ux_editor.page": "Side", - "ux_editor.page_delete_confirm": "Ja, slett skjemasiden", - "ux_editor.page_delete_information": "Alt innholdet på siden vil bli fjernet.", - "ux_editor.page_delete_text": "Er du sikker på at du vil slette denne siden?", - "ux_editor.page_menu_delete": "Slett", + "ux_editor.page_delete_text": "Er du sikker på at du vil slette denne siden?\nAlt innholdet på siden vil bli fjernet.", "ux_editor.page_menu_down": "Flytt ned", "ux_editor.page_menu_edit": "Gi nytt navn", "ux_editor.page_menu_up": "Flytt opp", diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.test.tsx index 6e4fcaa6e7c..06040710286 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.test.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.test.tsx @@ -17,20 +17,56 @@ const mockSaveNewName = jest.fn(); const mockOnClose = jest.fn(); const defaultProps: InputPopoverProps = { + disabled: false, oldName: mockOldName, layoutOrder: mockLayoutOrder, saveNewName: mockSaveNewName, onClose: mockOnClose, - open: true, - trigger: , }; describe('InputPopover', () => { - const user = userEvent.setup(); afterEach(jest.clearAllMocks); + it('does hides dropdown menu item by default when not open', () => { + render(); + + const input = screen.queryByLabelText(textMock('ux_editor.input_popover_label')); + expect(input).not.toBeInTheDocument(); + }); + + it('opens the popover when the dropdown menu item is clicked', async () => { + render(); + + const input = screen.queryByLabelText(textMock('ux_editor.input_popover_label')); + expect(input).not.toBeInTheDocument(); + + await openDropdownMenuItem(); + + const inputAfter = screen.getByLabelText(textMock('ux_editor.input_popover_label')); + expect(inputAfter).toBeInTheDocument(); + }); + + it('saves the new name on Enter key press', async () => { + const user = userEvent.setup(); + render(); + + await openDropdownMenuItem(); + + const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); + expect(input).toHaveValue(mockOldName); + + await act(() => user.type(input, mockNewValue)); + await act(() => user.keyboard('{Enter}')); + + expect(mockSaveNewName).toHaveBeenCalledTimes(1); + expect(mockSaveNewName).toHaveBeenCalledWith(mockNewName); + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + it('calls the "saveNewName" function when the confirm button is clicked', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(input).toHaveValue(mockOldName); @@ -50,7 +86,9 @@ describe('InputPopover', () => { }); it('does not call "saveNewName" when input is same as old value', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(input).toHaveValue(mockOldName); @@ -59,22 +97,10 @@ describe('InputPopover', () => { expect(mockSaveNewName).toHaveBeenCalledTimes(0); }); - it('saves the new name on Enter key press', async () => { - render(); - - const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); - expect(input).toHaveValue(mockOldName); - - await act(() => user.type(input, mockNewValue)); - await act(() => user.keyboard('{Enter}')); - - expect(mockSaveNewName).toHaveBeenCalledTimes(1); - expect(mockSaveNewName).toHaveBeenCalledWith(mockNewName); - expect(mockOnClose).toHaveBeenCalledTimes(1); - }); - it('cancels the new name on Escape key press', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(input).toHaveValue(mockOldName); @@ -87,7 +113,9 @@ describe('InputPopover', () => { }); it('displays error message if new name is not unique', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(input).toHaveValue(mockOldName); @@ -102,7 +130,9 @@ describe('InputPopover', () => { }); it('displays error message if new name is empty', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(input).toHaveValue(mockOldName); @@ -117,7 +147,9 @@ describe('InputPopover', () => { }); it('displays error message if new name is too long', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(input).toHaveValue(mockOldName); @@ -133,7 +165,9 @@ describe('InputPopover', () => { }); it('displays error message if new name has illegal format', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const input = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(input).toHaveValue(mockOldName); @@ -149,7 +183,9 @@ describe('InputPopover', () => { }); it('closes the popover when cancel button is clicked', async () => { + const user = userEvent.setup(); render(); + await openDropdownMenuItem(); const button = screen.getByRole('button', { name: textMock('general.cancel') }); await act(() => user.click(button)); @@ -157,3 +193,11 @@ describe('InputPopover', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); }); + +const openDropdownMenuItem = async () => { + const user = userEvent.setup(); + const dropdownMenuItem = screen.getByRole('menuitem', { + name: textMock('ux_editor.page_menu_edit'), + }); + await act(() => user.click(dropdownMenuItem)); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.tsx index a27ed4b00e5..bb4bc97af29 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/InputPopover/InputPopover.tsx @@ -1,83 +1,52 @@ -import React, { ReactNode, useRef, useEffect, ChangeEvent, useState } from 'react'; +import React, { ReactNode, ChangeEvent, useState, useRef, KeyboardEvent } from 'react'; import classes from './InputPopover.module.css'; -import { Button, ErrorMessage, LegacyPopover, Textfield } from '@digdir/design-system-react'; +import { + Button, + DropdownMenu, + ErrorMessage, + Popover, + Textfield, +} from '@digdir/design-system-react'; import { useTranslation } from 'react-i18next'; import { getPageNameErrorKey } from '../../../../../utils/designViewUtils'; +import { PencilIcon } from '@studio/icons'; export type InputPopoverProps = { - /** - * The old name of the page - */ + disabled: boolean; oldName: string; - /** - * The list containing all page names - */ layoutOrder: string[]; - /** - * Saves the new name of the page - * @param newName the new name to save - * @returns void - */ saveNewName: (newName: string) => void; - /** - * Function to be executed when closing the popover - * @param event optional mouse event - * @returns void - */ - onClose: (event?: React.MouseEvent | MouseEvent) => void; - /** - * If the popover is open or not - */ - open: boolean; - /** - * The component that triggers the opening of the popover - */ - trigger: ReactNode; + onClose: () => void; }; /** * @component - * Displays a popover where the user can edit the name of the page + * Displays a dropdown menu item with a popover where the user can edit the name of the page * + * @property {boolean}[disabled] - If the dropdown item is disabled * @property {string}[oldName] - The old name of the page * @property {string[]}[layoutOrder] - The list containing all page names * @property {function}[saveNewName] - Saves the new name of the page - * @property {function}[onClose] - Function to be executed when closing the popover - * @property {boolean}[open] - If the popover is open or not - * @property {ReactNode}[trigger] - The component that triggers the opening of the popover + * @property {function}[onClose] - Function to be executed on close * * @returns {ReactNode} - The rendered component */ export const InputPopover = ({ + disabled, oldName, layoutOrder, saveNewName, onClose, - open = false, - trigger, }: InputPopoverProps): ReactNode => { const { t } = useTranslation(); - const ref = useRef(null); + const newNameRef = useRef(null); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [newName, setNewName] = useState(oldName); const shouldSavingBeEnabled = errorMessage === null && newName !== oldName; - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (ref.current && !ref.current.contains(event.target)) { - onClose(event); - } - }; - if (open) { - document.addEventListener('mousedown', handleClickOutside); - } - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - }, [onClose, open]); - /** * Handles the change of the new page name. If the name exists, is empty, is too * long, or has a wrong format, an error is set, otherwise the value displayed is changed. @@ -89,58 +58,67 @@ export const InputPopover = ({ setNewName(newNameCandidate); }; - /** - * If there is no error and the name is changed, and enter is clicked, the new name is saved. - * When Escape is clicked, the popover closes. - */ - const handleKeyPress = (event) => { + const handleKeyPress = (event: KeyboardEvent) => { if (event.key === 'Enter' && !errorMessage && oldName !== newName) { saveNewName(newName); - onClose(); - } else if (event.key === 'Escape') { - onClose(); - setNewName(oldName); - setErrorMessage(null); + handleClose(); } }; + const handleClose = () => { + onClose(); + setIsEditDialogOpen(false); + }; + return ( -
- - - - {errorMessage} - -
- - -
-
-
+ onChange={handleOnChange} + onKeyDown={handleKeyPress} + value={newName} + error={errorMessage !== null} + /> + + {errorMessage} + +
+ + +
+ + + ); }; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.test.tsx index 9dde9eb8486..81b7aa385a2 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.test.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.test.tsx @@ -9,14 +9,12 @@ import { renderWithMockStore, } from '../../../../testing/mocks'; import { formDesignerMock } from '../../../../testing/stateMocks'; -import { layout2NameMock } from '../../../../testing/layoutMock'; import { useFormLayoutSettingsQuery } from '../../../../hooks/queries/useFormLayoutSettingsQuery'; const mockOrg = 'org'; const mockApp = 'app'; const mockPageName1: string = formDesignerMock.layout.selectedLayout; const mockSelectedLayoutSet = 'test-layout-set'; -const mockPageName2 = layout2NameMock; const mockSetSearchParams = jest.fn(); const mockSearchParams = { layout: mockPageName1 }; @@ -49,34 +47,10 @@ describe('NavigationMenu', () => { const menuButton = screen.getByRole('button', { name: textMock('general.options') }); await act(() => user.click(menuButton)); - const elementInMenuAfter = screen.getByText(textMock('ux_editor.page_menu_up')); - expect(elementInMenuAfter).toBeInTheDocument(); - }); - - it('should update the url to new page when deleting selected page', async () => { - const user = userEvent.setup(); - await render(); - - const menuButton = screen.getByRole('button', { name: textMock('general.options') }); - await act(() => user.click(menuButton)); - - const deleteButton = screen.getByText(textMock('ux_editor.page_menu_delete')); - await act(() => user.click(deleteButton)); - - const confirmButton = screen.getByRole('button', { - name: textMock('ux_editor.page_delete_confirm'), + const elementInMenuAfter = screen.getByRole('menuitem', { + name: textMock('ux_editor.page_menu_up'), }); - await act(() => user.click(confirmButton)); - - expect(queriesMock.deleteFormLayout).toBeCalledWith( - mockOrg, - mockApp, - mockPageName1, - mockSelectedLayoutSet, - ); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - - expect(mockSetSearchParams).toHaveBeenCalledWith({ layout: mockPageName2 }); + expect(elementInMenuAfter).toBeInTheDocument(); }); it('Calls updateFormLayoutName with new name when name is changed by the user', async () => { @@ -84,7 +58,9 @@ describe('NavigationMenu', () => { await render(); await act(() => user.click(screen.getByTitle(textMock('general.options')))); - await act(() => user.click(screen.getByText(textMock('ux_editor.page_menu_edit')))); + await act(() => + user.click(screen.getByRole('menuitem', { name: textMock('ux_editor.page_menu_edit') })), + ); const inputField = screen.getByLabelText(textMock('ux_editor.input_popover_label')); expect(inputField).toHaveValue(mockPageName1); @@ -114,8 +90,10 @@ describe('NavigationMenu', () => { await act(() => user.click(screen.getByTitle(textMock('general.options')))); - const upButton = screen.queryByText(textMock('ux_editor.page_menu_up')); - const downButton = screen.queryByText(textMock('ux_editor.page_menu_down')); + const upButton = screen.queryByRole('menuitem', { name: textMock('ux_editor.page_menu_up') }); + const downButton = screen.queryByRole('menuitem', { + name: textMock('ux_editor.page_menu_down'), + }); expect(upButton).not.toBeInTheDocument(); expect(downButton).not.toBeInTheDocument(); @@ -127,8 +105,10 @@ describe('NavigationMenu', () => { await act(() => user.click(screen.getByTitle(textMock('general.options')))); - const upButton = screen.getByText(textMock('ux_editor.page_menu_up')); - const downButton = screen.getByText(textMock('ux_editor.page_menu_down')); + const upButton = screen.getByRole('menuitem', { name: textMock('ux_editor.page_menu_up') }); + const downButton = screen.getByRole('menuitem', { + name: textMock('ux_editor.page_menu_down'), + }); expect(upButton).toBeInTheDocument(); expect(downButton).toBeInTheDocument(); diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx index 704cb4174a9..fc771dec222 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx @@ -1,33 +1,19 @@ -import React, { ReactNode, MouseEvent, SyntheticEvent, useState } from 'react'; +import React, { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button } from '@digdir/design-system-react'; -import { - MenuElipsisVerticalIcon, - ArrowUpIcon, - ArrowDownIcon, - PencilIcon, - TrashIcon, -} from '@navikt/aksel-icons'; -import { AltinnMenu, AltinnMenuItem } from 'app-shared/components'; +import { Button, DropdownMenu } from '@digdir/design-system-react'; +import { MenuElipsisVerticalIcon, ArrowUpIcon, ArrowDownIcon } from '@navikt/aksel-icons'; import { useFormLayoutSettingsQuery } from '../../../../hooks/queries/useFormLayoutSettingsQuery'; import { useUpdateLayoutOrderMutation } from '../../../../hooks/mutations/useUpdateLayoutOrderMutation'; import { useUpdateLayoutNameMutation } from '../../../../hooks/mutations/useUpdateLayoutNameMutation'; import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; import { useSelector } from 'react-redux'; -import { useDeleteLayoutMutation } from '../../../../hooks/mutations/useDeleteLayoutMutation'; import type { IAppState } from '../../../../types/global'; -import { Divider } from 'app-shared/primitives'; -import { AltinnConfirmDialog } from 'app-shared/components'; import { useSearchParams } from 'react-router-dom'; -import { firstAvailableLayout } from '../../../../utils/formLayoutsUtils'; import { InputPopover } from './InputPopover'; import { deepCopy } from 'app-shared/pure'; import { useAppContext } from '../../../../hooks/useAppContext'; export type NavigationMenuProps = { - /** - * The name of the page - */ pageName: string; pageIsReceipt: boolean; }; @@ -37,10 +23,11 @@ export type NavigationMenuProps = { * Displays the buttons to move a page accoridon up or down, edit the name and delete the page * * @property {string}[pageName] - The name of the page + * @property {boolean}[pageIsReceipt] - If the page is a receipt page * - * @returns {ReactNode} - The rendered component + * @returns {JSX.Element} - The rendered component */ -export const NavigationMenu = ({ pageName, pageIsReceipt }: NavigationMenuProps): ReactNode => { +export const NavigationMenu = ({ pageName, pageIsReceipt }: NavigationMenuProps): JSX.Element => { const { t } = useTranslation(); const { org, app } = useStudioUrlParams(); @@ -58,41 +45,18 @@ export const NavigationMenu = ({ pageName, pageIsReceipt }: NavigationMenuProps) const disableDown = layoutOrder.indexOf(pageName) === layoutOrder.length - 1; const { mutate: updateLayoutOrder } = useUpdateLayoutOrderMutation(org, app, selectedLayoutSet); - const { mutate: deleteLayout } = useDeleteLayoutMutation(org, app, selectedLayoutSet); const { mutate: updateLayoutName } = useUpdateLayoutNameMutation(org, app, selectedLayoutSet); - const [menuAnchorEl, setMenuAnchorEl] = useState(null); - const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(); - const [isEditDialogOpen, setIsEditDialogOpen] = useState(); - const [searchParams, setSearchParams] = useSearchParams(); - const selectedLayout = searchParams.get('layout'); - - const onPageSettingsClick = (event: MouseEvent) => - setMenuAnchorEl(event.currentTarget); - - const onMenuClose = (_event: SyntheticEvent) => setMenuAnchorEl(null); - - const onMenuItemClick = (event: SyntheticEvent, action: 'up' | 'down' | 'edit' | 'delete') => { - if (action === 'delete') { - setIsConfirmDeleteDialogOpen((prevState) => !prevState); - } else if (action === 'edit') { - setIsEditDialogOpen((prevState) => !prevState); - } else { - if (action === 'up' || action === 'down') { - updateLayoutOrder({ layoutName: pageName, direction: action }); - } - setMenuAnchorEl(null); - } - }; - const handleConfirmDelete = () => { - deleteLayout(pageName); + const settingsRef = useRef(null); + const [dropdownOpen, setDropdownOpen] = useState(false); - if (selectedLayout === pageName) { - const layoutToSelect = firstAvailableLayout(pageName, layoutOrder); - setSearchParams({ layout: layoutToSelect }); + const moveLayout = (action: 'up' | 'down') => { + if (action === 'up' || action === 'down') { + updateLayoutOrder({ layoutName: pageName, direction: action }); } + setDropdownOpen(false); }; const handleSaveNewName = (newName: string) => { @@ -104,76 +68,50 @@ export const NavigationMenu = ({ pageName, pageIsReceipt }: NavigationMenuProps)
); }; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx index 950c03450a4..059991cd5aa 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx @@ -6,14 +6,16 @@ import { textMock } from '../../../../../../testing/mocks/i18nMock'; import { formDesignerMock } from '../../../testing/stateMocks'; import { useFormLayoutSettingsQuery } from '../../../hooks/queries/useFormLayoutSettingsQuery'; import { renderHookWithMockStore, renderWithMockStore } from '../../../testing/mocks'; +import { layout2NameMock } from '../../../testing/layoutMock'; const mockOrg = 'org'; const mockApp = 'app'; -const mockPageName: string = formDesignerMock.layout.selectedLayout; +const mockPageName1: string = formDesignerMock.layout.selectedLayout; const mockSelectedLayoutSet = 'test-layout-set'; +const mockPageName2 = layout2NameMock; const mockSetSearchParams = jest.fn(); -const mockSearchParams = { layout: mockPageName }; +const mockSearchParams = { layout: mockPageName1 }; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useParams: () => ({ @@ -25,6 +27,11 @@ jest.mock('react-router-dom', () => ({ }, })); +const mockDeleteFormLayout = jest.fn(); +jest.mock('./useDeleteLayout', () => ({ + useDeleteLayout: () => mockDeleteFormLayout, +})); + const mockChildren: ReactNode = (
@@ -33,7 +40,7 @@ const mockChildren: ReactNode = ( const mockOnClick = jest.fn(); const defaultProps: PageAccordionProps = { - pageName: mockPageName, + pageName: mockPageName1, children: mockChildren, isOpen: false, onClick: mockOnClick, @@ -46,7 +53,7 @@ describe('PageAccordion', () => { const user = userEvent.setup(); await render(); - const accordionButton = screen.getByRole('button', { name: mockPageName }); + const accordionButton = screen.getByRole('button', { name: mockPageName1 }); await act(() => user.click(accordionButton)); expect(mockOnClick).toHaveBeenCalledTimes(1); @@ -65,6 +72,23 @@ describe('PageAccordion', () => { const elementInMenuAfter = screen.getByText(textMock('ux_editor.page_menu_up')); expect(elementInMenuAfter).toBeInTheDocument(); }); + + it('Calls deleteLayout with pageName when delete button is clicked and deletion is confirmed, and updates the url correctly', async () => { + jest.spyOn(window, 'confirm').mockImplementation(jest.fn(() => true)); + await render(); + + await screen.getByRole('button', { name: textMock('general.delete') }).click(); + expect(mockDeleteFormLayout).toHaveBeenCalledTimes(1); + expect(mockDeleteFormLayout).toHaveBeenCalledWith(mockPageName1); + expect(mockSetSearchParams).toHaveBeenCalledWith({ layout: mockPageName2 }); + }); + + it('Does not call deleteLayout when delete button is clicked, but deletion is not confirmed', async () => { + jest.spyOn(window, 'confirm').mockImplementation(jest.fn(() => false)); + await render(); + await screen.getByRole('button', { name: textMock('general.delete') }).click(); + expect(mockDeleteFormLayout).not.toHaveBeenCalled(); + }); }); const waitForData = async () => { diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx index 18ea10f42f0..859ab542ec0 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx @@ -1,9 +1,17 @@ -import React, { ReactNode } from 'react'; +import React, { ReactNode, useCallback } from 'react'; import classes from './PageAccordion.module.css'; import cn from 'classnames'; -import { Accordion } from '@digdir/design-system-react'; +import { Accordion, Button } from '@digdir/design-system-react'; import { NavigationMenu } from './NavigationMenu'; import * as testids from '../../../../../../testing/testids'; +import { TrashIcon } from '@studio/icons'; +import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useAppContext } from '../../../hooks/useAppContext'; +import { firstAvailableLayout } from '../../../utils/formLayoutsUtils'; +import { useFormLayoutSettingsQuery } from '../../../hooks/queries/useFormLayoutSettingsQuery'; +import { useDeleteLayout } from './useDeleteLayout'; export type PageAccordionProps = { pageName: string; @@ -33,6 +41,28 @@ export const PageAccordion = ({ onClick, pageIsReceipt, }: PageAccordionProps): ReactNode => { + const { t } = useTranslation(); + const { org, app } = useStudioUrlParams(); + const { selectedLayoutSet } = useAppContext(); + const [searchParams, setSearchParams] = useSearchParams(); + const selectedLayout = searchParams.get('layout'); + + const { data: formLayoutSettings } = useFormLayoutSettingsQuery(org, app, selectedLayoutSet); + const layoutOrder = formLayoutSettings?.pages.order; + + const deleteLayout = useDeleteLayout(); + + const handleConfirmDelete = useCallback(() => { + if (confirm(t('ux_editor.page_delete_text'))) { + deleteLayout(pageName); + + if (selectedLayout === pageName) { + const layoutToSelect = firstAvailableLayout(pageName, layoutOrder); + setSearchParams({ layout: layoutToSelect }); + } + } + }, [deleteLayout, layoutOrder, pageName, selectedLayout, setSearchParams, t]); + return (
+
{ + const { org, app } = useStudioUrlParams(); + const { selectedLayoutSet } = useAppContext(); + const { mutate: deleteLayout } = useDeleteLayoutMutation(org, app, selectedLayoutSet); + return useMemo(() => deleteLayout, [deleteLayout]); +}; From 143f6edc8c2721a06bb44963df425aff270d717a Mon Sep 17 00:00:00 2001 From: JamalAlabdullah <90609090+JamalAlabdullah@users.noreply.github.com> Date: Wed, 13 Dec 2023 08:27:51 +0100 Subject: [PATCH 10/21] 11552 add GitHub links on contact page (#11815) * add github link to contact page --- frontend/language/src/nb.json | 3 +++ frontend/packages/shared/src/icons/GitHub.svg | 7 +++++++ .../pages/Contact/Contact.module.css | 7 ++++++- .../pages/Contact/Contact.test.tsx | 6 ++++++ .../studio-root/pages/Contact/Contact.tsx | 20 ++++++++++++++++++- 5 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 frontend/packages/shared/src/icons/GitHub.svg diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index ceea614040b..83688b4a3d5 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -85,6 +85,9 @@ "contact.email.heading": "Send e-post", "contact.email.link": "<0 href=\"mailto:servicedesk@altinn.no\">servicedesk@altinn.no", "contact.fetch_app_error_message": "Kunne ikke laste inn tittelen for denne appen. Prøv igjen senere.", + "contact.github_issue.content": "Dersom du har behov for funksjonalitet eller ser feil og mangler som må fikses, kan du rapportere det inn til oss ved å opprette en sak i Github.", + "contact.github_issue.heading": "Rapporter feil og mangler til oss", + "contact.github_issue.link": "<0 href=\"https://github.com/Altinn/altinn-studio/issues/new/choose\">Opprett sak i Github", "contact.slack.content": "Dersom du har spørsmål om hvordan du bygger en applikasjon, kan du snakke direkte med utviklingsteamet i Altinn Studio på Slack. De kan hjelpe deg med:", "contact.slack.content_list": "<0>å bygge applikasjonene slik du ønsker<0>svare på spørsmål og veilede<0>ta imot innspill på ny funksjonalitet", "contact.slack.heading": "Skriv melding i vår Slack-kanal", diff --git a/frontend/packages/shared/src/icons/GitHub.svg b/frontend/packages/shared/src/icons/GitHub.svg new file mode 100644 index 00000000000..7ed0d58feca --- /dev/null +++ b/frontend/packages/shared/src/icons/GitHub.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/frontend/studio-root/pages/Contact/Contact.module.css b/frontend/studio-root/pages/Contact/Contact.module.css index 7c351387756..2891f5b279f 100644 --- a/frontend/studio-root/pages/Contact/Contact.module.css +++ b/frontend/studio-root/pages/Contact/Contact.module.css @@ -40,12 +40,17 @@ justify-content: center; height: 60px; width: 60px; + box-sizing: border-box; } -.icon { +.emailIcon { font-size: 2.5rem; } +.githubIcon { + padding: 10px; +} + .textContainer { flex: 1; } diff --git a/frontend/studio-root/pages/Contact/Contact.test.tsx b/frontend/studio-root/pages/Contact/Contact.test.tsx index cddf7bbaafa..74dce22704d 100644 --- a/frontend/studio-root/pages/Contact/Contact.test.tsx +++ b/frontend/studio-root/pages/Contact/Contact.test.tsx @@ -21,5 +21,11 @@ describe('Contact', () => { expect(screen.getByText(textMock('contact.slack.content'))).toBeInTheDocument(); expect(screen.getByText(textMock('contact.slack.content_list'))).toBeInTheDocument(); expect(screen.getByText(textMock('contact.slack.link'))).toBeInTheDocument(); + + expect( + screen.getByRole('heading', { name: textMock('contact.github_issue.heading') }), + ).toBeInTheDocument(); + expect(screen.getByText(textMock('contact.github_issue.content'))).toBeInTheDocument(); + expect(screen.getByText(textMock('contact.github_issue.link'))).toBeInTheDocument(); }); }); diff --git a/frontend/studio-root/pages/Contact/Contact.tsx b/frontend/studio-root/pages/Contact/Contact.tsx index 2e785dd3a97..1e40e530c05 100644 --- a/frontend/studio-root/pages/Contact/Contact.tsx +++ b/frontend/studio-root/pages/Contact/Contact.tsx @@ -5,6 +5,8 @@ import { Trans, useTranslation } from 'react-i18next'; import { EnvelopeClosedIcon } from '@navikt/aksel-icons'; import { PageContainer } from 'app-shared/components/PageContainer/PageContainer'; import Slack from 'app-shared/icons/Slack.svg'; +import GitHub from 'app-shared/icons/GitHub.svg'; +import classNames from 'classnames'; export const Contact = () => { const { t } = useTranslation(); @@ -20,7 +22,7 @@ export const Contact = () => {
- +
@@ -55,6 +57,22 @@ export const Contact = () => {
+
+
+ +
+
+ + {t('contact.github_issue.heading')} + + {t('contact.github_issue.content')} + + + + + +
+
From 71d7acc66cd24ea4dbef2385abcdce12c3470965 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 13 Dec 2023 11:12:33 +0100 Subject: [PATCH 11/21] chore(deps): update actions/setup-dotnet action to v4 (#11829) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/designer-dotnet-test.yaml | 2 +- .github/workflows/gitea-designer-integration-tests.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/designer-dotnet-test.yaml b/.github/workflows/designer-dotnet-test.yaml index 23fef00d90e..450203ebcd0 100644 --- a/.github/workflows/designer-dotnet-test.yaml +++ b/.github/workflows/designer-dotnet-test.yaml @@ -24,7 +24,7 @@ jobs: DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE: false steps: - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: | 6.0.x diff --git a/.github/workflows/gitea-designer-integration-tests.yaml b/.github/workflows/gitea-designer-integration-tests.yaml index 16296e20494..c5c9d38b509 100644 --- a/.github/workflows/gitea-designer-integration-tests.yaml +++ b/.github/workflows/gitea-designer-integration-tests.yaml @@ -22,7 +22,7 @@ jobs: DOTNET_HOSTBUILDER__RELOADCONFIGONCHANGE: false steps: - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: dotnet-version: | 6.0.x From 558665436c6fff3aced4021ac3f79dd7ea239500 Mon Sep 17 00:00:00 2001 From: Michael Queyrichon Date: Wed, 13 Dec 2023 12:00:59 +0100 Subject: [PATCH 12/21] Warn user when leaving bpmn editor with unsaved changes (#11715) * Upgrade react-router-dom from 6.18.0 to 6.20.0 * Refactor routes * Create new hook to block navigation * Implement new hook * Move App code into RouterProvider * Add tests for useConfirmationNavigation hook * Fix unit tests * Fix Canvas unit tests * Rename hook to useConfirmationDialogOnPageLeave * Rename AppShell and Layout * Fix missing type * Add missing changes * Fixes after cr --- frontend/app-development/index.tsx | 16 +- frontend/app-development/{ => layout}/App.css | 0 .../{ => layout}/App.module.css | 2 +- .../app-development/{ => layout}/App.test.tsx | 13 +- frontend/app-development/{ => layout}/App.tsx | 17 +-- ...{AppShell.test.tsx => PageLayout.test.tsx} | 6 +- .../layout/{AppShell.tsx => PageLayout.tsx} | 2 +- frontend/app-development/package.json | 2 +- .../router/PageRoutes.module.css | 6 - .../app-development/router/PageRoutes.tsx | 50 +++--- frontend/app-preview/package.json | 2 +- frontend/dashboard/package.json | 2 +- frontend/language/src/nb.json | 1 + .../process-editor/src/ProcessEditor.test.tsx | 37 +++-- .../src/components/Canvas/Canvas.test.tsx | 39 +++-- .../src/components/Canvas/Canvas.tsx | 10 +- .../src/contexts/BpmnContext.tsx | 2 +- frontend/packages/shared/package.json | 2 +- .../useConfirmationDialogOnPageLeave.test.tsx | 142 ++++++++++++++++++ .../hooks/useConfirmationDialogOnPageLeave.ts | 34 +++++ frontend/resourceadm/package.json | 2 +- package.json | 8 +- yarn.lock | 54 +++---- 23 files changed, 329 insertions(+), 120 deletions(-) rename frontend/app-development/{ => layout}/App.css (100%) rename frontend/app-development/{ => layout}/App.module.css (92%) rename frontend/app-development/{ => layout}/App.test.tsx (83%) rename frontend/app-development/{ => layout}/App.tsx (91%) rename frontend/app-development/layout/{AppShell.test.tsx => PageLayout.test.tsx} (96%) rename frontend/app-development/layout/{AppShell.tsx => PageLayout.tsx} (96%) delete mode 100644 frontend/app-development/router/PageRoutes.module.css create mode 100644 frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx create mode 100644 frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts diff --git a/frontend/app-development/index.tsx b/frontend/app-development/index.tsx index 181c17d610e..a94a55a441b 100644 --- a/frontend/app-development/index.tsx +++ b/frontend/app-development/index.tsx @@ -3,9 +3,6 @@ import { createRoot } from 'react-dom/client'; import { Provider } from 'react-redux'; import { run } from './sagas'; import { setupStore } from './store'; -import { BrowserRouter } from 'react-router-dom'; -import { App } from './App'; -import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import * as queries from 'app-shared/api/queries'; import * as mutations from 'app-shared/api/mutations'; @@ -15,6 +12,7 @@ import { LoggerConfig, LoggerContextProvider } from 'app-shared/contexts/LoggerC import 'app-shared/design-tokens'; import { altinnStudioEnvironment } from 'app-shared/utils/altinnStudioEnv'; import { QueryClientConfig } from '@tanstack/react-query'; +import { PageRoutes } from './router/PageRoutes'; const store = setupStore(); @@ -44,13 +42,11 @@ const queryClientConfig: QueryClientConfig = { root.render( - - - - - - - + + + + + , ); diff --git a/frontend/app-development/App.css b/frontend/app-development/layout/App.css similarity index 100% rename from frontend/app-development/App.css rename to frontend/app-development/layout/App.css diff --git a/frontend/app-development/App.module.css b/frontend/app-development/layout/App.module.css similarity index 92% rename from frontend/app-development/App.module.css rename to frontend/app-development/layout/App.module.css index 68de738a5a7..77fc309e022 100644 --- a/frontend/app-development/App.module.css +++ b/frontend/app-development/layout/App.module.css @@ -7,7 +7,7 @@ ); --left-menu-width: 68px; - background-color: lightgray; + background: var(--fds-semantic-background-default); min-height: 100vh; width: 100%; display: flex; diff --git a/frontend/app-development/App.test.tsx b/frontend/app-development/layout/App.test.tsx similarity index 83% rename from frontend/app-development/App.test.tsx rename to frontend/app-development/layout/App.test.tsx index 980f0775245..11da8bf7a4f 100644 --- a/frontend/app-development/App.test.tsx +++ b/frontend/app-development/layout/App.test.tsx @@ -1,15 +1,15 @@ import React from 'react'; import { App } from './App'; -import type { IUserState } from './sharedResources/user/userSlice'; +import type { IUserState } from '../sharedResources/user/userSlice'; import { screen } from '@testing-library/react'; import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; -import { renderWithProviders } from './test/testUtils'; -import * as testids from '../testing/testids'; -import { textMock } from '../testing/mocks/i18nMock'; +import { renderWithProviders } from '../test/testUtils'; +import * as testids from '../../testing/testids'; +import { textMock } from '../../testing/mocks/i18nMock'; import { queriesMock } from 'app-shared/mocks/queriesMock'; -jest.mock('../language/src/nb.json', jest.fn()); -jest.mock('../language/src/en.json', jest.fn()); +jest.mock('../../language/src/nb.json', jest.fn()); +jest.mock('../../language/src/en.json', jest.fn()); // Mocking console.error due to Tanstack Query removing custom logger between V4 and v5 see issue: #11692 const realConsole = console; @@ -26,6 +26,7 @@ const render = async (remainingMinutes: number = 40) => { }, }); }; + describe('App', () => { beforeEach(() => { global.console = { diff --git a/frontend/app-development/App.tsx b/frontend/app-development/layout/App.tsx similarity index 91% rename from frontend/app-development/App.tsx rename to frontend/app-development/layout/App.tsx index 791a82acd89..f976ef124ef 100644 --- a/frontend/app-development/App.tsx +++ b/frontend/app-development/layout/App.tsx @@ -1,16 +1,16 @@ import React, { useCallback, useEffect, useRef } from 'react'; import postMessages from 'app-shared/utils/postMessages'; import { AltinnPopoverSimple } from 'app-shared/components/molecules/AltinnPopoverSimple'; -import { HandleServiceInformationActions } from './features/overview/handleServiceInformationSlice'; +import { HandleServiceInformationActions } from '../features/overview/handleServiceInformationSlice'; import { fetchRemainingSession, keepAliveSession, signOutUser, -} from './sharedResources/user/userSlice'; +} from '../sharedResources/user/userSlice'; import './App.css'; -import { matchPath, useLocation } from 'react-router-dom'; +import { Outlet, matchPath, useLocation } from 'react-router-dom'; import classes from './App.module.css'; -import { useAppDispatch, useAppSelector } from './hooks'; +import { useAppDispatch, useAppSelector } from '../hooks'; import { getRepositoryType } from 'app-shared/utils/repository'; import { RepositoryType } from 'app-shared/types/global'; import { @@ -21,12 +21,11 @@ import { } from 'app-shared/api/paths'; import i18next from 'i18next'; import { initReactI18next, useTranslation } from 'react-i18next'; -import nb from '../language/src/nb.json'; -import en from '../language/src/en.json'; +import nb from '../../language/src/nb.json'; +import en from '../../language/src/en.json'; import { DEFAULT_LANGUAGE } from 'app-shared/constants'; import { useRepoStatusQuery } from 'app-shared/hooks/queries'; -import * as testids from '../testing/testids'; -import { PageRoutes } from './router/PageRoutes'; +import * as testids from '../../testing/testids'; const TEN_MINUTES_IN_MILLISECONDS = 600000; @@ -158,7 +157,7 @@ export function App() {

{t('session.inactive')}

- +
); diff --git a/frontend/app-development/layout/AppShell.test.tsx b/frontend/app-development/layout/PageLayout.test.tsx similarity index 96% rename from frontend/app-development/layout/AppShell.test.tsx rename to frontend/app-development/layout/PageLayout.test.tsx index c4248403305..3be31feab19 100644 --- a/frontend/app-development/layout/AppShell.test.tsx +++ b/frontend/app-development/layout/PageLayout.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AppShell } from './AppShell'; +import { PageLayout } from './PageLayout'; import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; import { renderWithProviders } from '../test/testUtils'; @@ -43,7 +43,7 @@ jest.mock('react-router-dom', () => ({ // Mocking console.error due to Tanstack Query removing custom logger between V4 and v5 see issue: #11692 const realConsole = console; -describe('App', () => { +describe('PageLayout', () => { beforeEach(() => { global.console = { ...console, @@ -112,7 +112,7 @@ const render = async (queries: Partial = {}) => { ...queries, }; - renderWithProviders(, { + renderWithProviders(, { startUrl: `${APP_DEVELOPMENT_BASENAME}/my-org/my-app/${RoutePaths.Overview}`, queries: allQueries, }); diff --git a/frontend/app-development/layout/AppShell.tsx b/frontend/app-development/layout/PageLayout.tsx similarity index 96% rename from frontend/app-development/layout/AppShell.tsx rename to frontend/app-development/layout/PageLayout.tsx index e169f4b2946..8988f2c9624 100644 --- a/frontend/app-development/layout/AppShell.tsx +++ b/frontend/app-development/layout/PageLayout.tsx @@ -9,7 +9,7 @@ import { MergeConflictWarning } from '../features/simpleMerge/MergeConflictWarni /** * Displays the layout for the app development pages */ -export const AppShell = (): React.ReactNode => { +export const PageLayout = (): React.ReactNode => { const { pathname } = useLocation(); const match = matchPath({ path: '/:org/:app', caseSensitive: true, end: false }, pathname); const { org, app } = match.params; diff --git a/frontend/app-development/package.json b/frontend/app-development/package.json index 7cb79ab2fa2..97c339f264d 100644 --- a/frontend/app-development/package.json +++ b/frontend/app-development/package.json @@ -21,7 +21,7 @@ "react-dom": "18.2.0", "react-i18next": "13.3.1", "react-redux": "8.1.3", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "redux": "4.2.1", "redux-saga": "1.2.3", "reselect": "4.1.8" diff --git a/frontend/app-development/router/PageRoutes.module.css b/frontend/app-development/router/PageRoutes.module.css deleted file mode 100644 index 9b49d8dae29..00000000000 --- a/frontend/app-development/router/PageRoutes.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.root { - display: flex; - flex-direction: column; - min-height: 100vh; - background: white; -} diff --git a/frontend/app-development/router/PageRoutes.tsx b/frontend/app-development/router/PageRoutes.tsx index 2b4206f965e..441e66d5579 100644 --- a/frontend/app-development/router/PageRoutes.tsx +++ b/frontend/app-development/router/PageRoutes.tsx @@ -1,30 +1,40 @@ import React from 'react'; -import classes from './PageRoutes.module.css'; -import { Navigate, Route, Routes } from 'react-router-dom'; -import { AppShell } from 'app-development/layout/AppShell'; +import { + RouterProvider, + createBrowserRouter, + createRoutesFromElements, + Navigate, + Route, +} from 'react-router-dom'; +import { App } from 'app-development/layout/App'; +import { PageLayout } from 'app-development/layout/PageLayout'; import { RoutePaths } from 'app-development/enums/RoutePaths'; import { routerRoutes } from 'app-development/router/routes'; import { StudioNotFoundPage } from '@studio/components'; +import { APP_DEVELOPMENT_BASENAME } from 'app-shared/constants'; const BASE_PATH = '/:org/:app'; +const router = createBrowserRouter( + createRoutesFromElements( + }> + }> + {/* Redirects from /:org/:app to child route /overview */} + } /> + {routerRoutes.map((route) => ( + } /> + ))} + } /> + + } /> + , + ), + { + basename: APP_DEVELOPMENT_BASENAME, + }, +); + /** * Displays the routes for app development pages */ -export const PageRoutes = () => { - return ( -
- - }> - {/* Redirects from /:org/:app to child route /overview */} - } /> - {routerRoutes.map((route) => ( - } /> - ))} - } /> - - } /> - -
- ); -}; +export const PageRoutes = () => ; diff --git a/frontend/app-preview/package.json b/frontend/app-preview/package.json index 77450bd7d1a..80a78b6d8b2 100644 --- a/frontend/app-preview/package.json +++ b/frontend/app-preview/package.json @@ -15,7 +15,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-redux": "8.1.3", - "react-router-dom": "6.18.0" + "react-router-dom": "6.20.0" }, "devDependencies": { "cross-env": "7.0.3", diff --git a/frontend/dashboard/package.json b/frontend/dashboard/package.json index 6ae7d77798f..6ce959c34aa 100644 --- a/frontend/dashboard/package.json +++ b/frontend/dashboard/package.json @@ -12,7 +12,7 @@ "@mui/x-data-grid": "5.17.26", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "react-use": "17.4.0" }, "devDependencies": { diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 83688b4a3d5..f1830ab12f6 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -651,6 +651,7 @@ "process_editor.unknown_heading_error_message": "Obs, noe gikk galt!", "process_editor.unknown_paragraph_error_message": "En feil oppstod ved innlasting av BPMN-prosessen. Undersøk at filen er på et gyldig BPMN-format.", "process_editor.unsaved_changes": "Du har {{ count }} ulagrede endringer", + "process_editor.unsaved_changes_confirmation_message": "Du har ulagrede endringer. Vil du forlate siden uten å lagre?", "process_editor.view_mode": "Visningsmodus", "receipt.attachments": "Vedlegg", "receipt.body": "Det er gjennomført en maskinell kontroll under utfylling, men vi tar forbehold om at det kan bli oppdaget feil under saksbehandlingen og at annen dokumentasjon kan være nødvendig. Vennligst oppgi referansenummer ved eventuelle henvendelser til etaten.", diff --git a/frontend/packages/process-editor/src/ProcessEditor.test.tsx b/frontend/packages/process-editor/src/ProcessEditor.test.tsx index e658663ea95..4c83df6c36c 100644 --- a/frontend/packages/process-editor/src/ProcessEditor.test.tsx +++ b/frontend/packages/process-editor/src/ProcessEditor.test.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { render, screen, act } from '@testing-library/react'; +import { render as rtlRender, screen, act } from '@testing-library/react'; import { ProcessEditor, ProcessEditorProps } from './ProcessEditor'; import { textMock } from '../../../testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; +import { RouterProvider, createMemoryRouter } from 'react-router-dom'; const mockBPMNXML: string = ``; @@ -11,22 +12,34 @@ const mockAppLibVersion7: string = '7.0.3'; const mockOnSave = jest.fn(); +const defaultProps: ProcessEditorProps = { + bpmnXml: mockBPMNXML, + onSave: mockOnSave, + appLibVersion: mockAppLibVersion8, +}; + +const render = (props: Partial = {}) => { + const allProps = { ...defaultProps, ...props }; + const router = createMemoryRouter([ + { + path: '/', + element: , + }, + ]); + + return rtlRender(); +}; + describe('ProcessEditor', () => { afterEach(jest.clearAllMocks); - const defaultProps: ProcessEditorProps = { - bpmnXml: mockBPMNXML, - onSave: mockOnSave, - appLibVersion: mockAppLibVersion8, - }; - it('should render loading while bpmnXml is undefined', () => { - render(); + render({ bpmnXml: undefined }); expect(screen.getByTitle(textMock('process_editor.loading'))).toBeInTheDocument(); }); it('should render "NoBpmnFoundAlert" when bpmnXml is null', () => { - render(); + render({ bpmnXml: null }); expect( screen.getByRole('heading', { name: textMock('process_editor.fetch_bpmn_error_title'), @@ -38,7 +51,7 @@ describe('ProcessEditor', () => { it('should render "canvas" when bpmnXml is provided and default render is view-mode', async () => { // eslint-disable-next-line testing-library/no-unnecessary-act await act(() => { - render(); + render(); }); expect( @@ -48,7 +61,7 @@ describe('ProcessEditor', () => { it('does not display the alert when the version is 8 or newer', async () => { const user = userEvent.setup(); - render(); + render(); // Fix to remove act error await act(() => user.tab()); @@ -62,7 +75,7 @@ describe('ProcessEditor', () => { it('displays the alert when the version is 7 or older', async () => { const user = userEvent.setup(); - render(); + render({ appLibVersion: mockAppLibVersion7 }); // Fix to remove act error await act(() => user.tab()); diff --git a/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx b/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx index 1961d257357..7041eec6e1b 100644 --- a/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx +++ b/frontend/packages/process-editor/src/components/Canvas/Canvas.test.tsx @@ -1,17 +1,36 @@ import React from 'react'; import { render as rtlRender, screen, act } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { Canvas, CanvasProps } from './Canvas'; +import { Canvas } from './Canvas'; import { textMock } from '../../../../../testing/mocks/i18nMock'; -import { BpmnContextProvider } from '../../contexts/BpmnContext'; +import { BpmnContextProvider, BpmnContextProviderProps } from '../../contexts/BpmnContext'; +import { RouterProvider, createMemoryRouter } from 'react-router-dom'; const mockOnSave = jest.fn(); const mockAppLibVersion8: string = '8.0.1'; const mockAppLibVersion7: string = '7.0.1'; -const defaultProps: CanvasProps = { - onSave: mockOnSave, +const defaultProps: BpmnContextProviderProps = { + appLibVersion: mockAppLibVersion8, + bpmnXml: '', + children: null, +}; + +const render = (props: Partial = {}) => { + const allProps = { ...defaultProps, ...props }; + const router = createMemoryRouter([ + { + path: '/', + element: ( + + + + ), + }, + ]); + + return rtlRender(); }; describe('Canvas', () => { @@ -19,7 +38,7 @@ describe('Canvas', () => { it('hides actionMenu when version is 7 or older', async () => { const user = userEvent.setup(); - render(mockAppLibVersion7); + render({ appLibVersion: mockAppLibVersion7 }); // Fix to remove act error await act(() => user.tab()); @@ -30,7 +49,7 @@ describe('Canvas', () => { it('shows actionMenu when version is 8 or newer', async () => { const user = userEvent.setup(); - render(mockAppLibVersion8); + render({ appLibVersion: mockAppLibVersion8 }); // Fix to remove act error await act(() => user.tab()); @@ -38,12 +57,4 @@ describe('Canvas', () => { const editButton = screen.getByRole('button', { name: textMock('process_editor.edit_mode') }); expect(editButton).toBeInTheDocument; }); - - const render = (appLibVersion: string) => { - return rtlRender( - - - , - ); - }; }); diff --git a/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx b/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx index dad5a9ca171..816afea5c64 100644 --- a/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx +++ b/frontend/packages/process-editor/src/components/Canvas/Canvas.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import 'bpmn-js/dist/assets/diagram-js.css'; import 'bpmn-js/dist/assets/bpmn-js.css'; @@ -6,6 +7,7 @@ import 'bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css'; import classes from './Canvas.module.css'; import { useBpmnContext } from '../../contexts/BpmnContext'; +import { useConfirmationDialogOnPageLeave } from 'app-shared/hooks/useConfirmationDialogOnPageLeave'; import { BPMNViewer } from './BPMNViewer'; import { BPMNEditor } from './BPMNEditor'; import { CanvasActionMenu } from './CanvasActionMenu'; @@ -24,8 +26,9 @@ export type CanvasProps = { * @returns {JSX.Element} - The rendered component */ export const Canvas = ({ onSave }: CanvasProps): JSX.Element => { - const { getUpdatedXml, isEditAllowed } = useBpmnContext(); + const { getUpdatedXml, isEditAllowed, numberOfUnsavedChanges } = useBpmnContext(); const [isEditorView, setIsEditorView] = useState(false); + const { t } = useTranslation(); const toggleViewModus = (): void => { setIsEditorView((prevIsEditorView) => !prevIsEditorView); @@ -35,6 +38,11 @@ export const Canvas = ({ onSave }: CanvasProps): JSX.Element => { onSave(await getUpdatedXml()); }; + useConfirmationDialogOnPageLeave( + Boolean(numberOfUnsavedChanges), + t('process_editor.unsaved_changes_confirmation_message'), + ); + return (
{isEditAllowed && ( diff --git a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx index c3aba92f0c2..f2354532923 100644 --- a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx +++ b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx @@ -23,7 +23,7 @@ export const BpmnContext = createContext({ appLibVersion: '', }); -type BpmnContextProviderProps = { +export type BpmnContextProviderProps = { children: React.ReactNode; bpmnXml: string | undefined | null; appLibVersion: string; diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 4d9d1865623..74c44dd4fe5 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -14,7 +14,7 @@ "react-dnd-html5-backend": "16.0.1", "react-dom": "18.2.0", "react-redux": "8.1.3", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "react-select": "5.7.7", "redux-saga": "1.2.3" }, diff --git a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx new file mode 100644 index 00000000000..5d4b2fa2c24 --- /dev/null +++ b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.test.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { act, render as rtlRender } from '@testing-library/react'; +import { useConfirmationDialogOnPageLeave } from './useConfirmationDialogOnPageLeave'; +import { RouterProvider, createMemoryRouter, useBeforeUnload } from 'react-router-dom'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useBeforeUnload: jest.fn(), +})); + +const confirmationMessage = 'test'; + +const Component = ({ showConfirmationDialog }: { showConfirmationDialog: boolean }) => { + useConfirmationDialogOnPageLeave(showConfirmationDialog, confirmationMessage); + return null; +}; + +const render = (showConfirmationDialog: boolean) => { + const router = createMemoryRouter([ + { + path: '/', + element: , + }, + { + path: '/test', + element: null, + }, + ]); + + const { rerender } = rtlRender(); + return { + rerender, + router, + }; +}; + +describe('useConfirmationDialogOnPageLeave', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call useBeforeUnload with the expected arguments', () => { + const showConfirmationDialog = true; + render(showConfirmationDialog); + + expect(useBeforeUnload).toHaveBeenCalledWith(expect.any(Function), { + capture: true, + }); + }); + + it('should prevent navigation if showConfirmationDialog is true', () => { + const event = { + type: 'beforeunload', + returnValue: confirmationMessage, + } as BeforeUnloadEvent; + event.preventDefault = jest.fn(); + + const showConfirmationDialog = true; + render(showConfirmationDialog); + + const callbackFn = (useBeforeUnload as jest.MockedFunction).mock + .calls[0][0]; + callbackFn(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.returnValue).toBe(confirmationMessage); + }); + + it('should not prevent navigation if showConfirmationDialog is false', () => { + const event = { + type: 'beforeunload', + returnValue: '', + } as BeforeUnloadEvent; + event.preventDefault = jest.fn(); + + const showConfirmationDialog = false; + render(showConfirmationDialog); + + const callbackFn = (useBeforeUnload as jest.MockedFunction).mock + .calls[0][0]; + callbackFn(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.returnValue).toBe(''); + }); + + it('doesnt show confirmation dialog when there are no unsaved changes', async () => { + window.confirm = jest.fn(); + + const showConfirmationDialog = false; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(0); + expect(router.state.location.pathname).toBe('/test'); + }); + + it('show confirmation dialog when there are unsaved changes', async () => { + window.confirm = jest.fn(); + + const showConfirmationDialog = true; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(router.state.location.pathname).toBe('/'); + }); + + it('cancel redirection when clicking cancel', async () => { + window.confirm = jest.fn(() => false); + + const showConfirmationDialog = true; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(router.state.location.pathname).toBe('/'); + }); + + it('redirect when clicking OK', async () => { + window.confirm = jest.fn(() => true); + + const showConfirmationDialog = true; + const { router } = render(showConfirmationDialog); + + await act(async () => { + await router.navigate('/test'); + }); + + expect(window.confirm).toHaveBeenCalledTimes(1); + expect(router.state.location.pathname).toBe('/test'); + }); +}); diff --git a/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts new file mode 100644 index 00000000000..24b7a993d0c --- /dev/null +++ b/frontend/packages/shared/src/hooks/useConfirmationDialogOnPageLeave.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect } from 'react'; +import { useBeforeUnload, useBlocker } from 'react-router-dom'; + +export const useConfirmationDialogOnPageLeave = ( + showConfirmationDialog: boolean, + confirmationMessage: string, +) => { + useBeforeUnload( + useCallback( + (event: BeforeUnloadEvent) => { + if (showConfirmationDialog) { + event.preventDefault(); + event.returnValue = confirmationMessage; + } + }, + [showConfirmationDialog, confirmationMessage], + ), + { capture: true }, + ); + + const blocker = useBlocker(({ currentLocation, nextLocation }) => { + return showConfirmationDialog && currentLocation.pathname !== nextLocation.pathname; + }); + + useEffect(() => { + if (blocker.state === 'blocked') { + if (window.confirm(confirmationMessage)) { + blocker.proceed(); + } else { + blocker.reset(); + } + } + }, [blocker, confirmationMessage]); +}; diff --git a/frontend/resourceadm/package.json b/frontend/resourceadm/package.json index 71c832237b1..b0b621c8835 100644 --- a/frontend/resourceadm/package.json +++ b/frontend/resourceadm/package.json @@ -12,7 +12,7 @@ "@mui/x-data-grid": "5.17.26", "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "6.18.0", + "react-router-dom": "6.20.0", "react-use": "17.4.0" }, "devDependencies": { diff --git a/package.json b/package.json index a11ab547b23..d5330568b3a 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ "@tanstack/react-query-devtools": "5.8.1", "ajv": "8.12.0", "ajv-formats": "2.1.1", - "react-error-boundary": "^4.0.11", + "react-error-boundary": "4.0.11", "react-i18next": "13.3.1", - "react-router-dom": "6.18.0", - "react-toastify": "^9.1.3" + "react-router-dom": "6.20.0", + "react-toastify": "9.1.3" }, "devDependencies": { "@emotion/react": "11.11.1", @@ -60,7 +60,7 @@ "lint-staged": "15.1.0", "mini-css-extract-plugin": "2.7.6", "msw": "1.3.2", - "prettier": "^3.0.3", + "prettier": "3.0.3", "react": "18.2.0", "react-dom": "18.2.0", "redux-mock-store": "1.5.4", diff --git a/yarn.lock b/yarn.lock index 3018d53ebd5..77f40340a4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3763,10 +3763,10 @@ __metadata: languageName: node linkType: hard -"@remix-run/router@npm:1.11.0": - version: 1.11.0 - resolution: "@remix-run/router@npm:1.11.0" - checksum: 629ec578b9dfd3c5cb5de64a0798dd7846ec5ba0351aa66f42b1c65efb43da8f30366be59b825303648965b0df55b638c110949b24ef94fd62e98117fdfb0c0f +"@remix-run/router@npm:1.13.0": + version: 1.13.0 + resolution: "@remix-run/router@npm:1.13.0" + checksum: bb173a012d2036c5ee69babfe30c73975b970c2e5a0edaba138c302ae80d255e238e462e77365ab4efe819b6397e1a7f3a416d6200d17f9655f0ca1c51c4f45e languageName: node linkType: hard @@ -5606,13 +5606,13 @@ __metadata: lint-staged: "npm:15.1.0" mini-css-extract-plugin: "npm:2.7.6" msw: "npm:1.3.2" - prettier: "npm:^3.0.3" + prettier: "npm:3.0.3" react: "npm:18.2.0" react-dom: "npm:18.2.0" - react-error-boundary: "npm:^4.0.11" + react-error-boundary: "npm:4.0.11" react-i18next: "npm:13.3.1" - react-router-dom: "npm:6.18.0" - react-toastify: "npm:^9.1.3" + react-router-dom: "npm:6.20.0" + react-toastify: "npm:9.1.3" redux-mock-store: "npm:1.5.4" redux-saga: "npm:1.2.3" redux-saga-test-plan: "npm:4.0.6" @@ -5740,7 +5740,7 @@ __metadata: react-dom: "npm:18.2.0" react-i18next: "npm:13.3.1" react-redux: "npm:8.1.3" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" redux: "npm:4.2.1" redux-saga: "npm:1.2.3" reselect: "npm:4.1.8" @@ -5762,7 +5762,7 @@ __metadata: react: "npm:18.2.0" react-dom: "npm:18.2.0" react-redux: "npm:8.1.3" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" typescript: "npm:5.2.2" webpack: "npm:5.89.0" webpack-dev-server: "npm:4.15.1" @@ -5786,7 +5786,7 @@ __metadata: react-dnd-html5-backend: "npm:16.0.1" react-dom: "npm:18.2.0" react-redux: "npm:8.1.3" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" react-select: "npm:5.7.7" redux-saga: "npm:1.2.3" typescript: "npm:5.2.2" @@ -7630,7 +7630,7 @@ __metadata: jest: "npm:29.7.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" react-use: "npm:17.4.0" typescript: "npm:5.2.2" webpack: "npm:5.89.0" @@ -14054,7 +14054,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^3.0.3": +"prettier@npm:3.0.3": version: 3.0.3 resolution: "prettier@npm:3.0.3" bin: @@ -14370,7 +14370,7 @@ __metadata: languageName: node linkType: hard -"react-error-boundary@npm:^4.0.11": +"react-error-boundary@npm:4.0.11": version: 4.0.11 resolution: "react-error-boundary@npm:4.0.11" dependencies: @@ -14534,27 +14534,27 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:6.18.0": - version: 6.18.0 - resolution: "react-router-dom@npm:6.18.0" +"react-router-dom@npm:6.20.0": + version: 6.20.0 + resolution: "react-router-dom@npm:6.20.0" dependencies: - "@remix-run/router": "npm:1.11.0" - react-router: "npm:6.18.0" + "@remix-run/router": "npm:1.13.0" + react-router: "npm:6.20.0" peerDependencies: react: ">=16.8" react-dom: ">=16.8" - checksum: b0e72603d73172b6c6662afe2faed326753d5bbd9905aa560e3dade7996fc13d19e34e3ed668d2849efd685e2db2f711129c84b1439870e92c9cc91ddc343cf5 + checksum: 4b6741c545cedf5a5c4f996deb953679dcc985425e0664e27b97fdb9ab1387cbe1a6a12bfc7f7c38ec40b15759b4bf6396930ec26540a4a81ae16d154fd35049 languageName: node linkType: hard -"react-router@npm:6.18.0": - version: 6.18.0 - resolution: "react-router@npm:6.18.0" +"react-router@npm:6.20.0": + version: 6.20.0 + resolution: "react-router@npm:6.20.0" dependencies: - "@remix-run/router": "npm:1.11.0" + "@remix-run/router": "npm:1.13.0" peerDependencies: react: ">=16.8" - checksum: a00c8f347b7ffee575f4a7731782e688e3fca458ca5bd970fb41cef66a6851853caa24464155ab438d5879f367b1223a539642a405a865913ffe7e63e53b1245 + checksum: 2cdac5ad8b7a7bc230173b26768bcf3f6a9abc0a19983fa7b76b9ffdbeb44bfbd88fcc2033e9062defafef144db207859eb3162a9c9742d70cfce4e7166ff1e5 languageName: node linkType: hard @@ -14595,7 +14595,7 @@ __metadata: languageName: node linkType: hard -"react-toastify@npm:^9.1.3": +"react-toastify@npm:9.1.3": version: 9.1.3 resolution: "react-toastify@npm:9.1.3" dependencies: @@ -15090,7 +15090,7 @@ __metadata: jest: "npm:29.7.0" react: "npm:18.2.0" react-dom: "npm:18.2.0" - react-router-dom: "npm:6.18.0" + react-router-dom: "npm:6.20.0" react-use: "npm:17.4.0" typescript: "npm:5.2.2" webpack: "npm:5.89.0" From c8303a2b5a8d2c5290d619f1c3fa73b8e74399f6 Mon Sep 17 00:00:00 2001 From: Mirko Sekulic Date: Wed, 13 Dec 2023 12:29:25 +0100 Subject: [PATCH 13/21] chore(deps): libgit2sharp 0.28.0 => 0.29.0 (#11852) --- backend/packagegroups/NuGet.props | 2 +- .../src/Designer/Services/Implementation/SourceControlSI.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/packagegroups/NuGet.props b/backend/packagegroups/NuGet.props index a00cf3b7690..987d677484b 100644 --- a/backend/packagegroups/NuGet.props +++ b/backend/packagegroups/NuGet.props @@ -14,7 +14,7 @@ - + diff --git a/backend/src/Designer/Services/Implementation/SourceControlSI.cs b/backend/src/Designer/Services/Implementation/SourceControlSI.cs index 7f28fa9f71a..dac5b6363b6 100644 --- a/backend/src/Designer/Services/Implementation/SourceControlSI.cs +++ b/backend/src/Designer/Services/Implementation/SourceControlSI.cs @@ -57,7 +57,7 @@ public string CloneRemoteRepository(string org, string repository) { string remoteRepo = FindRemoteRepoLocation(org, repository); CloneOptions cloneOptions = new(); - cloneOptions.CredentialsProvider = CredentialsProvider(); + cloneOptions.FetchOptions.CredentialsProvider = CredentialsProvider(); string localPath = FindLocalRepoLocation(org, repository); string cloneResult = LibGit2Sharp.Repository.Clone(remoteRepo, localPath, cloneOptions); @@ -70,7 +70,7 @@ public string CloneRemoteRepository(string org, string repository, string destin { string remoteRepo = FindRemoteRepoLocation(org, repository); CloneOptions cloneOptions = new(); - cloneOptions.CredentialsProvider = CredentialsProvider(); + cloneOptions.FetchOptions.CredentialsProvider = CredentialsProvider(); if (!string.IsNullOrEmpty(branchName)) { From 6505582cc990feff0fa6efc2b9b8ef610bb35994 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Wed, 13 Dec 2023 12:35:49 +0100 Subject: [PATCH 14/21] Fix failing Cypress tests (#11853) --- frontend/language/src/nb.json | 3 ++- .../PageAccordion/PageAccordion.test.tsx | 4 ++-- .../DesignView/PageAccordion/PageAccordion.tsx | 4 ++-- .../cypress/src/integration/studio/designer.js | 16 +--------------- .../testing/cypress/src/selectors/settings.js | 2 +- 5 files changed, 8 insertions(+), 21 deletions(-) diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index f1830ab12f6..de753de85ce 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -255,6 +255,7 @@ "general.date": "Dato", "general.date_time_format": "{{date}} kl. {{time}}", "general.delete": "Slett", + "general.delete_item": "Slett {{item}}", "general.details": "Detaljer", "general.disabled": "Deaktivert", "general.edit": "Endre", @@ -1585,4 +1586,4 @@ "validation_errors.pattern": "Feil format eller verdi", "validation_errors.required": "Feltet er påkrevd", "validation_errors.value_as_url": "Ugyldig lenke" -} \ No newline at end of file +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx index 059991cd5aa..2f0beea0766 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx @@ -77,7 +77,7 @@ describe('PageAccordion', () => { jest.spyOn(window, 'confirm').mockImplementation(jest.fn(() => true)); await render(); - await screen.getByRole('button', { name: textMock('general.delete') }).click(); + await screen.getByRole('button', { name: textMock('general.delete_item', { item: mockPageName1 }) }).click(); expect(mockDeleteFormLayout).toHaveBeenCalledTimes(1); expect(mockDeleteFormLayout).toHaveBeenCalledWith(mockPageName1); expect(mockSetSearchParams).toHaveBeenCalledWith({ layout: mockPageName2 }); @@ -86,7 +86,7 @@ describe('PageAccordion', () => { it('Does not call deleteLayout when delete button is clicked, but deletion is not confirmed', async () => { jest.spyOn(window, 'confirm').mockImplementation(jest.fn(() => false)); await render(); - await screen.getByRole('button', { name: textMock('general.delete') }).click(); + await screen.getByRole('button', { name: textMock('general.delete_item', { item: mockPageName1 }) }).click(); expect(mockDeleteFormLayout).not.toHaveBeenCalled(); }); }); diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx index 859ab542ec0..25fef05562c 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx @@ -76,9 +76,9 @@ export const PageAccordion = ({ - {numberOfUnsavedChanges > 0 && ( - +
{!isEditAllowed && }
+ {isEditAllowed && numberOfUnsavedChanges > 0 && ( + {t('process_editor.unsaved_changes', { count: numberOfUnsavedChanges })} -
+ )} - {isEditorView && ( + {isEditAllowed && ( diff --git a/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.module.css b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.module.css new file mode 100644 index 00000000000..daf572924a1 --- /dev/null +++ b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.module.css @@ -0,0 +1,11 @@ +.helpTextWrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; +} + +.helpTextContent { + white-space: pre-line; + word-wrap: break-word; +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.test.tsx b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.test.tsx similarity index 60% rename from frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.test.tsx rename to frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.test.tsx index f0c150989df..2624f276b61 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.test.tsx +++ b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.test.tsx @@ -2,22 +2,16 @@ import React from 'react'; import { render as rtlRender, screen } from '@testing-library/react'; import { textMock } from '../../../../../../testing/mocks/i18nMock'; import { BpmnContextProvider } from '../../../contexts/BpmnContext'; -import { VersionAlert } from './VersionAlert'; +import { VersionHelpText } from './VersionHelpText'; const mockBPMNXML: string = ``; const mockAppLibVersion7: string = '7.0.3'; -describe('VersionAlert', () => { - it('should render VersionAlert', () => { +describe('VersionHelpText', () => { + it('should render VersionHelpText', () => { render(mockAppLibVersion7); - expect( - screen.getByRole('heading', { name: textMock('process_editor.too_old_version_title') }), - ).toBeInTheDocument(); - expect( - screen.getByText( - textMock('process_editor.too_old_version_text', { version: mockAppLibVersion7 }), - ), - ); + const tooOldText = screen.getByText(textMock('process_editor.too_old_version_title')); + expect(tooOldText).toBeInTheDocument(); }); const render = (appLibVersion?: string) => { @@ -26,7 +20,7 @@ describe('VersionAlert', () => { bpmnXml={mockBPMNXML} appLibVersion={appLibVersion || mockAppLibVersion7} > - + , ); }; diff --git a/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.tsx b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.tsx new file mode 100644 index 00000000000..584072e99da --- /dev/null +++ b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/VersionHelpText.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import classes from './VersionHelpText.module.css'; +import { HelpText, Link, Paragraph } from '@digdir/design-system-react'; +import { useTranslation, Trans } from 'react-i18next'; +import { useBpmnContext } from '../../../contexts/BpmnContext'; + +/** + * @component + * Displays the helptext informing the user that their version is too old + * + * @returns {JSX.Element} - The rendered component + */ +export const VersionHelpText = (): JSX.Element => { + const { t } = useTranslation(); + const { appLibVersion } = useBpmnContext(); + + return ( +
+ {t('process_editor.too_old_version_title')} + + + {t('process_editor.too_old_version_helptext_content', { version: appLibVersion })} + + + + Trenger du hjelp til å oppgradere, kan du{' '} + ta kontakte med oss. + + + +
+ ); +}; diff --git a/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/index.ts b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/index.ts new file mode 100644 index 00000000000..10fc68032b2 --- /dev/null +++ b/frontend/packages/process-editor/src/components/Canvas/VersionHelpText/index.ts @@ -0,0 +1 @@ +export { VersionHelpText } from './VersionHelpText'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.module.css new file mode 100644 index 00000000000..c9098953650 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.module.css @@ -0,0 +1,13 @@ +.headerWrapper { + display: flex; + align-items: center; + margin-inline: 1rem; + margin-top: 1rem; + justify-content: space-between; +} + +.headerTextAndIconWrapper { + display: flex; + align-items: center; + gap: 1rem; +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.test.tsx new file mode 100644 index 00000000000..cc5a137c893 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { ConfigContent } from './ConfigContent'; +import { render as rtlRender, screen } from '@testing-library/react'; +import { textMock } from '../../../../../../testing/mocks/i18nMock'; +import { BpmnContext, BpmnContextProps } from '../../../contexts/BpmnContext'; +import { BpmnDetails } from '../../../types/BpmnDetails'; +import { BpmnTypeEnum } from '../../../enum/BpmnTypeEnum'; + +const mockBPMNXML: string = ``; +const mockAppLibVersion8: string = '8.0.3'; + +const mockId: string = 'testId'; +const mockName: string = 'testName'; + +const mockBpmnDetails: BpmnDetails = { + id: mockId, + name: mockName, + taskType: 'data', + type: BpmnTypeEnum.Task, +}; + +const mockBpmnContextValue: BpmnContextProps = { + bpmnXml: mockBPMNXML, + appLibVersion: mockAppLibVersion8, + numberOfUnsavedChanges: 0, + setNumberOfUnsavedChanges: jest.fn(), + getUpdatedXml: jest.fn(), + isEditAllowed: true, + bpmnDetails: mockBpmnDetails, + setBpmnDetails: jest.fn(), +}; + +describe('ConfigContent', () => { + afterEach(jest.clearAllMocks); + + it('should display the details about the selected task when a "data" task is selected', () => { + render(); + + expect( + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_data_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); + }); + + it('should display the details about the selected task when a "confirmation" task is selected', () => { + render({ bpmnDetails: { ...mockBpmnDetails, taskType: 'confirmation' } }); + + expect( + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_confirmation_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); + }); + + it('should display the details about the selected task when a "feedback" task is selected', () => { + render({ bpmnDetails: { ...mockBpmnDetails, taskType: 'feedback' } }); + + expect( + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_feedback_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); + }); + + it('should display the details about the selected task when a "signing" task is selected', () => { + render({ bpmnDetails: { ...mockBpmnDetails, taskType: 'signing' } }); + + expect( + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_signing_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); + }); +}); + +const render = (rootContextProps: Partial = {}) => { + return rtlRender( + + + , + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.tsx new file mode 100644 index 00000000000..95803712790 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigContent.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import classes from './ConfigContent.module.css'; +import { useTranslation } from 'react-i18next'; +import { Divider, Heading, HelpText, Paragraph } from '@digdir/design-system-react'; +import { ConfigIcon } from './ConfigIcon'; +import { ConfigDetailsRow } from './ConfigDetailsRow'; +import { getConfigTitleKey, getConfigTitleHelpTextKey } from '../../../utils/configPanelUtils'; +import { useBpmnContext } from '../../../contexts/BpmnContext'; +import { ConfigSectionWrapper } from './ConfigSectionWrapper'; + +export const ConfigContent = (): JSX.Element => { + const { t } = useTranslation(); + const { bpmnDetails } = useBpmnContext(); + + const configTitle = t(getConfigTitleKey(bpmnDetails?.taskType)); + const configHeaderHelpText = t(getConfigTitleHelpTextKey(bpmnDetails?.taskType)); + + return ( + <> +
+
+ + + {configTitle} + +
+ + {configHeaderHelpText} + +
+ + + + + + + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.module.css new file mode 100644 index 00000000000..cfdfa5d251d --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.module.css @@ -0,0 +1,5 @@ +.wrapper { + display: flex; + gap: 0.25rem; + align-items: center; +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.test.tsx new file mode 100644 index 00000000000..aa95a6ceefd --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.test.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ConfigDetailsRow, ConfigDetailsRowProps } from './ConfigDetailsRow'; + +const mockTitle: string = 'Title'; +const mockText: string = 'Text'; + +const defaultProps: ConfigDetailsRowProps = { + title: mockTitle, + text: mockText, +}; + +describe('ConfigDetailsRow', () => { + afterEach(jest.clearAllMocks); + + it('displays the title and text correctly on screen', () => { + render(); + + expect(screen.getByText(mockTitle)).toBeInTheDocument(); + expect(screen.getByText(mockText)).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.tsx new file mode 100644 index 00000000000..3d0d37434de --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/ConfigDetailsRow.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import classes from './ConfigDetailsRow.module.css'; +import { Paragraph } from '@digdir/design-system-react'; + +export type ConfigDetailsRowProps = { + title: string; + text: string; +}; + +/** + * @component + * Displays rows in the config panel with title and text + * + * @property {title}[string] - The title in bold + * @property {text}[string] - The text + * + * @returns {JSX.Element} - The rendered component + */ +export const ConfigDetailsRow = ({ title, text }: ConfigDetailsRowProps): JSX.Element => ( +
+ + {title} + + {text} +
+); diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/index.ts new file mode 100644 index 00000000000..a6f5006aac9 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigDetailsRow/index.ts @@ -0,0 +1 @@ +export { ConfigDetailsRow } from './ConfigDetailsRow'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/ConfigIcon.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/ConfigIcon.module.css new file mode 100644 index 00000000000..fc2e696b413 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/ConfigIcon.module.css @@ -0,0 +1,3 @@ +.icon { + font-size: 2rem; +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/ConfigIcon.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/ConfigIcon.tsx new file mode 100644 index 00000000000..ce18e6f2346 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/ConfigIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import classes from './ConfigIcon.module.css'; +import { BpmnTaskType } from '../../../../types/BpmnTaskType'; +import { ConfirmationTask, DataTask, FeedbackTask, SignTask } from '@studio/icons'; + +export type ConfigIconProps = { + taskType: BpmnTaskType; +}; + +export const ConfigIcon = ({ taskType }: ConfigIconProps): JSX.Element => { + switch (taskType) { + case 'data': + return ; + case 'confirmation': + return ; + case 'feedback': + return ; + case 'signing': + return ; + } +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/index.ts new file mode 100644 index 00000000000..814ba55da41 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigIcon/index.ts @@ -0,0 +1 @@ +export { ConfigIcon } from './ConfigIcon'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/ConfigSectionWrapper.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/ConfigSectionWrapper.module.css new file mode 100644 index 00000000000..9b9af439fcf --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/ConfigSectionWrapper.module.css @@ -0,0 +1,6 @@ +.configSectionWrapper { + display: flex; + flex-direction: column; + margin-inline: 1rem; + gap: 1rem; +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/ConfigSectionWrapper.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/ConfigSectionWrapper.tsx new file mode 100644 index 00000000000..c7f376231e6 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/ConfigSectionWrapper.tsx @@ -0,0 +1,16 @@ +import React, { ReactNode } from 'react'; +import classes from './ConfigSectionWrapper.module.css'; +import { Divider } from '@digdir/design-system-react'; + +export type ConfigSectionWrapperProps = { + children: ReactNode; +}; + +export const ConfigSectionWrapper = ({ children }: ConfigSectionWrapperProps): JSX.Element => { + return ( + <> +
{children}
+ + + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/index.ts new file mode 100644 index 00000000000..3b5b0d80b3c --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/ConfigSectionWrapper/index.ts @@ -0,0 +1 @@ +export { ConfigSectionWrapper } from './ConfigSectionWrapper'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/index.ts new file mode 100644 index 00000000000..92e87a3f2da --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/index.ts @@ -0,0 +1 @@ +export { ConfigContent } from './ConfigContent'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.module.css index f74c8618203..bcc6becd43a 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.module.css +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.module.css @@ -2,8 +2,10 @@ background-color: var(--fds-semantic-surface-neutral-subtle); flex: 1; overflow-y: scroll; + border-left: 1px solid var(--fds-semantic-border-neutral-subtle); } -.content { - padding: 1.5rem; +.configPanelParagraph { + margin-inline: 1rem; + margin-top: 1rem; } 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 fc1ff703154..6e4d10a5f1e 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.test.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.test.tsx @@ -2,40 +2,141 @@ import React from 'react'; import { ConfigPanel } from './ConfigPanel'; import { render as rtlRender, screen } from '@testing-library/react'; import { textMock } from '../../../../../testing/mocks/i18nMock'; -import { BpmnContextProvider } from '../../contexts/BpmnContext'; +import { BpmnContext, BpmnContextProps } from '../../contexts/BpmnContext'; +import { BpmnDetails } from '../../types/BpmnDetails'; +import { BpmnTypeEnum } from '../../enum/BpmnTypeEnum'; const mockBPMNXML: string = ``; const mockAppLibVersion8: string = '8.0.3'; const mockAppLibVersion7: string = '7.0.3'; +const mockId: string = 'testId'; +const mockName: string = 'testName'; + +const mockBpmnDetails: BpmnDetails = { + id: mockId, + name: mockName, + taskType: 'data', + type: BpmnTypeEnum.Task, +}; + +const mockBpmnContextValue: BpmnContextProps = { + bpmnXml: mockBPMNXML, + appLibVersion: mockAppLibVersion8, + numberOfUnsavedChanges: 0, + setNumberOfUnsavedChanges: jest.fn(), + getUpdatedXml: jest.fn(), + isEditAllowed: true, + bpmnDetails: mockBpmnDetails, + setBpmnDetails: jest.fn(), +}; + describe('ConfigPanel', () => { + afterEach(jest.clearAllMocks); + it('should render without crashing', () => { - render('1.0.0'); + render({ appLibVersion: mockAppLibVersion7, bpmnDetails: null, isEditAllowed: false }); + expect( + screen.getByText(textMock('process_editor.configuration_panel_no_task')), + ).toBeInTheDocument(); + }); + + it('should display the message about selecting a task when bpmnDetails is "null"', () => { + render({ bpmnDetails: null }); + expect( + screen.getByText(textMock('process_editor.configuration_panel_no_task')), + ).toBeInTheDocument(); + }); + + it('should display the message about selecting a task when bpmnDetails.type is "Process"', () => { + render({ bpmnDetails: { ...mockBpmnDetails, type: BpmnTypeEnum.Process } }); + expect( + screen.getByText(textMock('process_editor.configuration_panel_no_task')), + ).toBeInTheDocument(); + }); + + it('should display the message about selected element not being supported when bpmnDetails.type "SequenceFlow"', () => { + render({ bpmnDetails: { ...mockBpmnDetails, type: BpmnTypeEnum.SequenceFlow } }); expect( - screen.getByRole('heading', { name: textMock('process_editor.configuration_panel_heading') }), + screen.getByText(textMock('process_editor.configuration_panel_element_not_supported')), ).toBeInTheDocument(); }); - it('should render the app lib version warning if version < 8.0.0', () => { + it('should display the message about selected element not being supported when bpmnDetails.type "StartEvent"', () => { + render({ bpmnDetails: { ...mockBpmnDetails, type: BpmnTypeEnum.StartEvent } }); + expect( + screen.getByText(textMock('process_editor.configuration_panel_element_not_supported')), + ).toBeInTheDocument(); + }); + + it('should display the message about selected element not being supported when bpmnDetails.type "EndEvent"', () => { + render({ bpmnDetails: { ...mockBpmnDetails, type: BpmnTypeEnum.EndEvent } }); + expect( + screen.getByText(textMock('process_editor.configuration_panel_element_not_supported')), + ).toBeInTheDocument(); + }); + + it('should display the details about the selected task when a "data" task is selected', () => { render(); - expect(screen.getByText(textMock('process_editor.too_old_version_title'))).toBeInTheDocument(); + + expect( + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_data_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); }); - it('should not render the app lib version warning if version >= 8.0.0', () => { - render(mockAppLibVersion8); + it('should display the details about the selected task when a "confirmation" task is selected', () => { + render({ bpmnDetails: { ...mockBpmnDetails, taskType: 'confirmation' } }); + expect( - screen.queryByText(textMock('process_editor.too_old_version_title')), - ).not.toBeInTheDocument(); + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_confirmation_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); }); - const render = (appLibVersion?: string) => { - return rtlRender( - - - , - ); - }; + it('should display the details about the selected task when a "feedback" task is selected', () => { + render({ bpmnDetails: { ...mockBpmnDetails, taskType: 'feedback' } }); + + expect( + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_feedback_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); + }); + + it('should display the details about the selected task when a "signing" task is selected', () => { + render({ bpmnDetails: { ...mockBpmnDetails, taskType: 'signing' } }); + + expect( + screen.getByRole('heading', { + name: textMock('process_editor.configuration_panel_signing_task'), + level: 2, + }), + ).toBeInTheDocument(); + + expect(screen.getByText(mockBpmnDetails.id)).toBeInTheDocument(); + expect(screen.getByText(mockBpmnDetails.name)).toBeInTheDocument(); + }); }); + +const render = (rootContextProps: Partial = {}) => { + return rtlRender( + + + , + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx index c4af8b56656..70bc05e3194 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigPanel.tsx @@ -1,10 +1,11 @@ import React from 'react'; import classes from './ConfigPanel.module.css'; -import { VersionAlert } from './VersionAlert'; import { useTranslation } from 'react-i18next'; -import { Alert, Heading, Paragraph } from '@digdir/design-system-react'; +import { Paragraph } from '@digdir/design-system-react'; import { useBpmnContext } from '../../contexts/BpmnContext'; +import { BpmnTypeEnum } from '../../enum/BpmnTypeEnum'; +import { ConfigContent } from './ConfigContent'; /** * @component @@ -14,23 +15,25 @@ import { useBpmnContext } from '../../contexts/BpmnContext'; */ export const ConfigPanel = (): JSX.Element => { const { t } = useTranslation(); - const { isEditAllowed } = useBpmnContext(); - return ( -
- {!isEditAllowed && } -
- - {t('process_editor.configuration_panel_heading')} - - - - {t('process_editor.configuration_panel.under_development_title')} - - - {t('process_editor.configuration_panel.under_development_body')} - - -
-
- ); + const { bpmnDetails } = useBpmnContext(); + + const displayContent = () => { + if (bpmnDetails === null || bpmnDetails.type === BpmnTypeEnum.Process) { + return ( + + {t('process_editor.configuration_panel_no_task')} + + ); + } else if (bpmnDetails.type === BpmnTypeEnum.Task) { + return ; + } else { + return ( + + {t('process_editor.configuration_panel_element_not_supported')} + + ); + } + }; + + return
{displayContent()}
; }; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.module.css deleted file mode 100644 index eb6db9c78b9..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.module.css +++ /dev/null @@ -1,8 +0,0 @@ -.alert { - height: max-content; -} - -.alertText { - white-space: pre-line; - word-wrap: break-word; -} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.tsx deleted file mode 100644 index ca53ea9cb56..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/VersionAlert.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Alert, Heading, Link, Paragraph } from '@digdir/design-system-react'; -import classes from './VersionAlert.module.css'; -import React, { ReactNode } from 'react'; -import { useTranslation, Trans } from 'react-i18next'; -import { useBpmnContext } from '../../../contexts/BpmnContext'; - -/** - * @component - * Displays the alert informing the user that their version is too old - * - * @returns {ReactNode} - The rendered component - */ -export const VersionAlert = (): ReactNode => { - const { t } = useTranslation(); - const { appLibVersion } = useBpmnContext(); - - return ( - - - {t('process_editor.too_old_version_title')} - - - {t('process_editor.too_old_version_text', { version: appLibVersion })} - - - {t('process_editor.need_to_contact_text')} - - - - - Les mer om hvordan dette gjøres her - - . Trenger du hjelp til å oppgradere, kan du kontakte oss. - - - - ); -}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/index.ts deleted file mode 100644 index 4b52507c1c1..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/VersionAlert/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { VersionAlert } from './VersionAlert'; diff --git a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx index f2354532923..146dbdfa72a 100644 --- a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx +++ b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx @@ -2,8 +2,9 @@ import { supportsProcessEditor } from '../utils/processEditorUtils'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; import Modeler from 'bpmn-js/lib/Modeler'; import React, { MutableRefObject, createContext, useContext, useRef, useState } from 'react'; +import { BpmnDetails } from '../types/BpmnDetails'; -type BpmnContextProps = { +export type BpmnContextProps = { bpmnXml: string; modelerRef?: MutableRefObject; numberOfUnsavedChanges: number; @@ -11,6 +12,8 @@ type BpmnContextProps = { getUpdatedXml: () => Promise; isEditAllowed: boolean; appLibVersion: string; + bpmnDetails: BpmnDetails; + setBpmnDetails: React.Dispatch>; }; export const BpmnContext = createContext({ @@ -21,6 +24,8 @@ export const BpmnContext = createContext({ getUpdatedXml: async () => '', isEditAllowed: true, appLibVersion: '', + bpmnDetails: null, + setBpmnDetails: () => {}, }); export type BpmnContextProviderProps = { @@ -34,8 +39,11 @@ export const BpmnContextProvider = ({ appLibVersion, }: BpmnContextProviderProps) => { const [numberOfUnsavedChanges, setNumberOfUnsavedChanges] = useState(0); + const [bpmnDetails, setBpmnDetails] = useState(null); + const isEditAllowed = supportsProcessEditor(appLibVersion) || shouldDisplayFeature('shouldOverrideAppLibCheck'); + const modelerRef = useRef(null); const getUpdatedXml = async (): Promise => { @@ -61,6 +69,8 @@ export const BpmnContextProvider = ({ getUpdatedXml, isEditAllowed, appLibVersion, + bpmnDetails, + setBpmnDetails, }} > {children} diff --git a/frontend/packages/process-editor/src/enum/BpmnTypeEnum.ts b/frontend/packages/process-editor/src/enum/BpmnTypeEnum.ts new file mode 100644 index 00000000000..d64774507c8 --- /dev/null +++ b/frontend/packages/process-editor/src/enum/BpmnTypeEnum.ts @@ -0,0 +1,7 @@ +export enum BpmnTypeEnum { + Process = 'bpmn:Process', + Task = 'bpmn:Task', + SequenceFlow = 'bpmn:SequenceFlow', + StartEvent = 'bpmn:StartEvent', + EndEvent = 'bpmn:EndEvent', +} diff --git a/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts b/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts index 896dd0d7640..d973c61e8a3 100644 --- a/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts +++ b/frontend/packages/process-editor/src/hooks/useBpmnEditor.ts @@ -3,6 +3,7 @@ import BpmnModeler from 'bpmn-js/lib/Modeler'; import { useBpmnContext } from '../contexts/BpmnContext'; import Modeler from 'bpmn-js/lib/Modeler'; import { useBpmnModeler } from './useBpmnModeler'; +import { getBpmnEditorDetailsFromBusinessObject } from '../utils/hookUtils'; // Wrapper around bpmn-js to Reactify it @@ -12,7 +13,7 @@ type UseBpmnViewerResult = { }; export const useBpmnEditor = (): UseBpmnViewerResult => { - const { bpmnXml, modelerRef, setNumberOfUnsavedChanges } = useBpmnContext(); + const { bpmnXml, modelerRef, setNumberOfUnsavedChanges, setBpmnDetails } = useBpmnContext(); const canvasRef = useRef(null); const { getModeler } = useBpmnModeler(); @@ -32,6 +33,16 @@ export const useBpmnEditor = (): UseBpmnViewerResult => { }); }; + const eventBus: any = modelerInstance.get('eventBus'); + const events = ['element.click']; + + events.forEach((event) => { + eventBus.on(event, (e: any) => { + const bpmnDetails = getBpmnEditorDetailsFromBusinessObject(e?.element?.businessObject); + setBpmnDetails(bpmnDetails); + }); + }); + const initializeEditor = async () => { try { await modelerInstance.importXML(bpmnXml); @@ -44,7 +55,7 @@ export const useBpmnEditor = (): UseBpmnViewerResult => { initializeEditor(); initializeUnsavedChangesCount(); - }, [bpmnXml, modelerRef, setNumberOfUnsavedChanges]); + }, [bpmnXml, modelerRef, setBpmnDetails, setNumberOfUnsavedChanges]); return { canvasRef, modelerRef }; }; diff --git a/frontend/packages/process-editor/src/hooks/useBpmnViewer.ts b/frontend/packages/process-editor/src/hooks/useBpmnViewer.ts index 59b6c2ff1d1..8babcf9c443 100644 --- a/frontend/packages/process-editor/src/hooks/useBpmnViewer.ts +++ b/frontend/packages/process-editor/src/hooks/useBpmnViewer.ts @@ -2,6 +2,7 @@ import { MutableRefObject, useRef, useEffect, useState } from 'react'; import BpmnJS from 'bpmn-js/dist/bpmn-navigated-viewer.development.js'; import { useBpmnContext } from '../contexts/BpmnContext'; import type { BpmnViewerError } from '../types/BpmnViewerError'; +import { getBpmnViewerDetailsFromBusinessObject } from '../utils/hookUtils'; // Wrapper around bpmn-js to Reactify it @@ -16,7 +17,7 @@ type UseBpmnViewerResult = { }; export const useBpmnViewer = (): UseBpmnViewerResult => { - const { bpmnXml } = useBpmnContext(); + const { bpmnXml, setBpmnDetails } = useBpmnContext(); const canvasRef = useRef(null); const [bpmnViewerError, setBpmnViewerError] = useState(undefined); @@ -28,6 +29,16 @@ export const useBpmnViewer = (): UseBpmnViewerResult => { const viewer = new BpmnJS({ container: canvasRef.current }); + const eventBus = viewer.get('eventBus'); + const events = ['element.click']; + + events.forEach((event) => { + eventBus.on(event, (e: any) => { + const bpmnDetails = getBpmnViewerDetailsFromBusinessObject(e?.element?.businessObject); + setBpmnDetails(bpmnDetails); + }); + }); + const initializeViewer = async () => { try { await viewer.importXML(bpmnXml); @@ -37,7 +48,7 @@ export const useBpmnViewer = (): UseBpmnViewerResult => { }; initializeViewer(); - }, [bpmnXml]); + }, [bpmnXml, setBpmnDetails]); return { canvasRef, bpmnViewerError }; }; diff --git a/frontend/packages/process-editor/src/types/BpmnBusinessObjectEditor.ts b/frontend/packages/process-editor/src/types/BpmnBusinessObjectEditor.ts new file mode 100644 index 00000000000..5dca17a793b --- /dev/null +++ b/frontend/packages/process-editor/src/types/BpmnBusinessObjectEditor.ts @@ -0,0 +1,19 @@ +import type { BpmnTaskType } from './BpmnTaskType'; +import type { BpmnTypeEnum } from '../enum/BpmnTypeEnum'; + +export interface BpmnBusinessObjectEditor { + $type: BpmnTypeEnum; + id: string; + name?: string; + extensionElements?: BpmnExtensionElementsEditor; + $attrs?: { + 'altinn:tasktype': BpmnTaskType; + }; +} + +export interface BpmnExtensionElementsEditor { + values?: Array<{ + taskType: BpmnTaskType; + $type: string; + }>; +} diff --git a/frontend/packages/process-editor/src/types/BpmnBusinessObjectViewer.ts b/frontend/packages/process-editor/src/types/BpmnBusinessObjectViewer.ts new file mode 100644 index 00000000000..9d207fb53f9 --- /dev/null +++ b/frontend/packages/process-editor/src/types/BpmnBusinessObjectViewer.ts @@ -0,0 +1,11 @@ +import type { BpmnTaskType } from './BpmnTaskType'; +import type { BpmnTypeEnum } from '../enum/BpmnTypeEnum'; + +export interface BpmnBusinessObjectViewer { + $type: BpmnTypeEnum; + id: string; + name?: string; + $attrs: { + 'altinn:tasktype': BpmnTaskType; + }; +} diff --git a/frontend/packages/process-editor/src/types/BpmnDetails.ts b/frontend/packages/process-editor/src/types/BpmnDetails.ts new file mode 100644 index 00000000000..9ad2d52258d --- /dev/null +++ b/frontend/packages/process-editor/src/types/BpmnDetails.ts @@ -0,0 +1,9 @@ +import { BpmnTaskType } from './BpmnTaskType'; +import { BpmnTypeEnum } from '../enum/BpmnTypeEnum'; + +export interface BpmnDetails { + id: string; + name: string; + taskType: BpmnTaskType | null; + type: BpmnTypeEnum; +} diff --git a/frontend/packages/process-editor/src/types/BpmnTaskType.ts b/frontend/packages/process-editor/src/types/BpmnTaskType.ts new file mode 100644 index 00000000000..e5aaaae7d0a --- /dev/null +++ b/frontend/packages/process-editor/src/types/BpmnTaskType.ts @@ -0,0 +1 @@ +export type BpmnTaskType = 'data' | 'confirmation' | 'feedback' | 'signing'; diff --git a/frontend/packages/process-editor/src/utils/configPanelUtils/configPanelUtils.test.ts b/frontend/packages/process-editor/src/utils/configPanelUtils/configPanelUtils.test.ts new file mode 100644 index 00000000000..3ab508fa348 --- /dev/null +++ b/frontend/packages/process-editor/src/utils/configPanelUtils/configPanelUtils.test.ts @@ -0,0 +1,47 @@ +import { getConfigTitleHelpTextKey, getConfigTitleKey } from './configPanelUtils'; + +describe('configPanelUtils', () => { + describe('getConfigTitleKey', () => { + it('returns data task key when taskType is "data"', () => { + const key = getConfigTitleKey('data'); + expect(key).toEqual('process_editor.configuration_panel_data_task'); + }); + + it('returns confirmation task key when taskType is "confirmation"', () => { + const key = getConfigTitleKey('confirmation'); + expect(key).toEqual('process_editor.configuration_panel_confirmation_task'); + }); + + it('returns feedback task key when taskType is "feedback"', () => { + const key = getConfigTitleKey('feedback'); + expect(key).toEqual('process_editor.configuration_panel_feedback_task'); + }); + + it('returns signing task key when taskType is "signing"', () => { + const key = getConfigTitleKey('signing'); + expect(key).toEqual('process_editor.configuration_panel_signing_task'); + }); + }); + + describe('getConfigTitleHelpTextKey', () => { + it('returns data helptext key when taskType is "data"', () => { + const key = getConfigTitleHelpTextKey('data'); + expect(key).toEqual('process_editor.configuration_panel_header_help_text_data'); + }); + + it('returns confirmation helptext key when taskType is "confirmation"', () => { + const key = getConfigTitleHelpTextKey('confirmation'); + expect(key).toEqual('process_editor.configuration_panel_header_help_text_confirmation'); + }); + + it('returns feedback helptext key when taskType is "feedback"', () => { + const key = getConfigTitleHelpTextKey('feedback'); + expect(key).toEqual('process_editor.configuration_panel_header_help_text_feedback'); + }); + + it('returns signing helptext key when taskType is "signing"', () => { + const key = getConfigTitleHelpTextKey('signing'); + expect(key).toEqual('process_editor.configuration_panel_header_help_text_signing'); + }); + }); +}); diff --git a/frontend/packages/process-editor/src/utils/configPanelUtils/configPanelUtils.ts b/frontend/packages/process-editor/src/utils/configPanelUtils/configPanelUtils.ts new file mode 100644 index 00000000000..19772d6622b --- /dev/null +++ b/frontend/packages/process-editor/src/utils/configPanelUtils/configPanelUtils.ts @@ -0,0 +1,20 @@ +import { BpmnTaskType } from '../../types/BpmnTaskType'; + +/** + * Returns the title to show in the config panel when a task is selected. + * @param taskType the task type of the bpmn. + * @returns the correct title key. + * + */ +export const getConfigTitleKey = (taskType: BpmnTaskType) => { + return `process_editor.configuration_panel_${taskType}_task`; +}; + +/** + * Returns the text to show in the config panel helptext based on the tasktype + * @param taskType the task type of the bpmn + * @returns the correct helptext key + */ +export const getConfigTitleHelpTextKey = (taskType: BpmnTaskType) => { + return `process_editor.configuration_panel_header_help_text_${taskType}`; +}; diff --git a/frontend/packages/process-editor/src/utils/configPanelUtils/index.ts b/frontend/packages/process-editor/src/utils/configPanelUtils/index.ts new file mode 100644 index 00000000000..301b12445a5 --- /dev/null +++ b/frontend/packages/process-editor/src/utils/configPanelUtils/index.ts @@ -0,0 +1 @@ +export { getConfigTitleKey, getConfigTitleHelpTextKey } from './configPanelUtils'; diff --git a/frontend/packages/process-editor/src/utils/hookUtils/hookUtils.test.ts b/frontend/packages/process-editor/src/utils/hookUtils/hookUtils.test.ts new file mode 100644 index 00000000000..9808116d2f7 --- /dev/null +++ b/frontend/packages/process-editor/src/utils/hookUtils/hookUtils.test.ts @@ -0,0 +1,112 @@ +import { BpmnTypeEnum } from '../../enum/BpmnTypeEnum'; +import { BpmnBusinessObjectViewer } from '../../types/BpmnBusinessObjectViewer'; +import { + BpmnBusinessObjectEditor, + BpmnExtensionElementsEditor, +} from '../../types/BpmnBusinessObjectEditor'; +import { BpmnDetails } from '../../types/BpmnDetails'; +import { BpmnTaskType } from '../../types/BpmnTaskType'; +import { + getBpmnEditorDetailsFromBusinessObject, + getBpmnViewerDetailsFromBusinessObject, +} from './hookUtils'; + +describe('hookUtils', () => { + afterEach(jest.clearAllMocks); + + describe('getBpmnViewerDetailsFromBusinessObject', () => { + const mockTypeTask: BpmnTypeEnum = BpmnTypeEnum.Task; + const mockId: string = 'mockId'; + const mockName: string = 'mockName'; + const mockTaskTypeData: BpmnTaskType = 'data'; + + const mockBpmnBusinessObject: BpmnBusinessObjectViewer = { + $type: mockTypeTask, + id: mockId, + name: mockName, + $attrs: { + 'altinn:tasktype': mockTaskTypeData, + }, + }; + + it('returns the BpmnDetails with correct values', () => { + const bpmnDetails: BpmnDetails = + getBpmnViewerDetailsFromBusinessObject(mockBpmnBusinessObject); + expect(bpmnDetails.id).toEqual(mockId); + expect(bpmnDetails.name).toEqual(mockName); + expect(bpmnDetails.type).toEqual(mockTypeTask); + expect(bpmnDetails.taskType).toEqual(mockTaskTypeData); + }); + + it('returns taskType with value "null" when $attrs are not present', () => { + const bpmnBusinessObject: BpmnBusinessObjectViewer = { + ...mockBpmnBusinessObject, + $attrs: undefined, + }; + const bpmnDetails: BpmnDetails = getBpmnViewerDetailsFromBusinessObject(bpmnBusinessObject); + expect(bpmnDetails.id).toEqual(mockId); + expect(bpmnDetails.name).toEqual(mockName); + expect(bpmnDetails.type).toEqual(mockTypeTask); + expect(bpmnDetails.taskType).toBeNull(); + }); + }); + + describe('getBpmnEditorDetailsFromBusinessObject', () => { + const mockTypeTask: BpmnTypeEnum = BpmnTypeEnum.Task; + const mockId: string = 'mockId'; + const mockName: string = 'mockName'; + const mockTaskTypeData: BpmnTaskType = 'data'; + + const mockBpmnExtensionElements: BpmnExtensionElementsEditor = { + values: [ + { + taskType: mockTaskTypeData, + $type: 'altinn:taskType', + }, + ], + }; + + const mockBpmnBusinessObject: BpmnBusinessObjectEditor = { + $type: mockTypeTask, + id: mockId, + name: mockName, + extensionElements: mockBpmnExtensionElements, + }; + + it('returns the BpmnDetails with correct values', () => { + const bpmnDetails: BpmnDetails = + getBpmnEditorDetailsFromBusinessObject(mockBpmnBusinessObject); + expect(bpmnDetails.id).toEqual(mockId); + expect(bpmnDetails.name).toEqual(mockName); + expect(bpmnDetails.type).toEqual(mockTypeTask); + expect(bpmnDetails.taskType).toEqual(mockTaskTypeData); + }); + + it('returns taskType with value "null" when etensionElements are not present', () => { + const bpmnBusinessObject: BpmnBusinessObjectEditor = { + ...mockBpmnBusinessObject, + extensionElements: undefined, + }; + const bpmnDetails: BpmnDetails = getBpmnEditorDetailsFromBusinessObject(bpmnBusinessObject); + expect(bpmnDetails.id).toEqual(mockId); + expect(bpmnDetails.name).toEqual(mockName); + expect(bpmnDetails.type).toEqual(mockTypeTask); + expect(bpmnDetails.taskType).toBeNull(); + }); + + it('returns taskType with value "null" when etensionElements.values are not present', () => { + const bpmnBusinessObject: BpmnBusinessObjectEditor = { + ...mockBpmnBusinessObject, + extensionElements: { + ...mockBpmnExtensionElements, + values: undefined, + }, + }; + const bpmnDetails: BpmnDetails = getBpmnEditorDetailsFromBusinessObject(bpmnBusinessObject); + expect(bpmnDetails.id).toEqual(mockId); + expect(bpmnDetails.name).toEqual(mockName); + expect(bpmnDetails.type).toEqual(mockTypeTask); + expect(bpmnDetails.taskType).toBeNull(); + }); + }); +}); diff --git a/frontend/packages/process-editor/src/utils/hookUtils/hookUtils.ts b/frontend/packages/process-editor/src/utils/hookUtils/hookUtils.ts new file mode 100644 index 00000000000..e3703a14250 --- /dev/null +++ b/frontend/packages/process-editor/src/utils/hookUtils/hookUtils.ts @@ -0,0 +1,46 @@ +import type { BpmnDetails } from '../../types/BpmnDetails'; +import type { BpmnBusinessObjectViewer } from '../../types/BpmnBusinessObjectViewer'; +import type { BpmnBusinessObjectEditor } from '../../types/BpmnBusinessObjectEditor'; + +/** + * Gets the bpmn details from the business object in viewer mode + * @param businessObject the business object in viewer mode + * @returns the bpmn details + */ +export const getBpmnViewerDetailsFromBusinessObject = ( + businessObject: BpmnBusinessObjectViewer, +): BpmnDetails => { + const bpmnAttrs = businessObject?.$attrs; + const bpmnTasktype = bpmnAttrs ? bpmnAttrs['altinn:tasktype'] : null; + + const bpmnDetails: BpmnDetails = { + id: businessObject?.id, + name: businessObject?.name, + taskType: bpmnTasktype, + type: businessObject?.$type, + }; + return bpmnDetails; +}; + +/** + * Gets the bpmn details from the business object in editor mode + * @param businessObject the business object in editor mode + * @returns the bpmn details + */ +export const getBpmnEditorDetailsFromBusinessObject = ( + businessObject: BpmnBusinessObjectEditor, +): BpmnDetails => { + const extensionElementsValues = businessObject?.extensionElements?.values; + const taskTypeFromV8 = extensionElementsValues ? extensionElementsValues[0].taskType : null; + + const bpmnAttrs = businessObject.$attrs; + const taskTypeFromV7 = bpmnAttrs ? bpmnAttrs['altinn:tasktype'] : null; + + const bpmnDetails: BpmnDetails = { + id: businessObject?.id, + name: businessObject?.name, + taskType: taskTypeFromV8 || taskTypeFromV7, + type: businessObject?.$type, + }; + return bpmnDetails; +}; diff --git a/frontend/packages/process-editor/src/utils/hookUtils/index.ts b/frontend/packages/process-editor/src/utils/hookUtils/index.ts new file mode 100644 index 00000000000..4e0f1ccb8db --- /dev/null +++ b/frontend/packages/process-editor/src/utils/hookUtils/index.ts @@ -0,0 +1,4 @@ +export { + getBpmnViewerDetailsFromBusinessObject, + getBpmnEditorDetailsFromBusinessObject, +} from './hookUtils'; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx index 2f0beea0766..de5b64389b5 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.test.tsx @@ -77,7 +77,9 @@ describe('PageAccordion', () => { jest.spyOn(window, 'confirm').mockImplementation(jest.fn(() => true)); await render(); - await screen.getByRole('button', { name: textMock('general.delete_item', { item: mockPageName1 }) }).click(); + await screen + .getByRole('button', { name: textMock('general.delete_item', { item: mockPageName1 }) }) + .click(); expect(mockDeleteFormLayout).toHaveBeenCalledTimes(1); expect(mockDeleteFormLayout).toHaveBeenCalledWith(mockPageName1); expect(mockSetSearchParams).toHaveBeenCalledWith({ layout: mockPageName2 }); @@ -86,7 +88,9 @@ describe('PageAccordion', () => { it('Does not call deleteLayout when delete button is clicked, but deletion is not confirmed', async () => { jest.spyOn(window, 'confirm').mockImplementation(jest.fn(() => false)); await render(); - await screen.getByRole('button', { name: textMock('general.delete_item', { item: mockPageName1 }) }).click(); + await screen + .getByRole('button', { name: textMock('general.delete_item', { item: mockPageName1 }) }) + .click(); expect(mockDeleteFormLayout).not.toHaveBeenCalled(); }); }); diff --git a/frontend/testing/cypress/cypress.config.js b/frontend/testing/cypress/cypress.config.js index 8c78e33d88c..726a338ba37 100644 --- a/frontend/testing/cypress/cypress.config.js +++ b/frontend/testing/cypress/cypress.config.js @@ -4,6 +4,7 @@ const path = require('path'); module.exports = defineConfig({ chromeWebSecurity: false, projectId: 'o7mikf', + e2e: { experimentalRunAllSpecs: true, supportFile: 'src/support/index.js', @@ -15,6 +16,7 @@ module.exports = defineConfig({ ); }, }, + video: false, fixturesFolder: 'src/fixtures', downloadsFolder: 'downloads', @@ -27,11 +29,20 @@ module.exports = defineConfig({ requestTimeout: 10000, defaultCommandTimeout: 8000, reporter: 'junit', + reporterOptions: { mochaFile: 'reports/result-[hash].xml', }, + retries: { runMode: 1, openMode: 0, }, + + component: { + devServer: { + framework: 'react', + bundler: 'webpack', + }, + }, }); diff --git a/frontend/testing/cypress/src/integration/studio/designer.js b/frontend/testing/cypress/src/integration/studio/designer.js index 584e8f0320e..0dc2307cefc 100644 --- a/frontend/testing/cypress/src/integration/studio/designer.js +++ b/frontend/testing/cypress/src/integration/studio/designer.js @@ -45,7 +45,9 @@ context('Designer', () => { .findByRole('treeitem', { name: texts['ux_editor.component_title.Input'] }); // Do not need to confirm alert.confirm dialog, since Cypress default to click "Ok". - cy.findByTitle(texts['general.delete_item'].replace('{{item}}', 'Side1')).click({ force: true }); + cy.findByTitle(texts['general.delete_item'].replace('{{item}}', 'Side1')).click({ + force: true, + }); }); it('should add navigation buttons when adding more than one page', () => { From f9eef7225a663651332fe8fd719fc2cf70fbcdcf Mon Sep 17 00:00:00 2001 From: Mirko Sekulic Date: Mon, 18 Dec 2023 09:42:36 +0100 Subject: [PATCH 18/21] Cypress tests on PR pipeline (#11873) * init cypress pipeline * comment dns check * print hosts file * add curl to studio.localhost * Try run cypress tests * try install parameter * increase pipeline timeout * IGNORE_DOCKER_DNS_LOOKUP fix * pipeline steps cleanup * remove wait for startup script --- .github/workflows/run-cypress-on-pr.yaml | 69 ++++++++++++++++++++++++ development/setup.js | 4 +- 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/run-cypress-on-pr.yaml diff --git a/.github/workflows/run-cypress-on-pr.yaml b/.github/workflows/run-cypress-on-pr.yaml new file mode 100644 index 00000000000..af1048731c0 --- /dev/null +++ b/.github/workflows/run-cypress-on-pr.yaml @@ -0,0 +1,69 @@ +name: Cypress tests on pr +on: + pull_request: + branches: [ master ] + types: [opened, synchronize, reopened] + paths: + - 'frontend/**' + - '!frontend/stats/**' + - 'backend/**' + - '.github/workflows/run-cypress-on-pr.yaml' + - 'docker-compose.yml' + - 'Dockerfile' + - 'gitea/**' + workflow_dispatch: + +jobs: + cypress-tests: + name: Build environment and run e2e test + timeout-minutes: 25 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Generate .env file + run: | + echo CYPRESS_TEST_APP=autodeploy-v3 >> .env + echo DEVELOP_APP_DEVELOPMENT=0 >> .env + echo DEVELOP_RESOURCE_ADMIN=0 >> .env + echo DEVELOP_BACKEND=0 >> .env + echo DEVELOP_DASHBOARD=0 >> .env + echo DEVELOP_PREVIEW=0 >> .env + echo GITEA_ADMIN_PASS=g9wDIG@6gf >> .env + echo GITEA_ADMIN_USER=localg1iteaadmin >> .env + echo GITEA_CYPRESS_USER=cypress_testuser >> .env + echo GITEA_CYPRESS_PASS=g9wDIG@6gf >> .env + echo GITEA_ORG_USER=ttd >> .env + echo POSTGRES_PASSWORD=kyeDIG@eip >> .env + echo COMMIT= >> .env + echo IGNORE_DOCKER_DNS_LOOKUP=true >> .env + + - name: Build all images + run: | + docker compose build --no-cache + + - name: Install node + uses: actions/setup-node@v4 + with: + cache: 'yarn' + + - name: Run setup.js script + run: | + node ./development/setup.js + + - name: Cypress run + uses: cypress-io/github-action@v5 + with: + install: true + working-directory: frontend/testing/cypress + env: environment=local + spec: src/integration/studio/*.js + record: false + parallel: false + + - name: Stop compose file + if: always() + run: docker-compose down diff --git a/development/setup.js b/development/setup.js index 22e7e61989c..6e35c7adcb5 100644 --- a/development/setup.js +++ b/development/setup.js @@ -105,7 +105,9 @@ const addReleaseAndDeployTestDataToDb = async () => const script = async () => { const env = ensureDotEnv(); await dnsIsOk('studio.localhost'); - await dnsIsOk('host.docker.internal'); + if (!(env.IGNORE_DOCKER_DNS_LOOKUP === 'true')){ + await dnsIsOk('host.docker.internal'); + } await startingDockerCompose(); await waitFor('http://studio.localhost/repos/'); await createUser(env.GITEA_ADMIN_USER, env.GITEA_ADMIN_PASS, true); From ff1d577c88b2bbdae83bad6a2ce65eb7f6ee805d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20=C3=98vrelid?= <46874830+framitdavid@users.noreply.github.com> Date: Mon, 18 Dec 2023 10:00:21 +0100 Subject: [PATCH 19/21] fix(SchemaInspector): fixing renaming objects with child nodes (#11870) * fix(SchemaInspector): fixing renaming objects with child nodes * removed empty string * added one more test * PR feedback --- frontend/language/src/nb.json | 2 + .../src/components/SchemaInspector.test.tsx | 4 +- .../src/components/SchemaInspector.tsx | 87 ++++++------------- .../ItemFieldsTab/ItemFieldsTab.tsx | 2 +- .../ItemFieldsTable/ItemFieldsTable.tsx | 6 +- .../ItemPropertiesTab.test.tsx | 13 ++- .../SchemaInspector/ItemPropertiesTab.tsx | 11 ++- 7 files changed, 52 insertions(+), 73 deletions(-) diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index cda785540bb..c314649e951 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -34,10 +34,12 @@ "app_create_release_errors.check_status_on_build_error": "Oi! Vi opplever en teknisk feil og får derfor ikke oppdatert status på bygget. Vi forsøker å sjekke status på nytt...", "app_create_release_errors.fetch_release_failed": "Huff da, vi opplever en teknisk feil som ikke gjør det mulig for deg å bygge versjoner eller deploye en applikasjon til miljøene akkurat nå. Prøv igjen senere. Dersom problemet vedvarer, kontakt <0 href=\"mailto:tjenesteeier@altinn.no\">Altinn servicedesk.", "app_create_release_errors.technical_error_code": "Teknisk feilkode", + "app_data_modelling.fields_information": "Kun objekter kan tilordnes felter i datamodelleringsverktøyet. Tekst, heltall, desimaltall og ja/nei støtter ikke egne felter. For å løse dette, velg et element som er et objekt eller konverter gjeldende element til et objekt.", "app_data_modelling.landing_dialog_create": "Lag en ny datamodell", "app_data_modelling.landing_dialog_header": "Last opp eller lag en ny datamodell for å starte", "app_data_modelling.landing_dialog_paragraph": "Du er nå kommet til datamodelleringsverktøyet. Her kan du laste opp en eksisterende datamodell eller lage en ny modell for bruk i din Altinn-app.", "app_data_modelling.landing_dialog_upload": "Last opp datamodell", + "app_data_modelling.properties_information": "Ingen egenskaper vises for øyeblikket fordi ingen elementer er valgt. Velg et element for å konfigurere og vise egenskapene til det valgte elementet.", "app_data_modelling.select_xsd": "Velg en XSD", "app_data_modelling.upload_xsd": "Last opp", "app_data_modelling.uploading_xsd": "Laster opp XSD...", diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx index 2a21bc9bfea..1061eb04cbe 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector.test.tsx @@ -74,9 +74,7 @@ describe('SchemaInspector', () => { renderSchemaInspector(mockUiSchema, getMockSchemaByPath('#/$defs/Kommentar2000Restriksjon')); const tablist = screen.getByRole('tablist'); expect(tablist).toBeDefined(); - const tabpanel = screen.getByRole('tabpanel'); - expect(tabpanel).toBeDefined(); - expect(screen.getAllByRole('tab')).toHaveLength(1); + expect(screen.getAllByRole('tab')).toHaveLength(2); const textboxes = screen.getAllByRole('textbox'); for (const textbox of textboxes) { diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector.tsx index 8b5d9f22501..d70a9f0102a 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector.tsx @@ -1,7 +1,6 @@ -import React, { useEffect, useState } from 'react'; -import { LegacyTabs } from '@digdir/design-system-react'; -import type { LegacyTabItem } from '@digdir/design-system-react'; -import { FieldType, isField, isObject, ObjectKind } from '@altinn/schema-model'; +import React from 'react'; +import { Alert, Tabs } from '@digdir/design-system-react'; +import { isObject } from '@altinn/schema-model'; import { ItemPropertiesTab } from './SchemaInspector/ItemPropertiesTab'; import { ItemFieldsTab } from './SchemaInspector/ItemFieldsTab'; import classes from './SchemaInspector.module.css'; @@ -13,71 +12,35 @@ import { useSchemaAndReduxSelector } from '../hooks/useSchemaAndReduxSelector'; export const SchemaInspector = () => { const { t } = useTranslation(); - enum TabValue { - Properties = 'properties', - Fields = 'fields', - } - const [tabsFor, setTabsFor] = useState(undefined); - const [activeTab, setActiveTab] = useState(TabValue.Properties); - const [tabItems, setTabItems] = useState([ - { - name: t('schema_editor.properties'), - content: null, - value: TabValue.Properties, - }, - ]); - const selectedItem = useSchemaAndReduxSelector(selectedItemSelector); - useEffect(() => { - if (!selectedItem) return; - if (tabsFor !== selectedItem.pointer) setActiveTab(TabValue.Properties); - const tabs = [ - { - name: t('schema_editor.properties'), - content: , - value: TabValue.Properties, - }, - ]; - if ( - selectedItem.objectKind === ObjectKind.Field && - selectedItem.fieldType === FieldType.Object - ) { - tabs.push({ - name: t('schema_editor.fields'), - content: , - value: TabValue.Fields, - }); - } - setTabsFor(selectedItem.pointer); - setTabItems(tabs); - }, [activeTab, TabValue.Fields, TabValue.Properties, selectedItem, tabsFor]); // eslint-disable-line react-hooks/exhaustive-deps - - const switchTab = (tabValue: string) => { - if ( - (tabValue === TabValue.Fields.toString() && (!isField(selectedItem) || !isObject(selectedItem))) || - !selectedItem - ) { - setActiveTab(TabValue.Properties); - } else { - setActiveTab(tabValue); - } - }; - - if (selectedItem) { + if (!selectedItem) { return ( -
- +
+

{t('schema_editor.no_item_selected')}

+
); } + const shouldDisplayFieldsTab = 'fieldType' in selectedItem && isObject(selectedItem); + return ( -
-

- {t('schema_editor.no_item_selected')} -

- -
+ + + {t('schema_editor.properties')} + {t('schema_editor.fields')} + + + + + {shouldDisplayFieldsTab ? ( + + + + ) : ( + {t('app_data_modelling.fields_information')} + )} + ); }; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.tsx index 5fd888aa136..0fe71a71ff4 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTab.tsx @@ -1,6 +1,6 @@ import type { BaseSyntheticEvent } from 'react'; import React, { useEffect } from 'react'; -import type { FieldNode } from '@altinn/schema-model'; +import { FieldNode } from '@altinn/schema-model'; import { FieldType, isField, isReference, ObjectKind } from '@altinn/schema-model'; import classes from './ItemFieldsTab.module.css'; import { usePrevious } from 'app-shared/hooks/usePrevious'; diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.tsx index 772f47aacba..9dafef44037 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemFieldsTab/ItemFieldsTable/ItemFieldsTable.tsx @@ -16,10 +16,7 @@ export type ItemFieldsTableProps = { * @component * Displays the Item Fields as a table */ -export const ItemFieldsTable = ({ - readonly, - selectedItem, -}: ItemFieldsTableProps): ReactNode => { +export const ItemFieldsTable = ({ readonly, selectedItem }: ItemFieldsTableProps): ReactNode => { const { t } = useTranslation(); const { schemaModel } = useSchemaEditorAppContext(); const addProperty = useAddProperty(); @@ -28,6 +25,7 @@ export const ItemFieldsTable = ({ addProperty(ObjectKind.Field, FieldType.String, selectedItem.pointer); const fieldNodes = schemaModel.getChildNodes(selectedItem.pointer); + const displayTableRows = fieldNodes.map((fieldNode, i) => ( { validateTestUiSchema(uiSchemaNodes); renderWithProviders({ appContextProps: { schemaModel: SchemaModel.fromArray(uiSchemaNodes) }, - })(); + })(); expect(screen.getByText(textMock('combination_inline_object_disclaimer'))).toBeDefined(); }); + + it('should render explanation message if the selected item is a root node', () => { + const rootNode: FieldNode = { + ...rootNodeMock, + pointer: '#', // root pointer + children: undefined, + }; + + renderWithProviders()(); + screen.getByText(textMock('app_data_modelling.properties_information')); + }); }); diff --git a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemPropertiesTab.tsx b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemPropertiesTab.tsx index 0d1dd774414..b38ee037135 100644 --- a/frontend/packages/schema-editor/src/components/SchemaInspector/ItemPropertiesTab.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaInspector/ItemPropertiesTab.tsx @@ -4,17 +4,24 @@ import { ObjectKind, ROOT_POINTER } from '@altinn/schema-model'; import { InlineObject } from './InlineObject'; import { ItemDataComponent } from './ItemDataComponent'; import { useSchemaEditorAppContext } from '@altinn/schema-editor/hooks/useSchemaEditorAppContext'; +import { Alert } from '@digdir/design-system-react'; +import { useTranslation } from 'react-i18next'; interface ItemPropertiesTabProps { selectedItem: UiSchemaNode; } export const ItemPropertiesTab = ({ selectedItem }: ItemPropertiesTabProps) => { + const { t } = useTranslation(); + const { schemaModel } = useSchemaEditorAppContext(); - if (schemaModel.isChildOfCombination(selectedItem.pointer) && selectedItem.objectKind !== ObjectKind.Reference) { + if ( + schemaModel.isChildOfCombination(selectedItem.pointer) && + selectedItem.objectKind !== ObjectKind.Reference + ) { return ; } else if (selectedItem.pointer === ROOT_POINTER) { - return <>root; + return {t('app_data_modelling.properties_information')}; } else { return ; } From c3b99813fa3e904f1c1fc71af25ffd7f563a1633 Mon Sep 17 00:00:00 2001 From: WilliamThorenfeldt <133344438+WilliamThorenfeldt@users.noreply.github.com> Date: Mon, 18 Dec 2023 10:54:14 +0100 Subject: [PATCH 20/21] chaning the lang (#11879) --- frontend/language/src/nb.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index c314649e951..1b2972301d3 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -942,7 +942,7 @@ "schema_editor.add": "Legg til", "schema_editor.add_combination": "Legg til kombinasjon", "schema_editor.add_element": "Legg til element", - "schema_editor.add_enum": "Legg til gyldig verdi", + "schema_editor.add_enum": "Legg til ny verdi", "schema_editor.add_field": "Legg til felt", "schema_editor.add_property": "Legg til felt", "schema_editor.add_reference": "Legg til referanse", From e4b8f879dcc53b66af5cf34c12b0d5c55c9962c6 Mon Sep 17 00:00:00 2001 From: Mirko Sekulic Date: Mon, 18 Dec 2023 12:59:58 +0100 Subject: [PATCH 21/21] Remove cypress from verification step in pr template (#11882) --- .github/pull_request_template.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4132ec96ea8..cd6b79b44a8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -10,7 +10,6 @@ - [ ] **Your** code builds clean without any errors or warnings - [ ] Manual testing done (required) - [ ] Relevant automated test added (if you find this hard, leave it and we'll help out) -- [ ] Cypress tests run green locally (https://github.com/Altinn/altinn-studio/tree/master/frontend/testing/cypress#run-altinn-studio-tests) ## Documentation - [ ] User documentation is updated with a separate linked PR in [altinn-studio-docs.](https://github.com/Altinn/altinn-studio-docs) (if applicable)