From 4773d9f03b58e1d4a8ee6dc0c0db5c8d2dac58bf Mon Sep 17 00:00:00 2001 From: Mirko Sekulic Date: Fri, 8 Nov 2024 10:27:38 +0100 Subject: [PATCH 1/6] fix: pipeline parameters (#14013) --- .github/workflows/deploy-designer.yaml | 2 +- .github/workflows/template-flux-config-push.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy-designer.yaml b/.github/workflows/deploy-designer.yaml index 3024ed94c9b..c5d6eed5e74 100644 --- a/.github/workflows/deploy-designer.yaml +++ b/.github/workflows/deploy-designer.yaml @@ -113,5 +113,5 @@ jobs: client-id: ${{ secrets.AZURE_CLIENT_ID_FC }} tenant-id: ${{ secrets.AZURE_TENANT_ID_FC }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID_FC }} - trace-connetion-string: ${{ secrets.APP_INSIGHTS_CONNECTION_STRING }} + trace-connection-string: ${{ secrets.APP_INSIGHTS_CONNECTION_STRING }} trace-repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/template-flux-config-push.yaml b/.github/workflows/template-flux-config-push.yaml index 6db196e1522..4c405347b63 100644 --- a/.github/workflows/template-flux-config-push.yaml +++ b/.github/workflows/template-flux-config-push.yaml @@ -42,9 +42,9 @@ on: subscription-id: required: true trace-connection-string: - required: true + required: false trace-repo-token: - required: true + required: false jobs: config-oci-artifact-push: From bbc458d45584126bbb66db0fee18dfe9a7e7bccb Mon Sep 17 00:00:00 2001 From: JamalAlabdullah <90609090+JamalAlabdullah@users.noreply.github.com> Date: Fri, 8 Nov 2024 10:50:44 +0100 Subject: [PATCH 2/6] fix: removed unused unddefinedLayoutSet folder (#14010) Co-authored-by: Jamal Alabdullah --- .../UndefinedLayoutSet.test.tsx | 42 ------------------- .../UndefinedLayoutSet/UndefinedLayoutSet.tsx | 12 ------ 2 files changed, 54 deletions(-) delete mode 100644 frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.test.tsx delete mode 100644 frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.tsx diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.test.tsx deleted file mode 100644 index 07d98ebf7dd..00000000000 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.test.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { UndefinedLayoutSet, type UndefinedLayoutSetProps } from './UndefinedLayoutSet'; -import userEvent from '@testing-library/user-event'; - -const defaultUndefinedLayoutSetProps = { - onClick: jest.fn(), - label: '', -}; - -describe('UndefinedLayoutSet', () => { - it('it should render add layout-set with given label', () => { - const label = 'Add link to layout set'; - renderUndefinedLayoutSet({ - ...defaultUndefinedLayoutSetProps, - label, - }); - - const addLinkToLayoutSetButton = screen.getByRole('button', { name: label }); - expect(addLinkToLayoutSetButton).toBeInTheDocument(); - }); - - it('should invoke onClick callback when button is clicked', async () => { - const user = userEvent.setup(); - const label = 'add'; - const onClickMock = jest.fn(); - - renderUndefinedLayoutSet({ - onClick: onClickMock, - label, - }); - - const button = screen.getByRole('button', { name: label }); - await user.click(button); - - expect(onClickMock).toHaveBeenCalledTimes(1); - }); -}); - -const renderUndefinedLayoutSet = (props?: UndefinedLayoutSetProps): void => { - render(); -}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.tsx deleted file mode 100644 index 9c3ec829372..00000000000 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/UndefinedLayoutSet/UndefinedLayoutSet.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import { StudioProperty } from '@studio/components'; -import { LinkIcon } from '@studio/icons'; - -export type UndefinedLayoutSetProps = { - onClick: () => void; - label: string; -}; - -export const UndefinedLayoutSet = ({ onClick, label }: UndefinedLayoutSetProps) => ( - } onClick={onClick} property={label} /> -); From a78cd5f8922a8b2fb66cd2ac07b31f40c4397ac1 Mon Sep 17 00:00:00 2001 From: Mirko Sekulic Date: Fri, 8 Nov 2024 12:01:32 +0100 Subject: [PATCH 3/6] feat: distributed requests synchronization (#13993) --- backend/packagegroups/NuGet.props | 2 + .../Extensions/ServiceCollectionExtensions.cs | 29 ++++ .../UserRequestSynchronizationSettings.cs | 23 --- .../Controllers/AppDevelopmentController.cs | 11 +- .../ApplicationMetadataController.cs | 11 +- .../Controllers/RepositoryController.cs | 134 ++++++------------ .../Controllers/ResourceAdminController.cs | 20 +-- backend/src/Designer/Designer.csproj | 1 + .../Infrastructure/ServiceRegistration.cs | 11 -- .../DistributedLockProviderExtensions.cs | 60 ++++++++ .../RequestSyncExtensions.cs | 35 +++++ .../RequestSynchronizationMiddleware.cs | 36 +++++ .../Services/EditingContextResolver.cs | 99 +++++++++++++ .../Services/IEditingContextResolver.cs | 18 +++ .../Services/IRequestSyncEvaluator.cs | 18 +++ .../Services/IRequestSyncResolver.cs | 18 +++ .../EndpointNameSyncEvaluator.cs | 83 +++++++++++ .../Services/RequestSyncResolver.cs | 28 ++++ backend/src/Designer/Program.cs | 4 + .../ProcessModeling/ProcessModelingService.cs | 49 ++----- .../UserRequestsSynchronizationService.cs | 56 -------- .../IUserRequestsSynchronizationService.cs | 12 -- .../ApiTests/DesignerEndpointsTestsBase.cs | 7 + .../AddLayoutSetTests.cs | 5 - .../DataModelsController/PostTests.cs | 2 +- .../ResetLocalRepositoryTests.cs | 4 +- .../ResourceAdminControllerTestsBaseClass.cs | 5 +- .../Designer.Tests/Designer.Tests.csproj | 1 + .../Designer.Tests/Fixtures/GiteaFixture.cs | 3 + .../GiteaIntegrationTestsBase.cs | 42 +++--- .../Services/ProcessModelingServiceTests.cs | 6 +- ...UserRequestsSynchronizationServiceTests.cs | 33 ----- .../Utils/TestLockPathProvider.cs | 25 ++++ 33 files changed, 560 insertions(+), 331 deletions(-) delete mode 100644 backend/src/Designer/Configuration/UserRequestSynchronizationSettings.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/DistributedLockProviderExtensions.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/RequestSyncExtensions.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/RequestSynchronizationMiddleware.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/Services/EditingContextResolver.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/Services/IEditingContextResolver.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncEvaluator.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncResolver.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncEvaluators/EndpointNameSyncEvaluator.cs create mode 100644 backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncResolver.cs delete mode 100644 backend/src/Designer/Services/Implementation/UserRequestsSynchronizationService.cs delete mode 100644 backend/src/Designer/Services/Interfaces/IUserRequestsSynchronizationService.cs delete mode 100644 backend/tests/Designer.Tests/Services/UserRequestsSynchronizationServiceTests.cs create mode 100644 backend/tests/Designer.Tests/Utils/TestLockPathProvider.cs diff --git a/backend/packagegroups/NuGet.props b/backend/packagegroups/NuGet.props index 92bc5c44d6e..82b74f8b1a1 100644 --- a/backend/packagegroups/NuGet.props +++ b/backend/packagegroups/NuGet.props @@ -39,6 +39,7 @@ + @@ -60,5 +61,6 @@ + diff --git a/backend/src/Designer/Configuration/Extensions/ServiceCollectionExtensions.cs b/backend/src/Designer/Configuration/Extensions/ServiceCollectionExtensions.cs index 9aead04b2df..88a0ee3b7d3 100644 --- a/backend/src/Designer/Configuration/Extensions/ServiceCollectionExtensions.cs +++ b/backend/src/Designer/Configuration/Extensions/ServiceCollectionExtensions.cs @@ -11,6 +11,16 @@ namespace Altinn.Studio.Designer.Configuration.Extensions { public static class ServiceCollectionExtensions { + + /// + /// Registers all settings that implement or inherit from the marker type. + /// Settings configuration will be read from the configuration, and the settings section name will be the same as the class name. + /// It will register the settings as a scoped service. + /// + /// The to add the service to. + /// An holding the configuration of the app. + /// The marker type used to identify the services or settings to be registered. + /// A reference to this instance after the operation has completed. public static IServiceCollection RegisterSettingsByBaseType(this IServiceCollection services, IConfiguration configuration) { var typesToRegister = AltinnAssembliesScanner.GetTypesAssignedFrom() @@ -81,5 +91,24 @@ private static void ConfigureSettingsTypeBySection(this IServiceCollect services.TryAddScoped(typeof(TOption), svc => ((IOptionsSnapshot)svc.GetService(typeof(IOptionsSnapshot)))!.Value); } + /// + /// Register all the services that implement or inherit from the marker interface. + /// + /// The to add the service to. + /// The marker type used to identify the services or settings to be registered. + /// A reference to this instance after the operation has completed. + public static IServiceCollection RegisterSingletonServicesByBaseType(this IServiceCollection services) + { + var typesToRegister = AltinnAssembliesScanner.GetTypesAssignedFrom() + .Where(type => !type.IsInterface && !type.IsAbstract); + + foreach (var serviceType in typesToRegister) + { + services.TryAddSingleton(typeof(TMarker), serviceType); + } + + return services; + } + } } diff --git a/backend/src/Designer/Configuration/UserRequestSynchronizationSettings.cs b/backend/src/Designer/Configuration/UserRequestSynchronizationSettings.cs deleted file mode 100644 index 472e07adad0..00000000000 --- a/backend/src/Designer/Configuration/UserRequestSynchronizationSettings.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Altinn.Studio.Designer.Configuration.Marker; - -namespace Altinn.Studio.Designer.Configuration -{ - /// - /// Settings that provide configuration for the user request synchronization service. - /// - public class UserRequestSynchronizationSettings : ISettingsMarker - { - /// - /// Describes the number of minutes a semaphore will be kept before it is removed. Expiry is renewed each time the semaphore is used. - /// - public int SemaphoreExpiryInSeconds { get; set; } = 2 * 60 * 60; - /// - /// Describes how frequently the service will clean up unused semaphores. - /// - public int CleanUpFrequencyInSeconds { get; set; } = 2 * 60 * 60; - /// - /// Defines the maximum number of parallel requests per user. - /// - public int MaxDegreeOfParallelism { get; set; } = 1; - } -} diff --git a/backend/src/Designer/Controllers/AppDevelopmentController.cs b/backend/src/Designer/Controllers/AppDevelopmentController.cs index b9833d8894c..0c9ed9a261f 100644 --- a/backend/src/Designer/Controllers/AppDevelopmentController.cs +++ b/backend/src/Designer/Controllers/AppDevelopmentController.cs @@ -35,7 +35,6 @@ public class AppDevelopmentController : Controller private readonly IAltinnGitRepositoryFactory _altinnGitRepositoryFactory; private readonly ApplicationInsightsSettings _applicationInsightsSettings; private readonly IMediator _mediator; - private readonly IUserRequestsSynchronizationService _userRequestsSynchronizationService; /// @@ -47,8 +46,7 @@ public class AppDevelopmentController : Controller /// /// An /// - /// An used to control parallel execution of user requests. - public AppDevelopmentController(IAppDevelopmentService appDevelopmentService, IRepository repositoryService, ISourceControl sourceControl, IAltinnGitRepositoryFactory altinnGitRepositoryFactory, ApplicationInsightsSettings applicationInsightsSettings, IMediator mediator, IUserRequestsSynchronizationService userRequestsSynchronizationService) + public AppDevelopmentController(IAppDevelopmentService appDevelopmentService, IRepository repositoryService, ISourceControl sourceControl, IAltinnGitRepositoryFactory altinnGitRepositoryFactory, ApplicationInsightsSettings applicationInsightsSettings, IMediator mediator) { _appDevelopmentService = appDevelopmentService; _repository = repositoryService; @@ -56,7 +54,6 @@ public AppDevelopmentController(IAppDevelopmentService appDevelopmentService, IR _altinnGitRepositoryFactory = altinnGitRepositoryFactory; _applicationInsightsSettings = applicationInsightsSettings; _mediator = mediator; - _userRequestsSynchronizationService = userRequestsSynchronizationService; } /// @@ -218,8 +215,6 @@ public ActionResult UpdateFormLayoutName(string org, string app, [FromQuery] str public async Task SaveLayoutSettings(string org, string app, [FromQuery] string layoutSetName, [FromBody] JsonNode layoutSettings, CancellationToken cancellationToken) { string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, app, developer); - await semaphore.WaitAsync(); try { var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); @@ -230,10 +225,6 @@ public async Task SaveLayoutSettings(string org, string app, [From { return NotFound(exception.Message); } - finally - { - semaphore.Release(); - } } /// diff --git a/backend/src/Designer/Controllers/ApplicationMetadataController.cs b/backend/src/Designer/Controllers/ApplicationMetadataController.cs index 61826174c94..53fb28f44c9 100644 --- a/backend/src/Designer/Controllers/ApplicationMetadataController.cs +++ b/backend/src/Designer/Controllers/ApplicationMetadataController.cs @@ -1,5 +1,4 @@ using System.IO; -using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Models.App; using Altinn.Studio.Designer.Services.Interfaces; @@ -19,17 +18,15 @@ namespace Altinn.Studio.Designer.Controllers public class ApplicationMetadataController : ControllerBase { private readonly IApplicationMetadataService _applicationMetadataService; - private readonly IUserRequestsSynchronizationService _userRequestsSynchronizationService; /// /// Initializes a new instance of the class. /// /// The application metadata service /// The user requests synchronization service - public ApplicationMetadataController(IApplicationMetadataService applicationMetadataService, IUserRequestsSynchronizationService userRequestsSynchronizationService) + public ApplicationMetadataController(IApplicationMetadataService applicationMetadataService) { _applicationMetadataService = applicationMetadataService; - _userRequestsSynchronizationService = userRequestsSynchronizationService; } /// @@ -145,8 +142,6 @@ public async Task UpdateMetadataForAttachment([FromBody] dynamic a [Route("attachment-component")] public async Task DeleteMetadataForAttachment(string org, string app, [FromBody] string id) { - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, app, ""); - await semaphore.WaitAsync(); try { await _applicationMetadataService.DeleteMetadataForAttachment(org, app, id); @@ -156,10 +151,6 @@ public async Task DeleteMetadataForAttachment(string org, string a { return BadRequest("Could not delete metadata"); } - finally - { - semaphore.Release(); - } } } } diff --git a/backend/src/Designer/Controllers/RepositoryController.cs b/backend/src/Designer/Controllers/RepositoryController.cs index 7dd985d5ffe..093394037f8 100644 --- a/backend/src/Designer/Controllers/RepositoryController.cs +++ b/backend/src/Designer/Controllers/RepositoryController.cs @@ -4,7 +4,6 @@ using System.IO.Compression; using System.Linq; using System.Net; -using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Configuration; using Altinn.Studio.Designer.Enums; @@ -27,21 +26,33 @@ namespace Altinn.Studio.Designer.Controllers /// /// Initializes a new instance of the class. /// - /// the gitea wrapper - /// the source control - /// the repository control - /// An used to control parallel execution of user requests. - /// websocket syncHub [Authorize] [AutoValidateAntiforgeryToken] [Route("designer/api/repos")] - public class RepositoryController(IGitea giteaWrapper, ISourceControl sourceControl, IRepository repository, IUserRequestsSynchronizationService userRequestsSynchronizationService, IHubContext syncHub) : ControllerBase + public class RepositoryController : ControllerBase { - private readonly IGitea _giteaApi = giteaWrapper; - private readonly ISourceControl _sourceControl = sourceControl; - private readonly IRepository _repository = repository; - private readonly IUserRequestsSynchronizationService _userRequestsSynchronizationService = userRequestsSynchronizationService; - private readonly IHubContext _syncHub = syncHub; + private readonly IGitea _giteaApi; + private readonly ISourceControl _sourceControl; + private readonly IRepository _repository; + private readonly IHubContext _syncHub; + + /// + /// This is the API controller for functionality related to repositories. + /// + /// + /// Initializes a new instance of the class. + /// + /// the gitea wrapper + /// the source control + /// the repository control + /// websocket syncHub + public RepositoryController(IGitea giteaWrapper, ISourceControl sourceControl, IRepository repository, IHubContext syncHub) + { + _giteaApi = giteaWrapper; + _sourceControl = sourceControl; + _repository = repository; + _syncHub = syncHub; + } /// /// Returns a list over repositories @@ -204,18 +215,8 @@ public async Task GetRepository(string org, string repository) [Route("repo/{org}/{repository:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/status")] public async Task RepoStatus(string org, string repository) { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, repository, developer); - await semaphore.WaitAsync(); - try - { - await _sourceControl.FetchRemoteChanges(org, repository); - return _sourceControl.RepositoryStatus(org, repository); - } - finally - { - semaphore.Release(); - } + await _sourceControl.FetchRemoteChanges(org, repository); + return _sourceControl.RepositoryStatus(org, repository); } /// @@ -228,18 +229,8 @@ public async Task RepoStatus(string org, string repository) [Route("repo/{org}/{repository:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/diff")] public async Task> RepoDiff(string org, string repository) { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, repository, developer); - await semaphore.WaitAsync(); - try - { - await _sourceControl.FetchRemoteChanges(org, repository); - return await _sourceControl.GetChangedContent(org, repository); - } - finally - { - semaphore.Release(); - } + await _sourceControl.FetchRemoteChanges(org, repository); + return await _sourceControl.GetChangedContent(org, repository); } /// @@ -252,27 +243,16 @@ public async Task> RepoDiff(string org, string reposi [Route("repo/{org}/{repository:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/pull")] public async Task Pull(string org, string repository) { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, repository, developer); - await semaphore.WaitAsync(); - - try - { - RepoStatus pullStatus = await _sourceControl.PullRemoteChanges(org, repository); + RepoStatus pullStatus = await _sourceControl.PullRemoteChanges(org, repository); - RepoStatus status = _sourceControl.RepositoryStatus(org, repository); + RepoStatus status = _sourceControl.RepositoryStatus(org, repository); - if (pullStatus.RepositoryStatus != Enums.RepositoryStatus.Ok) - { - status.RepositoryStatus = pullStatus.RepositoryStatus; - } - - return status; - } - finally + if (pullStatus.RepositoryStatus != Enums.RepositoryStatus.Ok) { - semaphore.Release(); + status.RepositoryStatus = pullStatus.RepositoryStatus; } + + return status; } /// @@ -283,38 +263,33 @@ public async Task Pull(string org, string repository) /// True if the reset was successful, otherwise false. [HttpGet] [Route("repo/{org}/{repository:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/reset")] - public ActionResult ResetLocalRepository(string org, string repository) + public async Task ResetLocalRepository(string org, string repository) { string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, repository, developer); - semaphore.Wait(); + AltinnRepoEditingContext editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repository, developer); try { - _repository.ResetLocalRepository(AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repository, developer)); + await _repository.ResetLocalRepository(editingContext); return Ok(); } catch (Exception) { return StatusCode(StatusCodes.Status500InternalServerError); } - finally - { - semaphore.Release(); - } } /// /// Pushes changes for a given repo /// + /// Unique identifier of the organisation responsible for the app. + /// the name of the local repository to reset /// Info about the commit [HttpPost] [Route("repo/{org}/{repository:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/commit-and-push")] - public async Task CommitAndPushRepo([FromBody] CommitInfo commitInfo) + public async Task CommitAndPushRepo(string org, string repository, [FromBody] CommitInfo commitInfo) { string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(commitInfo.Org, commitInfo.Repository, developer); - await semaphore.WaitAsync(); try { await _sourceControl.PushChangesForRepository(commitInfo); @@ -330,25 +305,20 @@ public async Task CommitAndPushRepo([FromBody] CommitInfo commitInfo) await _syncHub.Clients.Group(developer).FileSyncSuccess(syncSuccess); } } - finally - { - semaphore.Release(); - } - } /// /// Commit changes /// + /// Unique identifier of the organisation responsible for the app. + /// the name of the local repository to reset /// Info about the commit /// http response message as ok if commit is successful [HttpPost] [Route("repo/{org}/{repository:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/commit")] - public ActionResult Commit([FromBody] CommitInfo commitInfo) + public async Task Commit(string org, string repository, [FromBody] CommitInfo commitInfo) { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(commitInfo.Org, commitInfo.Repository, developer); - semaphore.Wait(); + await Task.CompletedTask; try { _sourceControl.Commit(commitInfo); @@ -358,10 +328,6 @@ public ActionResult Commit([FromBody] CommitInfo commitInfo) { return StatusCode(StatusCodes.Status500InternalServerError); } - finally - { - semaphore.Release(); - } } /// @@ -373,18 +339,8 @@ public ActionResult Commit([FromBody] CommitInfo commitInfo) [Route("repo/{org}/{repository:regex(^(?!datamodels$)[[a-z]][[a-z0-9-]]{{1,28}}[[a-z0-9]]$)}/push")] public async Task Push(string org, string repository) { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, repository, developer); - await semaphore.WaitAsync(); - try - { - bool pushSuccess = await _sourceControl.Push(org, repository); - return pushSuccess ? Ok() : StatusCode(StatusCodes.Status500InternalServerError); - } - finally - { - semaphore.Release(); - } + bool pushSuccess = await _sourceControl.Push(org, repository); + return pushSuccess ? Ok() : StatusCode(StatusCodes.Status500InternalServerError); } /// diff --git a/backend/src/Designer/Controllers/ResourceAdminController.cs b/backend/src/Designer/Controllers/ResourceAdminController.cs index 47eead00ed1..10be3387b87 100644 --- a/backend/src/Designer/Controllers/ResourceAdminController.cs +++ b/backend/src/Designer/Controllers/ResourceAdminController.cs @@ -35,9 +35,8 @@ public class ResourceAdminController : ControllerBase private readonly IOrgService _orgService; private readonly IResourceRegistry _resourceRegistry; private readonly ResourceRegistryIntegrationSettings _resourceRegistrySettings; - private readonly IUserRequestsSynchronizationService _userRequestsSynchronizationService; - public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRegistryOptions resourceRegistryOptions, IMemoryCache memoryCache, IOptions cacheSettings, IOrgService orgService, IOptions resourceRegistryEnvironment, IResourceRegistry resourceRegistry, IUserRequestsSynchronizationService userRequestsSynchronizationService) + public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRegistryOptions resourceRegistryOptions, IMemoryCache memoryCache, IOptions cacheSettings, IOrgService orgService, IOptions resourceRegistryEnvironment, IResourceRegistry resourceRegistry) { _giteaApi = gitea; _repository = repository; @@ -47,7 +46,6 @@ public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRe _orgService = orgService; _resourceRegistrySettings = resourceRegistryEnvironment.Value; _resourceRegistry = resourceRegistry; - _userRequestsSynchronizationService = userRequestsSynchronizationService; } [HttpPost] @@ -274,20 +272,8 @@ public ActionResult GetValidateResource(string org, string repository, string id [Route("designer/api/{org}/resources/updateresource/{id}")] public async Task UpdateResource(string org, string id, [FromBody] ServiceResource resource, CancellationToken cancellationToken = default) { - string repository = GetRepositoryName(org); - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, repository, developer); - await semaphore.WaitAsync(cancellationToken); - try - { - resource.HasCompetentAuthority = await GetCompetentAuthorityFromOrg(org); - return _repository.UpdateServiceResource(org, id, resource); - } - finally - { - semaphore.Release(); - } - + resource.HasCompetentAuthority = await GetCompetentAuthorityFromOrg(org); + return _repository.UpdateServiceResource(org, id, resource); } [HttpPost] diff --git a/backend/src/Designer/Designer.csproj b/backend/src/Designer/Designer.csproj index f27d3cd30cc..5b6b5fd1df5 100644 --- a/backend/src/Designer/Designer.csproj +++ b/backend/src/Designer/Designer.csproj @@ -27,6 +27,7 @@ + diff --git a/backend/src/Designer/Infrastructure/ServiceRegistration.cs b/backend/src/Designer/Infrastructure/ServiceRegistration.cs index 765c1a26b2a..33dfce06b31 100644 --- a/backend/src/Designer/Infrastructure/ServiceRegistration.cs +++ b/backend/src/Designer/Infrastructure/ServiceRegistration.cs @@ -20,8 +20,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Options; using static Altinn.Studio.DataModeling.Json.Keywords.JsonSchemaKeywords; namespace Altinn.Studio.Designer.Infrastructure @@ -82,7 +80,6 @@ public static IServiceCollection RegisterServiceImplementations(this IServiceCol services.AddTransient(); services.AddTransient(); services.RegisterDatamodeling(configuration); - services.RegisterUserRequestSynchronization(configuration); return services; } @@ -99,13 +96,5 @@ public static IServiceCollection RegisterDatamodeling(this IServiceCollection se RegisterXsdKeywords(); return services; } - - public static IServiceCollection RegisterUserRequestSynchronization(this IServiceCollection services, IConfiguration configuration) - { - services.Configure(configuration.GetSection(nameof(UserRequestSynchronizationSettings))); - services.TryAddSingleton(typeof(UserRequestSynchronizationSettings), svc => ((IOptions)svc.GetService(typeof(IOptions)))!.Value); - services.TryAddSingleton(); - return services; - } } } diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/DistributedLockProviderExtensions.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/DistributedLockProviderExtensions.cs new file mode 100644 index 00000000000..58cb9e26dc2 --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/DistributedLockProviderExtensions.cs @@ -0,0 +1,60 @@ +#nullable enable +using System; +using System.Threading; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Models; +using Medallion.Threading; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization; + +/// +/// Extension methods for . +/// Enriches the provider with methods that create locks based on . +/// +public static class DistributedLockProviderExtensions +{ + private static string GenerateKey(AltinnRepoEditingContext editingContext) + => $"{editingContext.Org}_{editingContext.Repo}_{editingContext.Developer}".ToLower(); + + + /// + /// Constructs an instance with the given . + /// + public static IDistributedLock CreateLock(this IDistributedLockProvider distributedLockProvider, + AltinnRepoEditingContext editingContext) + { + string key = GenerateKey(editingContext); + return distributedLockProvider.CreateLock(key); + } + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle? TryAcquireLock(this IDistributedLockProvider provider, AltinnRepoEditingContext editingContext, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(editingContext).TryAcquire(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static IDistributedSynchronizationHandle AcquireLock(this IDistributedLockProvider provider, AltinnRepoEditingContext editingContext, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(editingContext).Acquire(timeout, cancellationToken); + + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask TryAcquireLockAsync(this IDistributedLockProvider provider, AltinnRepoEditingContext editingContext, TimeSpan timeout = default, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(editingContext).TryAcquireAsync(timeout, cancellationToken); + + /// + /// Equivalent to calling and then + /// . + /// + public static ValueTask AcquireLockAsync(this IDistributedLockProvider provider, AltinnRepoEditingContext editingContext, TimeSpan? timeout = null, CancellationToken cancellationToken = default) => + (provider ?? throw new ArgumentNullException(nameof(provider))).CreateLock(editingContext).AcquireAsync(timeout, cancellationToken); + + +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/RequestSyncExtensions.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/RequestSyncExtensions.cs new file mode 100644 index 00000000000..25a1acb882a --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/RequestSyncExtensions.cs @@ -0,0 +1,35 @@ +using Altinn.Studio.Designer.Configuration; +using Altinn.Studio.Designer.Configuration.Extensions; +using Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services; +using Medallion.Threading; +using Medallion.Threading.Postgres; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization; + +public static class RequestSyncExtensions +{ + /// + /// Registers all services needed for request synchronization. + /// + /// The to add the service to. + /// An holding the configuration of the project. + /// A reference to this instance after the operation has completed. + public static IServiceCollection RegisterSynchronizationServices(this IServiceCollection services, IConfiguration configuration) + { + services.AddSingleton(); + services.AddSingleton(); + services.RegisterSingletonServicesByBaseType(); + services.AddSingleton(_ => + { + PostgreSQLSettings postgresSettings = configuration.GetSection(nameof(PostgreSQLSettings)).Get(); + string connectionString = string.Format( + postgresSettings.ConnectionString, + postgresSettings.DesignerDbPwd); + return new PostgresDistributedSynchronizationProvider(connectionString); + }); + return services; + } + +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/RequestSynchronizationMiddleware.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/RequestSynchronizationMiddleware.cs new file mode 100644 index 00000000000..f89734513bb --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/RequestSynchronizationMiddleware.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services; +using Altinn.Studio.Designer.Models; +using Medallion.Threading; +using Microsoft.AspNetCore.Http; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization; + +/// +/// Middleware that synchronizes requests in a distributed environment. +/// +public class RequestSynchronizationMiddleware +{ + private readonly RequestDelegate _next; + private readonly TimeSpan _waitTimeout = TimeSpan.FromSeconds(30); + + public RequestSynchronizationMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext httpContext, IRequestSyncResolver requestSyncResolver, IDistributedLockProvider synchronizationProvider) + { + if (requestSyncResolver.TryResolveSyncRequest(httpContext, out AltinnRepoEditingContext editingContext)) + { + await using (await synchronizationProvider.AcquireLockAsync(editingContext, _waitTimeout, httpContext.RequestAborted)) + { + await _next(httpContext); + return; + } + } + + await _next(httpContext); + } +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/Services/EditingContextResolver.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/EditingContextResolver.cs new file mode 100644 index 00000000000..9e7fdc67299 --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/EditingContextResolver.cs @@ -0,0 +1,99 @@ +using System; +using Altinn.Studio.Designer.Controllers; +using Altinn.Studio.Designer.Helpers; +using Altinn.Studio.Designer.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services; + +public class EditingContextResolver : IEditingContextResolver +{ + public bool TryResolveContext(HttpContext httpContext, out AltinnRepoEditingContext context) + { + context = null; + + if (TryResolveOrg(httpContext, out string org) && TryResolveApp(httpContext, out string app) && TryResolveDeveloper(httpContext, out string developer)) + { + context = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); + return true; + } + + return false; + } + + private static bool TryResolveOrg(HttpContext httpContext, out string org) + { + org = null; + var routeValues = httpContext.Request.RouteValues; + if (routeValues.TryGetValue("org", out object orgValue)) + { + org = orgValue?.ToString(); + return true; + } + + return false; + + } + + private static bool TryResolveApp(HttpContext httpContext, out string app) + { + + if (TryResolveAppFromRouteValues(httpContext, out app)) + { + return true; + } + + if (TryResolveAppIfResourceAdmin(httpContext, out app)) + { + return true; + } + + return false; + } + + private static bool TryResolveAppFromRouteValues(HttpContext httpContext, out string app) + { + app = null; + var routeValues = httpContext.Request.RouteValues; + + if (routeValues.TryGetValue("app", out object appValue) || routeValues.TryGetValue("repo", out appValue) || + routeValues.TryGetValue("repository", out appValue)) + { + app = appValue?.ToString(); + return true; + } + + return false; + } + + private static bool TryResolveAppIfResourceAdmin(HttpContext httpContext, out string app) + { + app = null; + var endpoint = httpContext.GetEndpoint(); + + var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); + + if (controllerActionDescriptor == null) + { + return false; + } + + string controllerName = controllerActionDescriptor.ControllerName; + bool isResourceAdmin = string.Equals(controllerName, nameof(ResourceAdminController).Replace("Controller", string.Empty), + StringComparison.InvariantCulture); + if (isResourceAdmin && TryResolveOrg(httpContext, out string org)) + { + app = $"{org}-resources"; + return true; + } + + return false; + } + + private static bool TryResolveDeveloper(HttpContext httpContext, out string developer) + { + developer = AuthenticationHelper.GetDeveloperUserName(httpContext); + return !string.IsNullOrEmpty(developer); + } +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IEditingContextResolver.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IEditingContextResolver.cs new file mode 100644 index 00000000000..ce1d24e4dbb --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IEditingContextResolver.cs @@ -0,0 +1,18 @@ +using Altinn.Studio.Designer.Models; +using Microsoft.AspNetCore.Http; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services; + +/// +/// Resolves the editing context for the incoming request. +/// +public interface IEditingContextResolver +{ + /// + /// Attempts to resolve the editing context for the incoming request. + /// + /// An instance holding request information. + /// Contains the resolved if the context was successfully resolved; otherwise, null. + /// A flag that indicates if editing context is resolved. + bool TryResolveContext(HttpContext httpContext, out AltinnRepoEditingContext context); +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncEvaluator.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncEvaluator.cs new file mode 100644 index 00000000000..bd076d06d2e --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncEvaluator.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services; + +/// +/// Evaluates if the incoming request is eligible for synchronization. +/// It doesn't determine if the request should be synchronized, but if it is eligible for synchronization. +/// It is used by to determine if the incoming request should be synchronized. +/// +public interface IRequestSyncEvaluator +{ + /// + /// Evaluates if the incoming request is eligible for synchronization. + /// + /// An class holding request information. + /// A flag that indicates if request is eligible for synchronization. + bool IsEligibleForSynchronization(HttpContext httpContext); +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncResolver.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncResolver.cs new file mode 100644 index 00000000000..989ca232244 --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/IRequestSyncResolver.cs @@ -0,0 +1,18 @@ +using Altinn.Studio.Designer.Models; +using Microsoft.AspNetCore.Http; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services; + +/// +/// Determines if the incoming request should be synchronized. +/// +public interface IRequestSyncResolver +{ + /// + /// Determines if the incoming request should be synchronized. + /// + /// An class holding request information. + /// An holding the data about editing context if request should be synchronized. Otherwise null. + /// A flag that indicates if request should be synchronized. + bool TryResolveSyncRequest(HttpContext httpContext, out AltinnRepoEditingContext editingContext); +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncEvaluators/EndpointNameSyncEvaluator.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncEvaluators/EndpointNameSyncEvaluator.cs new file mode 100644 index 00000000000..498e6d1b859 --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncEvaluators/EndpointNameSyncEvaluator.cs @@ -0,0 +1,83 @@ +using System.Collections.Frozen; +using System.Collections.Generic; +using Altinn.Studio.Designer.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services.RequestSyncEvaluators; + +/// +/// Evaluates if a request is eligible for synchronization based on the endpoint name. +/// Contains a whitelist of endpoints that are eligible for synchronization. +/// +public class EndpointNameSyncEvaluator : IRequestSyncEvaluator +{ + private const string RemoveControllerSuffix = "Controller"; + private readonly FrozenDictionary> _endpointsWhiteList = new Dictionary> + { + { + nameof(RepositoryController).Replace(RemoveControllerSuffix, string.Empty), + GenerateFrozenSet( + nameof(RepositoryController.RepoStatus), + nameof(RepositoryController.RepoDiff), + nameof(RepositoryController.Pull), + nameof(RepositoryController.ResetLocalRepository), + nameof(RepositoryController.CommitAndPushRepo), + nameof(RepositoryController.Commit), + nameof(RepositoryController.Push) + ) + }, + { + nameof(AppDevelopmentController).Replace(RemoveControllerSuffix, string.Empty), + GenerateFrozenSet( + nameof(AppDevelopmentController.SaveLayoutSettings) + ) + }, + { + nameof(ApplicationMetadataController).Replace(RemoveControllerSuffix, string.Empty), + GenerateFrozenSet( + nameof(ApplicationMetadataController.DeleteMetadataForAttachment) + ) + }, + { + nameof(ProcessModelingController).Replace(RemoveControllerSuffix, string.Empty), + GenerateFrozenSet( + nameof(ProcessModelingController.AddDataTypeToApplicationMetadata), + nameof(ProcessModelingController.DeleteDataTypeFromApplicationMetadata) + ) + }, + { + nameof(ResourceAdminController).Replace(RemoveControllerSuffix, string.Empty), + GenerateFrozenSet( + nameof(ResourceAdminController.UpdateResource) + ) + } + }.ToFrozenDictionary(); + + private static FrozenSet GenerateFrozenSet(params string[] actions) + { + return actions.ToFrozenSet(); + } + + public bool IsEligibleForSynchronization(HttpContext httpContext) + { + var endpoint = httpContext.GetEndpoint(); + + var controllerActionDescriptor = endpoint?.Metadata.GetMetadata(); + + if (controllerActionDescriptor == null) + { + return false; + } + + string controllerName = controllerActionDescriptor.ControllerName; + string actionName = controllerActionDescriptor.ActionName; + + if (_endpointsWhiteList.TryGetValue(controllerName, out FrozenSet actionSet) && actionSet.Contains(actionName)) + { + return true; + } + + return false; + } +} diff --git a/backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncResolver.cs b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncResolver.cs new file mode 100644 index 00000000000..bae0d863e81 --- /dev/null +++ b/backend/src/Designer/Middleware/UserRequestSynchronization/Services/RequestSyncResolver.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using System.Linq; +using Altinn.Studio.Designer.Models; +using Microsoft.AspNetCore.Http; + +namespace Altinn.Studio.Designer.Middleware.UserRequestSynchronization.Services; + +public class RequestSyncResolver : IRequestSyncResolver +{ + private readonly IEnumerable _requestSyncEvaluators; + private readonly IEditingContextResolver _editingContextResolver; + + public RequestSyncResolver(IEnumerable requestSyncEvaluators, IEditingContextResolver editingContextResolver) + { + _requestSyncEvaluators = requestSyncEvaluators; + _editingContextResolver = editingContextResolver; + } + + public bool TryResolveSyncRequest(HttpContext httpContext, out AltinnRepoEditingContext editingContext) + { + if (!_editingContextResolver.TryResolveContext(httpContext, out editingContext)) + { + return false; + } + + return _requestSyncEvaluators.Any(e => e.IsEligibleForSynchronization(httpContext)); + } +} diff --git a/backend/src/Designer/Program.cs b/backend/src/Designer/Program.cs index bd8c6a9dbb8..4ef468a331a 100644 --- a/backend/src/Designer/Program.cs +++ b/backend/src/Designer/Program.cs @@ -14,6 +14,7 @@ using Altinn.Studio.Designer.Infrastructure; using Altinn.Studio.Designer.Infrastructure.AnsattPorten; using Altinn.Studio.Designer.Infrastructure.Authorization; +using Altinn.Studio.Designer.Middleware.UserRequestSynchronization; using Altinn.Studio.Designer.Services.Implementation; using Altinn.Studio.Designer.Services.Interfaces; using Altinn.Studio.Designer.Tracing; @@ -261,6 +262,7 @@ void ConfigureServices(IServiceCollection services, IConfiguration configuration services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(Program).Assembly)); services.AddTransient(); services.AddFeatureManagement(); + services.RegisterSynchronizationServices(configuration); if (!env.IsDevelopment()) @@ -334,6 +336,8 @@ void Configure(IConfiguration configuration) app.MapHub("/previewHub"); app.MapHub("/sync-hub"); + app.UseMiddleware(); + logger.LogInformation("// Program.cs // Configure // Configuration complete"); } diff --git a/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs b/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs index bbf51bbec6d..b35f8a8ccdb 100644 --- a/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs +++ b/backend/src/Designer/Services/Implementation/ProcessModeling/ProcessModelingService.cs @@ -19,12 +19,10 @@ public class ProcessModelingService : IProcessModelingService { private readonly IAltinnGitRepositoryFactory _altinnGitRepositoryFactory; private readonly IAppDevelopmentService _appDevelopmentService; - private readonly IUserRequestsSynchronizationService _userRequestsSynchronizationService; - public ProcessModelingService(IAltinnGitRepositoryFactory altinnGitRepositoryFactory, IAppDevelopmentService appDevelopmentService, IUserRequestsSynchronizationService userRequestsSynchronizationService) + public ProcessModelingService(IAltinnGitRepositoryFactory altinnGitRepositoryFactory, IAppDevelopmentService appDevelopmentService) { _altinnGitRepositoryFactory = altinnGitRepositoryFactory; _appDevelopmentService = appDevelopmentService; - _userRequestsSynchronizationService = userRequestsSynchronizationService; } private string TemplatesFolderIdentifier(SemanticVersion version) => string.Join(".", nameof(Services), nameof(Implementation), nameof(ProcessModeling), "Templates", $"v{version.Major}"); @@ -65,46 +63,29 @@ public async Task AddDataTypeToApplicationMetadataAsync(AltinnRepoEditingContext { cancellationToken.ThrowIfCancellationRequested(); AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - await semaphore.WaitAsync(cancellationToken); - try + + ApplicationMetadata applicationMetadata = await altinnAppGitRepository.GetApplicationMetadata(cancellationToken); + if (!applicationMetadata.DataTypes.Exists(dataType => dataType.Id == dataTypeId)) { - ApplicationMetadata applicationMetadata = await altinnAppGitRepository.GetApplicationMetadata(cancellationToken); - if (!applicationMetadata.DataTypes.Exists(dataType => dataType.Id == dataTypeId)) + applicationMetadata.DataTypes.Add(new DataType { - applicationMetadata.DataTypes.Add(new DataType - { - Id = dataTypeId, - AllowedContentTypes = new List { "application/json" }, - MaxCount = 1, - TaskId = taskId, - EnablePdfCreation = false - }); - } - await altinnAppGitRepository.SaveApplicationMetadata(applicationMetadata); - } - finally - { - semaphore.Release(); + Id = dataTypeId, + AllowedContentTypes = new List { "application/json" }, + MaxCount = 1, + TaskId = taskId, + EnablePdfCreation = false + }); } + await altinnAppGitRepository.SaveApplicationMetadata(applicationMetadata); } public async Task DeleteDataTypeFromApplicationMetadataAsync(AltinnRepoEditingContext altinnRepoEditingContext, string dataTypeId, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - await semaphore.WaitAsync(cancellationToken); - try - { - ApplicationMetadata applicationMetadata = await altinnAppGitRepository.GetApplicationMetadata(cancellationToken); - applicationMetadata.DataTypes.RemoveAll(dataType => dataType.Id == dataTypeId); - await altinnAppGitRepository.SaveApplicationMetadata(applicationMetadata); - } - finally - { - semaphore.Release(); - } + ApplicationMetadata applicationMetadata = await altinnAppGitRepository.GetApplicationMetadata(cancellationToken); + applicationMetadata.DataTypes.RemoveAll(dataType => dataType.Id == dataTypeId); + await altinnAppGitRepository.SaveApplicationMetadata(applicationMetadata); } public async Task GetTaskTypeFromProcessDefinition(AltinnRepoEditingContext altinnRepoEditingContext, string layoutSetId) diff --git a/backend/src/Designer/Services/Implementation/UserRequestsSynchronizationService.cs b/backend/src/Designer/Services/Implementation/UserRequestsSynchronizationService.cs deleted file mode 100644 index 868a6050a17..00000000000 --- a/backend/src/Designer/Services/Implementation/UserRequestsSynchronizationService.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Threading; -using Altinn.Studio.Designer.Configuration; -using Altinn.Studio.Designer.Helpers; -using Altinn.Studio.Designer.Services.Interfaces; - -namespace Altinn.Studio.Designer.Services.Implementation -{ - /// - public class UserRequestsSynchronizationService : IUserRequestsSynchronizationService, IDisposable - { - private static readonly ConcurrentDictionary s_semaphoreSlims = new ConcurrentDictionary(); - private static readonly ConcurrentDictionary s_lastUsedTimes = new ConcurrentDictionary(); - private readonly UserRequestSynchronizationSettings _userParallelizationSettings; - - private readonly Timer _timer; - - public UserRequestsSynchronizationService(UserRequestSynchronizationSettings userParallelizationSettings) - { - _userParallelizationSettings = userParallelizationSettings; - _timer = new Timer(_ => CleanupUnusedKeys(), null, TimeSpan.FromSeconds(_userParallelizationSettings.CleanUpFrequencyInSeconds), TimeSpan.FromSeconds(_userParallelizationSettings.CleanUpFrequencyInSeconds)); - } - - public SemaphoreSlim GetRequestsSemaphore(string org, string repo, string developer) - { - Guard.AssertArgumentNotNull(org, nameof(org)); - Guard.AssertArgumentNotNull(repo, nameof(repo)); - Guard.AssertArgumentNotNull(developer, nameof(developer)); - - string key = GenerateKey(org, repo, developer); - s_lastUsedTimes.AddOrUpdate(key, DateTime.Now, (k, v) => DateTime.Now); - return s_semaphoreSlims.GetOrAdd(key, _ => new SemaphoreSlim(_userParallelizationSettings.MaxDegreeOfParallelism, _userParallelizationSettings.MaxDegreeOfParallelism)); - } - - private static string GenerateKey(string org, string repo, string developer) - => $"{org}_{repo}_{developer}".ToLower(); - - private void CleanupUnusedKeys() - { - DateTime now = DateTime.Now; - - foreach ((string key, DateTime lastUsed) in s_lastUsedTimes) - { - if (now.Subtract(lastUsed).TotalSeconds >= _userParallelizationSettings.SemaphoreExpiryInSeconds) - { - s_lastUsedTimes.TryRemove(key, out DateTime _); - s_semaphoreSlims.TryRemove(key, out SemaphoreSlim semaphoreSlim); - semaphoreSlim?.Dispose(); - } - } - } - - public void Dispose() => _timer?.Dispose(); - } -} diff --git a/backend/src/Designer/Services/Interfaces/IUserRequestsSynchronizationService.cs b/backend/src/Designer/Services/Interfaces/IUserRequestsSynchronizationService.cs deleted file mode 100644 index e1bf8776fa6..00000000000 --- a/backend/src/Designer/Services/Interfaces/IUserRequestsSynchronizationService.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Threading; - -namespace Altinn.Studio.Designer.Services.Interfaces -{ - /// - /// Service that provides semaphores for user requests, to control the number of parallel requests per user. - /// - public interface IUserRequestsSynchronizationService - { - SemaphoreSlim GetRequestsSemaphore(string org, string repo, string developer); - } -} diff --git a/backend/tests/Designer.Tests/Controllers/ApiTests/DesignerEndpointsTestsBase.cs b/backend/tests/Designer.Tests/Controllers/ApiTests/DesignerEndpointsTestsBase.cs index 1102f393408..6d52054892c 100644 --- a/backend/tests/Designer.Tests/Controllers/ApiTests/DesignerEndpointsTestsBase.cs +++ b/backend/tests/Designer.Tests/Controllers/ApiTests/DesignerEndpointsTestsBase.cs @@ -8,6 +8,8 @@ using Altinn.Studio.Designer.Services.Interfaces; using Designer.Tests.Mocks; using Designer.Tests.Utils; +using Medallion.Threading; +using Medallion.Threading.FileSystem; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.Extensions.DependencyInjection; @@ -34,6 +36,11 @@ protected override void ConfigureTestServices(IServiceCollection services) services.Configure(c => c.RepositoryLocation = TestRepositoriesLocation); services.AddSingleton(); + services.AddSingleton(_ => + { + var directoryInfo = TestLockPathProvider.Instance.LockFileDirectory; + return new FileDistributedSynchronizationProvider(directoryInfo); + }); } /// diff --git a/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/AddLayoutSetTests.cs b/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/AddLayoutSetTests.cs index 8993312a002..30ae9aa141d 100644 --- a/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/AddLayoutSetTests.cs +++ b/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/AddLayoutSetTests.cs @@ -7,18 +7,13 @@ using System.Text.Json.Nodes; using System.Threading.Tasks; using Altinn.Studio.Designer.Factories; -using Altinn.Studio.Designer.Filters; -using Altinn.Studio.Designer.Filters.AppDevelopment; using Altinn.Studio.Designer.Infrastructure.GitRepository; using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Models.Dto; -using Altinn.Studio.Designer.Services.Implementation; using Designer.Tests.Controllers.ApiTests; using Designer.Tests.Utils; using FluentAssertions; -using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Testing; -using Npgsql.Replication.PgOutput.Messages; using Xunit; namespace Designer.Tests.Controllers.AppDevelopmentController diff --git a/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs b/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs index 5cf41ba16c5..69afecad8f6 100644 --- a/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs +++ b/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs @@ -67,7 +67,7 @@ public async Task PostDatamodel_FromFormPost_ShouldReturnCreatedFromTemplate(str [InlineData("test/", "", false)] public async Task PostDatamodel_InvalidFormPost_ShouldReturnBadRequest(string modelName, string relativeDirectory, bool altinn2Compatible) { - string url = $"{VersionPrefix("xyz", "dummyRepo")}/new"; + string url = $"{VersionPrefix("xyz", "dummyrepo")}/new"; var createViewModel = new CreateModelViewModel() { ModelName = modelName, RelativeDirectory = relativeDirectory, Altinn2Compatible = altinn2Compatible }; diff --git a/backend/tests/Designer.Tests/Controllers/RepositoryController/ResetLocalRepositoryTests.cs b/backend/tests/Designer.Tests/Controllers/RepositoryController/ResetLocalRepositoryTests.cs index f98e85a7783..7dcabdcd866 100644 --- a/backend/tests/Designer.Tests/Controllers/RepositoryController/ResetLocalRepositoryTests.cs +++ b/backend/tests/Designer.Tests/Controllers/RepositoryController/ResetLocalRepositoryTests.cs @@ -23,9 +23,7 @@ public ResetLocalRepositoryTests(WebApplicationFactory factory) : base( // Do not use mocked repository protected override void ConfigureTestServices(IServiceCollection services) { - services.Configure(c => - c.RepositoryLocation = TestRepositoriesLocation); - services.AddSingleton(); + base.ConfigureTestServices(services); services.AddTransient(); } diff --git a/backend/tests/Designer.Tests/Controllers/ResourceAdminController/ResourceAdminControllerTestsBaseClass.cs b/backend/tests/Designer.Tests/Controllers/ResourceAdminController/ResourceAdminControllerTestsBaseClass.cs index 951ab23e92a..eea732572e7 100644 --- a/backend/tests/Designer.Tests/Controllers/ResourceAdminController/ResourceAdminControllerTestsBaseClass.cs +++ b/backend/tests/Designer.Tests/Controllers/ResourceAdminController/ResourceAdminControllerTestsBaseClass.cs @@ -21,10 +21,7 @@ public abstract class ResourceAdminControllerTestsBaseClass : Desig protected override void ConfigureTestServices(IServiceCollection services) { - - services.Configure(c => - c.RepositoryLocation = TestRepositoriesLocation); - services.AddSingleton(); + base.ConfigureTestServices(services); services.AddTransient(_ => RepositoryMock.Object); services.AddTransient(_ => ResourceRegistryMock.Object); services.AddTransient(_ => Altinn2MetadataClientMock.Object); diff --git a/backend/tests/Designer.Tests/Designer.Tests.csproj b/backend/tests/Designer.Tests/Designer.Tests.csproj index 84b0614d466..daf49e71bcd 100644 --- a/backend/tests/Designer.Tests/Designer.Tests.csproj +++ b/backend/tests/Designer.Tests/Designer.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/backend/tests/Designer.Tests/Fixtures/GiteaFixture.cs b/backend/tests/Designer.Tests/Fixtures/GiteaFixture.cs index 657297855ae..9ae13043898 100644 --- a/backend/tests/Designer.Tests/Fixtures/GiteaFixture.cs +++ b/backend/tests/Designer.Tests/Fixtures/GiteaFixture.cs @@ -47,6 +47,8 @@ public Lazy GiteaClient public string OAuthApplicationClientId { get; private set; } public string OAuthApplicationClientSecret { get; private set; } + public string DbConnectionString => _postgreSqlContainer?.GetConnectionString(); + private static AsyncRetryPolicy GiteaClientRetryPolicy => Policy.Handle() .Or() .WaitAndRetryAsync(4, retryAttempt => TimeSpan.FromSeconds(retryAttempt)); @@ -68,6 +70,7 @@ private async Task BuildAndStartPostgreSqlContainerAsync() .WithUsername("gitea") .WithPassword("gitea") .WithDatabase("gitea") + .WithExposedPort(TestUrlsProvider.GetRandomAvailablePort()) .Build(); await _postgreSqlContainer.StartAsync(); } diff --git a/backend/tests/Designer.Tests/GiteaIntegrationTests/GiteaIntegrationTestsBase.cs b/backend/tests/Designer.Tests/GiteaIntegrationTests/GiteaIntegrationTestsBase.cs index e7f526edf49..cd0eb79e10e 100644 --- a/backend/tests/Designer.Tests/GiteaIntegrationTests/GiteaIntegrationTestsBase.cs +++ b/backend/tests/Designer.Tests/GiteaIntegrationTests/GiteaIntegrationTestsBase.cs @@ -143,25 +143,29 @@ protected Stream GenerateGiteaOverrideConfigStream() ""AppLocation"": ""{templateLocation}/App"" }}, ""OidcLoginSettings"": {{ - ""ClientId"": ""{GiteaFixture.OAuthApplicationClientId}"", - ""ClientSecret"": ""{GiteaFixture.OAuthApplicationClientSecret}"", - ""Authority"": ""{TestUrlsProvider.Instance.GiteaUrl}"", - ""Scopes"": [ - ""openid"", - ""profile"", - ""write:activitypub"", - ""write:admin"", - ""write:issue"", - ""write:misc"", - ""write:notification"", - ""write:organization"", - ""write:package"", - ""write:repository"", - ""write:user"" - ], - ""RequireHttpsMetadata"": false, - ""CookieExpiryTimeInMinutes"" : 59 - }} + ""ClientId"": ""{GiteaFixture.OAuthApplicationClientId}"", + ""ClientSecret"": ""{GiteaFixture.OAuthApplicationClientSecret}"", + ""Authority"": ""{TestUrlsProvider.Instance.GiteaUrl}"", + ""Scopes"": [ + ""openid"", + ""profile"", + ""write:activitypub"", + ""write:admin"", + ""write:issue"", + ""write:misc"", + ""write:notification"", + ""write:organization"", + ""write:package"", + ""write:repository"", + ""write:user"" + ], + ""RequireHttpsMetadata"": false, + ""CookieExpiryTimeInMinutes"" : 59 + }}, + ""PostgreSQLSettings"": {{ + ""ConnectionString"": ""{GiteaFixture.DbConnectionString}"", + ""DesignerDbPwd"": """" + }} }} "; var configStream = new MemoryStream(Encoding.UTF8.GetBytes(configOverride)); diff --git a/backend/tests/Designer.Tests/Services/ProcessModelingServiceTests.cs b/backend/tests/Designer.Tests/Services/ProcessModelingServiceTests.cs index 96fd1dd373b..9762c986c34 100644 --- a/backend/tests/Designer.Tests/Services/ProcessModelingServiceTests.cs +++ b/backend/tests/Designer.Tests/Services/ProcessModelingServiceTests.cs @@ -19,7 +19,6 @@ public class ProcessModelingServiceTests : FluentTestsBase(); _altinnGitRepositoryFactory = new AltinnGitRepositoryFactory(TestDataHelper.GetTestDataRepositoriesRootDirectory()); _appDevelopmentService = new AppDevelopmentService(_altinnGitRepositoryFactory, schemaModelServiceMock.Object); - _userRequestsSynchronizationService = new Mock().Object; } [Theory] @@ -36,7 +34,7 @@ public void GetProcessDefinitionTemplates_GivenVersion_ReturnsListOfTemplates(st { SemanticVersion version = SemanticVersion.Parse(versionString); - IProcessModelingService processModelingService = new ProcessModelingService(new Mock().Object, _appDevelopmentService, _userRequestsSynchronizationService); + IProcessModelingService processModelingService = new ProcessModelingService(new Mock().Object, _appDevelopmentService); var result = processModelingService.GetProcessDefinitionTemplates(version).ToList(); @@ -56,7 +54,7 @@ public async Task GetTaskTypeFromProcessDefinition_GivenProcessDefinition_Return CreatedTestRepoPath = await TestDataHelper.CopyRepositoryForTest(org, app, developer, targetRepository); - IProcessModelingService processModelingService = new ProcessModelingService(_altinnGitRepositoryFactory, _appDevelopmentService, _userRequestsSynchronizationService); + IProcessModelingService processModelingService = new ProcessModelingService(_altinnGitRepositoryFactory, _appDevelopmentService); // Act string taskType = await processModelingService.GetTaskTypeFromProcessDefinition(AltinnRepoEditingContext.FromOrgRepoDeveloper(org, targetRepository, developer), "layoutSet1"); diff --git a/backend/tests/Designer.Tests/Services/UserRequestsSynchronizationServiceTests.cs b/backend/tests/Designer.Tests/Services/UserRequestsSynchronizationServiceTests.cs deleted file mode 100644 index 027daacec37..00000000000 --- a/backend/tests/Designer.Tests/Services/UserRequestsSynchronizationServiceTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System.Threading.Tasks; -using Altinn.Studio.Designer.Configuration; -using Altinn.Studio.Designer.Services.Implementation; -using Xunit; - -namespace Designer.Tests.Services -{ - public class UserRequestsSynchronizationServiceTests - { - [Theory] - [InlineData("ttd", "hvem-er-hvem", "testUser")] - public async Task Semaphores_ShouldBeCleanedUpAfterExpiry(string org, string repo, string developer) - { - var settings = new UserRequestSynchronizationSettings - { - MaxDegreeOfParallelism = 1, - SemaphoreExpiryInSeconds = 1, - CleanUpFrequencyInSeconds = 1 - }; - var service = new UserRequestsSynchronizationService(settings); - - var semaphore = service.GetRequestsSemaphore(org, repo, developer); - var semaphore2 = service.GetRequestsSemaphore(org, repo, developer); - Assert.Equal(semaphore2, semaphore); - - // Check if semaphore will expire - await Task.Delay(5000); - var semaphore3 = service.GetRequestsSemaphore(org, repo, developer); - Assert.NotEqual(semaphore3, semaphore); - - } - } -} diff --git a/backend/tests/Designer.Tests/Utils/TestLockPathProvider.cs b/backend/tests/Designer.Tests/Utils/TestLockPathProvider.cs new file mode 100644 index 00000000000..9ddabd87bed --- /dev/null +++ b/backend/tests/Designer.Tests/Utils/TestLockPathProvider.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; +using System.Threading; + +namespace Designer.Tests.Utils; + +public sealed class TestLockPathProvider +{ + private static readonly Lazy _instance = new(() => new TestLockPathProvider(), LazyThreadSafetyMode.ExecutionAndPublication); + + public static TestLockPathProvider Instance => _instance.Value; + + public DirectoryInfo LockFileDirectory { get; } + + private TestLockPathProvider() + { + string path = Path.Combine(Path.GetTempPath(), "altinn", "distributedlocks"); + LockFileDirectory = new DirectoryInfo(path); + if (!LockFileDirectory.Exists) + { + LockFileDirectory.Create(); + } + } + +} From 4c57f4658e9c0cbed28079224d99913b64e4357f Mon Sep 17 00:00:00 2001 From: JamalAlabdullah <90609090+JamalAlabdullah@users.noreply.github.com> Date: Fri, 8 Nov 2024 12:05:49 +0100 Subject: [PATCH 4/6] feat: 13928 add data model binding for subfrom (#13961) Co-authored-by: Jamal Alabdullah --- frontend/language/src/nb.json | 2 + .../CreateNewSubformLayoutSet.test.tsx | 51 ++++++++++++++ .../CreateNewSubformLayoutSet.tsx | 11 ++- .../SubformDataModelSelect.test.tsx | 68 +++++++++++++++++++ .../SubformDataModelSelect.tsx | 48 +++++++++++++ .../CreateNewSubformLayoutSet/index.ts | 1 + .../EditLayoutSetForSubform.test.tsx | 9 +-- .../ux-editor/src/hooks/useCreateSubform.ts | 4 +- 8 files changed, 185 insertions(+), 9 deletions(-) create mode 100644 frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.tsx diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 5509053ca62..3d3da74bec8 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1325,6 +1325,8 @@ "ux_editor.component_properties.subform.create_layout_set_button": "Lag et nytt underskjema", "ux_editor.component_properties.subform.create_layout_set_description": "Hvis du velger å lage et nytt underskjema, oppretter vi et tomt underskjema for deg. Det må du selv utforme, før du kan sette opp tabellen.", "ux_editor.component_properties.subform.created_layout_set_name": "Navn på underskjema", + "ux_editor.component_properties.subform.data_model_binding_label": "Velg datamodellknytning", + "ux_editor.component_properties.subform.data_model_empty_messsage": "Ingen tilgjengelige datamodeller", "ux_editor.component_properties.subform.go_to_layout_set": "Gå til utforming av underskjemaet", "ux_editor.component_properties.subform.layout_set_is_missing_content_heading": "Underskjemaet ditt mangler innhold.", "ux_editor.component_properties.subform.layout_set_is_missing_content_paragraph": "Denne tabellen bruker underskjemaet for å hente feltene og tekstene som skal vises i tabellen. Velg Utform underskjemaet for å legge inn innhold.", diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx index 47bcb935ae0..dc85b96c26d 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.test.tsx @@ -15,6 +15,21 @@ import { AppContext } from '../../../../../../AppContext'; import { appContextMock } from '../../../../../../testing/appContextMock'; const onSubformCreatedMock = jest.fn(); +const selectedOptionDataType = 'moped'; + +jest.mock('./SubformDataModelSelect', () => ({ + SubformDataModelSelect: ({ + selectedDataType, + setSelectedDataType, + }: { + selectedDataType: string | undefined; + setSelectedDataType: (value: string) => void; + }) => ( + + ), +})); describe('CreateNewSubformLayoutSet ', () => { afterEach(jest.clearAllMocks); @@ -34,6 +49,12 @@ describe('CreateNewSubformLayoutSet ', () => { expect(input).toBeInTheDocument(); }); + it('displays the data model select', async () => { + renderCreateNewSubformLayoutSet(); + const dataModelSelect = screen.getByRole('combobox'); + expect(dataModelSelect).toBeInTheDocument(); + }); + it('displays the save button', () => { renderCreateNewSubformLayoutSet(); const saveButton = screen.getByRole('button', { name: textMock('general.close') }); @@ -45,6 +66,8 @@ describe('CreateNewSubformLayoutSet ', () => { renderCreateNewSubformLayoutSet(); const input = screen.getByRole('textbox'); await user.type(input, 'NewSubform'); + const dataModelSelect = screen.getByRole('combobox'); + await user.selectOptions(dataModelSelect, ['moped']); const saveButton = screen.getByRole('button', { name: textMock('general.close') }); await user.click(saveButton); await waitFor(() => expect(onSubformCreatedMock).toHaveBeenCalledTimes(1)); @@ -69,6 +92,34 @@ describe('CreateNewSubformLayoutSet ', () => { await user.clear(input); await user.type(input, 'NewSubform'); + + const dataModelSelect = screen.getByRole('combobox'); + await user.selectOptions(dataModelSelect, ['moped']); + expect(saveButton).not.toBeDisabled(); + }); + + it('disables the save button when the input is valid and no data model is selected', async () => { + const user = userEvent.setup(); + renderCreateNewSubformLayoutSet(); + + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubform'); + + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); + expect(saveButton).toBeDisabled(); + }); + + it('does not disable the save button when the input is valid and a data model is selected', async () => { + const user = userEvent.setup(); + renderCreateNewSubformLayoutSet(); + + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubform'); + + const dataModelSelect = screen.getByRole('combobox'); + await user.selectOptions(dataModelSelect, ['moped']); + + const saveButton = screen.getByRole('button', { name: textMock('general.close') }); expect(saveButton).not.toBeDisabled(); }); }); diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx index fd041cdd149..896e8172cd6 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/CreateNewSubformLayoutSet.tsx @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'; import { StudioButton, StudioCard, StudioTextfield } from '@studio/components'; import { ClipboardIcon, CheckmarkIcon } from '@studio/icons'; import classes from './CreateNewSubformLayoutSet.module.css'; +import { SubformDataModelSelect } from './SubformDataModelSelect'; import { useValidateLayoutSetName } from 'app-shared/hooks/useValidateLayoutSetName'; import { useCreateSubform } from '@altinn/ux-editor/hooks/useCreateSubform'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; @@ -18,6 +19,7 @@ export const CreateNewSubformLayoutSet = ({ }: CreateNewSubformLayoutSetProps): React.ReactElement => { const { t } = useTranslation(); const [newSubform, setNewSubform] = useState(''); + const [selectedDataType, setSelectedDataType] = useState(); const { validateLayoutSetName } = useValidateLayoutSetName(); const { createSubform } = useCreateSubform(); const [nameError, setNameError] = useState(''); @@ -29,7 +31,7 @@ export const CreateNewSubformLayoutSet = ({ } function handleCreateSubform() { - createSubform({ layoutSetName: newSubform, onSubformCreated }); + createSubform({ layoutSetName: newSubform, onSubformCreated, dataType: selectedDataType }); } return ( @@ -45,12 +47,17 @@ export const CreateNewSubformLayoutSet = ({ onChange={handleChange} error={nameError} /> + } onClick={handleCreateSubform} title={t('general.close')} - disabled={!newSubform || !!nameError} + disabled={!newSubform || !!nameError || !selectedDataType} variant='tertiary' color='success' /> diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.test.tsx new file mode 100644 index 00000000000..a898fb739a2 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.test.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import type { ISubformDataModelSelectProps } from './SubformDataModelSelect'; +import { SubformDataModelSelect } from './SubformDataModelSelect'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from 'dashboard/testing/mocks'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { useAppMetadataModelIdsQuery } from 'app-shared/hooks/queries/useAppMetadataModelIdsQuery'; + +jest.mock('app-shared/hooks/queries/useAppMetadataModelIdsQuery'); + +const user = userEvent.setup(); + +const mockDataModelIds = ['dataModelId1', 'dataModelId2']; +const defaultProps: ISubformDataModelSelectProps = { + disabled: false, + selectedDataType: '', + setSelectedDataType: jest.fn(), +}; + +describe('SubformDataModelSelect', () => { + afterEach(jest.clearAllMocks); + + it('renders StudioNativeSelect with its label', () => { + (useAppMetadataModelIdsQuery as jest.Mock).mockReturnValue({ data: mockDataModelIds }); + renderSubformDataModelSelect(); + expect( + screen.getByRole('combobox', { + name: textMock('ux_editor.component_properties.subform.data_model_binding_label'), + }), + ).toBeInTheDocument(); + }); + + it('Renders all options', () => { + (useAppMetadataModelIdsQuery as jest.Mock).mockReturnValue({ data: mockDataModelIds }); + renderSubformDataModelSelect(); + expect(screen.getAllByRole('option')).toHaveLength(mockDataModelIds.length); + }); + + it('Selects provided selected item when there are provided options', () => { + (useAppMetadataModelIdsQuery as jest.Mock).mockReturnValue({ data: mockDataModelIds }); + renderSubformDataModelSelect({ selectedDataType: mockDataModelIds[1] }); + expect(screen.getByRole('combobox')).toHaveValue(mockDataModelIds[1]); + }); + + it('Renders a hidden placeholder option with an empty value', () => { + (useAppMetadataModelIdsQuery as jest.Mock).mockReturnValue({ data: mockDataModelIds }); + renderSubformDataModelSelect(); + const placeholderOption = screen.getByRole('option', { hidden: true, name: '' }); + expect(placeholderOption).toBeInTheDocument(); + expect(placeholderOption).toHaveAttribute('value', ''); + }); + + it('Calls setSelectedDataType when selecting an option', async () => { + (useAppMetadataModelIdsQuery as jest.Mock).mockReturnValue({ data: mockDataModelIds }); + const setSelectedDataType = jest.fn(); + renderSubformDataModelSelect({ setSelectedDataType }); + await user.selectOptions( + screen.getByRole('combobox'), + screen.getByRole('option', { name: mockDataModelIds[1] }), + ); + expect(setSelectedDataType).toHaveBeenCalledTimes(1); + expect(setSelectedDataType).toHaveBeenCalledWith(mockDataModelIds[1]); + }); +}); + +const renderSubformDataModelSelect = (props: Partial = {}) => + renderWithProviders(); diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.tsx new file mode 100644 index 00000000000..e1ed83dfa1b --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/SubformDataModelSelect.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useAppMetadataModelIdsQuery } from 'app-shared/hooks/queries/useAppMetadataModelIdsQuery'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { useTranslation } from 'react-i18next'; +import { StudioNativeSelect } from '@studio/components'; + +export interface ISubformDataModelSelectProps { + disabled: boolean; + selectedDataType: string; + setSelectedDataType: (dataType: string) => void; +} + +export const SubformDataModelSelect = ({ + disabled, + selectedDataType, + setSelectedDataType, +}: ISubformDataModelSelectProps) => { + const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); + const { data: dataModelIds = [] } = useAppMetadataModelIdsQuery(org, app, false); + + function handleChange(dataType: string) { + setSelectedDataType(dataType); + } + + return ( + handleChange(e.target.value)} + value={selectedDataType} + size='small' + > + + {dataModelIds.length === 0 ? ( + + ) : ( + dataModelIds.map((dataModelId) => ( + + )) + )} + + ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts index 39c8808d341..f239c07a245 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformLayoutSet/index.ts @@ -1 +1,2 @@ export { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet'; +export { SubformDataModelSelect } from './SubformDataModelSelect'; diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx index be1e641f48a..5c55c1eb33a 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSetForSubform.test.tsx @@ -134,7 +134,7 @@ describe('EditLayoutSetForSubform', () => { ); }); - it('calls handleComponentChange after creating a new layout set and clicking Lukk button', async () => { + it('calls handleComponentChange after creating a new layout set and selecting data model type, then clicking Lukk button', async () => { const user = userEvent.setup(); const subformLayoutSetId = 'subformLayoutSetId'; renderEditLayoutSetForSubform({ sets: [{ id: subformLayoutSetId, type: 'subform' }] }); @@ -144,14 +144,11 @@ describe('EditLayoutSetForSubform', () => { await user.click(createNewLayoutSetButton); const input = screen.getByRole('textbox'); await user.type(input, 'NewSubform'); + const dataModelSelect = await screen.findAllByRole('combobox'); + await user.selectOptions(dataModelSelect[0], subformLayoutSetId); const saveButton = screen.getByRole('button', { name: textMock('general.close') }); await user.click(saveButton); expect(handleComponentChangeMock).toHaveBeenCalledTimes(1); - expect(handleComponentChangeMock).toHaveBeenCalledWith( - expect.objectContaining({ - layoutSet: 'NewSubform', - }), - ); }); it('closes the view mode when clicking close button after selecting a layout set', async () => { diff --git a/frontend/packages/ux-editor/src/hooks/useCreateSubform.ts b/frontend/packages/ux-editor/src/hooks/useCreateSubform.ts index b96c3ca1aeb..80631609018 100644 --- a/frontend/packages/ux-editor/src/hooks/useCreateSubform.ts +++ b/frontend/packages/ux-editor/src/hooks/useCreateSubform.ts @@ -4,18 +4,20 @@ import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmen type CreateSubformProps = { layoutSetName: string; onSubformCreated: (layoutSetName: string) => void; + dataType?: string; }; export const useCreateSubform = () => { const { org, app } = useStudioEnvironmentParams(); const { mutate: addLayoutSet } = useAddLayoutSetMutation(org, app); - const createSubform = ({ layoutSetName, onSubformCreated }: CreateSubformProps) => { + const createSubform = ({ layoutSetName, onSubformCreated, dataType }: CreateSubformProps) => { addLayoutSet({ layoutSetIdToUpdate: layoutSetName, layoutSetConfig: { id: layoutSetName, type: 'subform', + dataType, }, }); onSubformCreated(layoutSetName); From 39db71857542349d26ac464831a643742b50b79b Mon Sep 17 00:00:00 2001 From: Nina Kylstad Date: Fri, 8 Nov 2024 12:47:49 +0100 Subject: [PATCH 5/6] chore: autogenerate github release each friday (#14011) --- .github/scripts/release.sh | 64 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yaml | 32 +++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 .github/scripts/release.sh create mode 100644 .github/workflows/release.yaml diff --git a/.github/scripts/release.sh b/.github/scripts/release.sh new file mode 100644 index 00000000000..16a5652a2f3 --- /dev/null +++ b/.github/scripts/release.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +set -e +set -u + +DRAFT=true + +while [[ $# -gt 0 ]]; do + case $1 in + --github-token) + GITHUB_TOKEN="$2" + shift # pop option + shift # pop value + ;; + --draft) + DRAFT="$2" + shift # pop option + shift # pop value + ;; + -*|--*) + echo "Unknown option $1" + exit 1 + ;; + *) + echo "Unknown argument $1" + exit 1 + ;; + esac +done + +CURRENT_VERSION=$(git describe --abbrev=0 --tags 2>/dev/null) +CURRENT_VERSION_PARTS=(${CURRENT_VERSION//./ }) +FIRST_PART=${CURRENT_VERSION_PARTS[0]:1} +SECOND_PART=${CURRENT_VERSION_PARTS[1]} +YEAR="$(date +"%Y")" + +echo "Current git tag: $CURRENT_VERSION" +echo "First part: $FIRST_PART" +echo "Second part: $SECOND_PART" +echo "Current year: $YEAR" +echo "-------------------------------------" + + +# Ensure that the version starts from 0 when the year changes +if [[ "$YEAR" == "$FIRST_PART" ]]; then + # Increment the second part of the version by 1 + NEW_VERSION="v${YEAR}.$(($SECOND_PART+1))" +else + # New year - start from 0 + NEW_VERSION="v${YEAR}.0" +fi + +echo "New git tag: $NEW_VERSION" +echo "Draft: $DRAFT" + +# Create the release +curl -L \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GITHUB_TOKEN}" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + https://api.github.com/repos/altinn/altinn-studio/releases \ + -d "{\"tag_name\":\"$NEW_VERSION\",\"name\":\"$NEW_VERSION\",\"draft\":$DRAFT,\"prerelease\":false,\"generate_release_notes\":true}" + diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000000..06bca2418c8 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,32 @@ +name: Generate release for Altinn Studio +on: + schedule: + # run every friday at 14:45 + - cron: '45 14 * * 5' + + workflow_dispatch: + inputs: + draft: + description: 'Create a draft release' + required: true + default: true + type: boolean + +jobs: + generate-release: + name: Run release script + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + path: altinn-studio + fetch-tags: true + fetch-depth: 0 + + - name: Run release script + working-directory: altinn-studio + run: | + bash .github/scripts/release.sh \ + --github-token ${{ secrets.GITHUB_TOKEN }} \ + --draft ${{ github.event.inputs.draft || false }} # cron job will always be a full release From 139ddbdd0ae138a2867b518eb021c36049474200 Mon Sep 17 00:00:00 2001 From: Erling Hauan <148075168+ErlingHauan@users.noreply.github.com> Date: Fri, 8 Nov 2024 13:56:08 +0100 Subject: [PATCH 6/6] feat: Implement StudioCodeListEditor in component edit view (#13922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gørild Døhl --- .../components/RepoList/RepoList.tsx | 2 +- frontend/language/src/nb.json | 18 +- .../shared/src/utils/featureToggleUtils.ts | 3 +- .../Properties/Properties.module.css | 3 +- .../src/components/Properties/Text.tsx | 4 +- .../EditCodeList/EditCodeList.module.css | 9 - .../EditManualOptions/EditManualOptions.tsx | 88 --------- .../EditOptions/EditManualOptions/index.ts | 1 - .../EditOptions/EditOptions.module.css | 30 +-- .../EditOptions/EditOptions.test.tsx | 6 +- .../editModal/EditOptions/EditOptions.tsx | 84 ++------- .../EditCodeList/EditCodeList.module.css | 3 + .../EditCodeList/EditCodeList.test.tsx | 4 +- .../EditCodeList/EditCodeList.tsx | 9 +- .../EditCodeList/EditCodeListReference.tsx | 4 +- .../EditCodelistReference.test.tsx | 4 +- .../EditCodeList/findFileNameError.test.ts | 0 .../EditCodeList/findFileNameError.ts | 0 .../{ => OptionTabs}/EditCodeList/index.ts | 0 .../EditManualOptions.test.tsx | 6 +- .../EditManualOptions/EditManualOptions.tsx | 77 ++++++++ .../EditOption/EditOption.module.css | 0 .../EditOption/EditOption.test.tsx | 8 +- .../EditOption/EditOption.tsx | 8 +- .../OptionValue/OptionValue.module.css | 0 .../EditOption/OptionValue/OptionValue.tsx | 4 +- .../EditOption/OptionValue/index.ts | 0 .../EditManualOptions}/EditOption/index.ts | 0 .../EditOption/utils.test.ts | 0 .../EditManualOptions}/EditOption/utils.ts | 0 .../OptionTabs/EditManualOptions/index.ts | 2 + .../EditManualOptionsWithEditor.test.tsx | 176 ++++++++++++++++++ .../EditManualOptionsWithEditor.tsx | 50 +++++ .../EditManualOptionsWithEditor/index.ts | 1 + .../EditOptions/OptionTabs/OptionTabs.tsx | 127 +++++++++++++ .../EditOptions/OptionTabs/hooks/index.ts | 2 + .../hooks/useCodeListButtonValue.ts | 14 ++ .../hooks/useCodeListEditorTexts.ts | 29 +++ .../editModal/EditOptions/OptionTabs/index.ts | 1 + .../EditTextResourceBindings.module.css | 2 +- .../EditTextResourceBindings.tsx | 1 - 41 files changed, 559 insertions(+), 221 deletions(-) delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.module.css delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/EditManualOptions.tsx delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.module.css rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditCodeList/EditCodeList.test.tsx (98%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditCodeList/EditCodeList.tsx (94%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditCodeList/EditCodeListReference.tsx (90%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditCodeList/EditCodelistReference.test.tsx (94%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditCodeList/findFileNameError.test.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditCodeList/findFileNameError.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditCodeList/index.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/{ => OptionTabs}/EditManualOptions/EditManualOptions.test.tsx (96%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.tsx rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/EditOption.module.css (100%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/EditOption.test.tsx (96%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/EditOption.tsx (92%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/OptionValue/OptionValue.module.css (100%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/OptionValue/OptionValue.tsx (88%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/OptionValue/index.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/index.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/utils.test.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/{ => EditOptions/OptionTabs/EditManualOptions}/EditOption/utils.ts (100%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListButtonValue.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListEditorTexts.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/index.ts diff --git a/frontend/dashboard/components/RepoList/RepoList.tsx b/frontend/dashboard/components/RepoList/RepoList.tsx index e083a1e5f94..a1ef29772b7 100644 --- a/frontend/dashboard/components/RepoList/RepoList.tsx +++ b/frontend/dashboard/components/RepoList/RepoList.tsx @@ -69,7 +69,7 @@ export const RepoList = ({ }, { accessor: 'description', - heading: t('dashboard.description'), + heading: t('general.description'), sortable: true, }, { diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 3d3da74bec8..3f13fd20ca5 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -121,7 +121,6 @@ "dashboard.created_by": "Opprettet av", "dashboard.creating_your_service": "Oppretter appen din", "dashboard.data_models": "Datamodeller", - "dashboard.description": "Beskrivelse", "dashboard.edit_app": "Endre {{appName}} i Studio", "dashboard.error_getting_organization_data.message": "Det oppsto en feil da vi skulle hente de organisasjonene som trengs for å kjøre appen.", "dashboard.error_getting_organization_data.title": "Kunne ikke laste inn organisasjoner", @@ -273,6 +272,7 @@ "general.date_time_format": "{{date}} kl. {{time}}", "general.delete": "Slett", "general.delete_item": "Slett {{item}}", + "general.description": "Beskrivelse", "general.edit": "Endre", "general.empty_string": "Tom tekst", "general.error_message": "Det har oppstått en feil. Hvis problemet fortsetter, ta kontakt med oss.", @@ -288,6 +288,7 @@ "general.loading": "Laster...", "general.next": "Neste", "general.no_options": "Ingen alternativer tilgjengelige", + "general.option": "Alternativ", "general.options": "Alternativer", "general.page": "Side", "general.page_error_message": "Vi vet ikke helt hva, men ta kontakt med oss, så graver vi i det sammen.", @@ -1492,9 +1493,17 @@ "ux_editor.modal_header_type_helper": "Velg titteltype", "ux_editor.modal_new_option": "Legg til alternativ", "ux_editor.modal_properties_add_radio_button_options": "Hvordan vil du legge til radioknapper?", + "ux_editor.modal_properties_code_list_custom_list": "Egendefinert kodeliste", + "ux_editor.modal_properties_code_list_delete_item": "Slett alternativ {{number}}", + "ux_editor.modal_properties_code_list_empty": "Kodelisten er tom.", "ux_editor.modal_properties_code_list_filename_error": "Filnavnet er ugyldig. Du kan bruke tall, understrek, punktum, bindestrek, og store/små bokstaver fra det norske alfabetet. Filnavnet må starte med en engelsk bokstav.", + "ux_editor.modal_properties_code_list_general_error": "Kan ikke lagre kodelisten, den inneholder feil.", "ux_editor.modal_properties_code_list_helper": "Velg kodeliste", "ux_editor.modal_properties_code_list_id": "Kodeliste-ID", + "ux_editor.modal_properties_code_list_item_description": "Beskrivelse for alternativ {{number}}", + "ux_editor.modal_properties_code_list_item_helpText": "Hjelpetekst for alternativ {{number}}", + "ux_editor.modal_properties_code_list_item_label": "Ledetekst for alternativ {{number}}", + "ux_editor.modal_properties_code_list_item_value": "Verdi for alternativ {{number}}", "ux_editor.modal_properties_code_list_read_more": "<0 href=\"{{optionsDocs}}\" >Les mer om kodelister", "ux_editor.modal_properties_code_list_read_more_dynamic": "<0 href=\"{{optionsDocs}}\" >Les mer om dynamiske kodelister", "ux_editor.modal_properties_code_list_read_more_static": "<0 href=\"{{optionsDocs}}\" >Les mer om statiske kodelister", @@ -1648,7 +1657,7 @@ "ux_editor.options.codelist_create_info.step4": "Skriv inn kodelisten i tekstfeltet midt på siden. Kodelisten må være i JSON-format.", "ux_editor.options.codelist_create_info.step5": "Velg \"Commit endringer\".", "ux_editor.options.codelist_create_info.step6": "Du er nå ferdig i Gitea for denne gang. Gå tilbake til Altinn Studio-fanen, eller klikk på Altinn-logoen øverst til venstre i Gitea for å komme tilbake til Altinn Studio.", - "ux_editor.options.codelist_only": "Denne komponenten støtter kun oppsett med kodelister.", + "ux_editor.options.codelist_only": "Denne komponenten støtter bare oppsett med forhåndsdefinerte kodelister.", "ux_editor.options.codelist_referenceId.description": "Her kan du legge til en referanse-ID til en dynamisk kodeliste som er satt opp i koden.", "ux_editor.options.codelist_referenceId.description_details": "Du bruker dynamiske kodelister for å tilpasse alternativer for brukerne. Det kan for eksempel være tilpasninger ut fra geografisk plassering, eller valg brukeren gjør tidligere i skjemaet.", "ux_editor.options.codelist_upload_info.heading": "Steg for å laste opp kodelister manuelt", @@ -1657,13 +1666,12 @@ "ux_editor.options.codelist_upload_info.step3": "Filen må ligge i mappen \"App/options\". Sørg for at den blir plassert der ved å oppgi denne stien i opplastingsfeltet. Når du skriver \"App/options/\", blir feltet automatisk oppdatert med mappesti.", "ux_editor.options.codelist_upload_info.step4": "Velg \"Commit endringer\".", "ux_editor.options.codelist_upload_info.step5": "Du er nå ferdig i Gitea for denne gang. Gå tilbake til Altinn Studio-fanen, eller klikk på Altinn-logoen øverst til venstre i Gitea for å komme tilbake til Altinn Studio.", + "ux_editor.options.multiple": "{{value}} alternativer", "ux_editor.options.section_heading": "Valg for kodelister", + "ux_editor.options.single": "{{value}} alternativ", "ux_editor.options.tab_codelist": "Velg kodeliste", "ux_editor.options.tab_manual": "Sett opp egne alternativer", "ux_editor.options.tab_referenceId": "Angi referanse-ID", - "ux_editor.options_text_description": "Beskrivelse", - "ux_editor.options_text_help_text": "Hjelpetekst", - "ux_editor.options_text_label": "Ledetekst", "ux_editor.page": "Side", "ux_editor.page_config_pdf_abort_converting_page_to_pdf": "Avbryt å gjøre om siden til PDF", "ux_editor.page_config_pdf_card_heading": "Siden skal være en PDF", diff --git a/frontend/packages/shared/src/utils/featureToggleUtils.ts b/frontend/packages/shared/src/utils/featureToggleUtils.ts index 476f2f64936..2410fb688db 100644 --- a/frontend/packages/shared/src/utils/featureToggleUtils.ts +++ b/frontend/packages/shared/src/utils/featureToggleUtils.ts @@ -12,7 +12,8 @@ export type SupportedFeatureFlags = | 'exportForm' | 'addComponentModal' | 'subform' - | 'summary2'; + | 'summary2' + | 'codeListEditor'; /* * Please add all the features that you want to be toggle on by default here. diff --git a/frontend/packages/ux-editor/src/components/Properties/Properties.module.css b/frontend/packages/ux-editor/src/components/Properties/Properties.module.css index dfcca284b18..da219e9fdd2 100644 --- a/frontend/packages/ux-editor/src/components/Properties/Properties.module.css +++ b/frontend/packages/ux-editor/src/components/Properties/Properties.module.css @@ -10,5 +10,6 @@ .texts { background-color: var(--fds-semantic-surface-neutral-default); - padding: var(--fds-spacing-5) 0; + padding-block: var(--fds-spacing-5) 0; + padding-inline: 0; } diff --git a/frontend/packages/ux-editor/src/components/Properties/Text.tsx b/frontend/packages/ux-editor/src/components/Properties/Text.tsx index 3b3d9a87368..7900772a841 100644 --- a/frontend/packages/ux-editor/src/components/Properties/Text.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/Text.tsx @@ -50,7 +50,6 @@ export const Text = () => { component={form} handleComponentChange={handleComponentChange} textResourceBindingKeys={Object.keys(schema.properties.textResourceBindings.properties)} - editFormId={formId} layoutName={selectedFormLayoutName} /> )} @@ -64,10 +63,9 @@ export const Text = () => { ComponentSpecificConfig) } handleComponentChange={handleComponentChange} - editFormId={formId} layoutName={selectedFormLayoutName} renderOptions={{ - onlyCodeListOptions: schema.properties.optionsId && !schema.properties.options, + areLayoutOptionsSupported: schema.properties.optionsId! && schema.properties.options, }} /> )} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.module.css deleted file mode 100644 index 309c7dd3efd..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.module.css +++ /dev/null @@ -1,9 +0,0 @@ -.studioFileUploader { - padding-top: var(--fds-spacing-2); - padding-bottom: var(--fds-spacing-1); -} - -.linkStaticCodeLists { - margin-bottom: 0; - padding-top: var(--fds-spacing-2); -} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/EditManualOptions.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/EditManualOptions.tsx deleted file mode 100644 index 9bd3ce9b2fa..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/EditManualOptions.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useMemo } from 'react'; -import { ErrorMessage } from '@digdir/designsystemet-react'; -import classes from '../EditOptions.module.css'; -import type { IGenericEditComponent } from '../../../componentConfig'; -import { useComponentErrorMessage } from '../../../../../hooks'; -import { addOptionToComponent, generateRandomOption } from '../../../../../utils/component'; -import { StudioProperty } from '@studio/components'; -import type { SelectionComponentType } from '../../../../../types/FormComponent'; -import { EditOption } from '../../EditOption'; -import { ArrayUtils } from '@studio/pure-functions'; -import type { Option } from 'app-shared/types/Option'; -import { useTranslation } from 'react-i18next'; - -export function EditManualOptions({ - component, - handleComponentChange, -}: IGenericEditComponent) { - const { t } = useTranslation(); - - const mappedOptionIds = useMemo( - () => component.options?.map((_, index) => `option_${index}`), - [component.options], - ); - - const errorMessage = useComponentErrorMessage(component); - - const handleOptionsChange = (options: Option[]) => { - handleComponentChange({ - ...component, - options, - }); - }; - - const handleOptionChange = (index: number) => (newOption: Option) => { - const newOptions = ArrayUtils.replaceByIndex(component.options || [], index, newOption); - return handleOptionsChange(newOptions); - }; - - const handleRemoveOption = (index: number) => { - const options = [...(component.options || [])]; - options.splice(index, 1); - handleOptionsChange(options); - }; - - const handleAddOption = () => { - if (component.optionsId) { - delete component.optionsId; - } - - handleComponentChange(addOptionToComponent(component, generateRandomOption())); - }; - - return ( - <> - - {component.options?.map((option, index) => { - const removeItem = () => handleRemoveOption(index); - const key = mappedOptionIds[index]; - const optionNumber = index + 1; - const legend = - component.type === 'RadioButtons' - ? t('ux_editor.radios_option', { optionNumber }) - : t('ux_editor.checkboxes_option', { optionNumber }); - return ( - - ); - })} - !label)} - onClick={handleAddOption} - property={t('ux_editor.modal_new_option')} - /> - - - {errorMessage && ( - - {errorMessage} - - )} - - ); -} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/index.ts deleted file mode 100644 index 1d7722683ca..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EditManualOptions } from './EditManualOptions'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.module.css index 4cedbe1a3d0..a719b57e128 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.module.css @@ -4,26 +4,34 @@ padding: var(--fds-spacing-5) 0 0; } -.codeListSwitchWrapper { - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; - margin: var(--fds-spacing-5); +.optionsHeading { + padding-left: var(--fds-spacing-5); } .errorMessage { margin: var(--fds-spacing-5) var(--fds-spacing-5) 0; } +.codelistTabContent { + padding: var(--fds-spacing-5); + display: flex; + flex-direction: column; + gap: var(--fds-spacing-2); +} + .manualTabContent { - padding: var(--fds-spacing-5) 0; + padding-block: var(--fds-spacing-5); + padding-inline: 0; } -.codelistTabContent { - padding: var(--fds-spacing-4); +.manualTabAlert { + margin-inline: var(--fds-spacing-5); } -.optionsHeading { - padding-left: var(--fds-spacing-5); +.manualTabDialog[open] { + --code-list-modal-min-width: min(80rem, 100%); + --code-list-modal-height: min(40rem, 100%); + + min-width: var(--code-list-modal-min-width); + height: var(--code-list-modal-height); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx index d4f4f5d6860..b33cea061df 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx @@ -34,7 +34,7 @@ const renderEditOptions = async void; queries?: Partial; renderOptions?: { - onlyCodeListOptions?: boolean; + areLayoutOptionsSupported?: boolean; }; } = {}) => { const component = { @@ -204,11 +204,11 @@ describe('EditOptions', () => { ).toBeInTheDocument(); }); - it('should show alert message in Manual tab when prop onlyCodeListOptions is true', async () => { + it('should show alert message in Manual tab when prop areLayoutOptionsSupported is false', async () => { const user = userEvent.setup(); await renderEditOptions({ componentProps: { optionsId: '' }, - renderOptions: { onlyCodeListOptions: true }, + renderOptions: { areLayoutOptionsSupported: false }, queries: { getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve([])), }, diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx index 1481f9de604..419c19faa77 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx @@ -1,21 +1,18 @@ -import React, { useEffect, useRef } from 'react'; -import { ErrorMessage, Heading, Alert } from '@digdir/designsystemet-react'; +import React from 'react'; +import { ErrorMessage, Heading } from '@digdir/designsystemet-react'; import classes from './EditOptions.module.css'; import type { IGenericEditComponent } from '../../componentConfig'; -import { EditCodeList, EditCodeListReference } from './EditCodeList'; -import { getSelectedOptionsType } from '../../../../utils/optionsUtils'; import { useOptionListIdsQuery } from '../../../../hooks/queries/useOptionListIdsQuery'; - -import { StudioSpinner, StudioTabs } from '@studio/components'; +import { StudioSpinner } from '@studio/components'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { useTranslation } from 'react-i18next'; -import { EditManualOptions } from './EditManualOptions/EditManualOptions'; import type { SelectionComponentType } from '../../../../types/FormComponent'; +import { OptionTabs } from '@altinn/ux-editor/components/config/editModal/EditOptions/OptionTabs/OptionTabs'; export interface ISelectionEditComponentProvidedProps extends IGenericEditComponent { renderOptions?: { - onlyCodeListOptions?: boolean; + areLayoutOptionsSupported?: boolean; }; } @@ -27,33 +24,14 @@ export enum SelectedOptionsType { } export function EditOptions({ - editFormId, component, handleComponentChange, renderOptions, }: ISelectionEditComponentProvidedProps) { - const previousEditFormId = useRef(editFormId); const { org, app } = useStudioEnvironmentParams(); const { data: optionListIds, isPending, isError, error } = useOptionListIdsQuery(org, app); - const [initialSelectedOptionType, setInitialSelectedOptionType] = - React.useState( - getSelectedOptionsType(component.optionsId, component.options, optionListIds || []), - ); const { t } = useTranslation(); - useEffect(() => { - if (editFormId !== previousEditFormId.current) { - previousEditFormId.current = editFormId; - } - }, [editFormId]); - - useEffect(() => { - if (!optionListIds) return; - setInitialSelectedOptionType( - getSelectedOptionsType(component.optionsId, component.options, optionListIds), - ); - }, [optionListIds, component.optionsId, component.options, setInitialSelectedOptionType]); - return (
@@ -65,54 +43,16 @@ export function EditOptions({ spinnerTitle={t('ux_editor.modal_properties_loading')} /> ) : isError ? ( - + {error instanceof Error ? error.message : t('ux_editor.modal_properties_error_message')} ) : ( - { - setInitialSelectedOptionType(value as SelectedOptionsType); - }} - > - - - {t('ux_editor.options.tab_codelist')} - - - {t('ux_editor.options.tab_manual')} - - - {t('ux_editor.options.tab_referenceId')} - - - - - - - {renderOptions.onlyCodeListOptions ? ( - {t('ux_editor.options.codelist_only')} - ) : ( - - )} - - - - - + )}
); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.module.css new file mode 100644 index 00000000000..1090d83be58 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.module.css @@ -0,0 +1,3 @@ +.linkStaticCodeLists { + padding-top: var(--fds-spacing-2); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.test.tsx similarity index 98% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.test.tsx index ddf8d29494e..8b619303179 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.test.tsx @@ -2,11 +2,11 @@ import React from 'react'; import { EditCodeList } from './EditCodeList'; import { screen, waitFor } from '@testing-library/react'; import { ComponentType } from 'app-shared/types/ComponentType'; -import { renderWithProviders, optionListIdsMock } from '../../../../../testing/mocks'; +import { renderWithProviders, optionListIdsMock } from '../../../../../../testing/mocks'; import userEvent from '@testing-library/user-event'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; -import type { FormComponent } from '../../../../../types/FormComponent'; +import type { FormComponent } from '../../../../../../types/FormComponent'; const mockComponent: FormComponent = { id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96', diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.tsx similarity index 94% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.tsx index 59a7fc8bcc6..3b9d925f05f 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodeList.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodeList.tsx @@ -1,14 +1,14 @@ import React from 'react'; import { ErrorMessage } from '@digdir/designsystemet-react'; -import type { IGenericEditComponent } from '../../../componentConfig'; -import { useOptionListIdsQuery } from '../../../../../hooks/queries/useOptionListIdsQuery'; +import type { IGenericEditComponent } from '../../../../componentConfig'; +import { useOptionListIdsQuery } from '../../../../../../hooks/queries/useOptionListIdsQuery'; import { useAddOptionListMutation } from 'app-shared/hooks/mutations'; import { useTranslation, Trans } from 'react-i18next'; import { StudioFileUploader, StudioNativeSelect, StudioSpinner } from '@studio/components'; import { altinnDocsUrl } from 'app-shared/ext-urls'; -import { FormField } from '../../../../FormField'; +import { FormField } from '../../../../../FormField'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; -import type { SelectionComponentType } from '../../../../../types/FormComponent'; +import type { SelectionComponentType } from '../../../../../../types/FormComponent'; import { removeExtension } from 'app-shared/utils/filenameUtils'; import { findFileNameError } from './findFileNameError'; import type { FileNameError } from './findFileNameError'; @@ -75,7 +75,6 @@ export function EditCodeList({ <> ({ component, diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodelistReference.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodelistReference.test.tsx similarity index 94% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodelistReference.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodelistReference.test.tsx index af5d8ba32ff..80f047e5542 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/EditCodelistReference.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/EditCodelistReference.test.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { EditCodeListReference } from './EditCodeListReference'; -import { renderWithProviders } from '../../../../../testing/mocks'; +import { renderWithProviders } from '../../../../../../testing/mocks'; import { ComponentType } from 'app-shared/types/ComponentType'; -import type { FormComponent } from '../../../../../types/FormComponent'; +import type { FormComponent } from '../../../../../../types/FormComponent'; import { textMock } from '@studio/testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/findFileNameError.test.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/findFileNameError.test.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/findFileNameError.test.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/findFileNameError.test.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/findFileNameError.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/findFileNameError.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/findFileNameError.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/findFileNameError.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditCodeList/index.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditCodeList/index.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/EditManualOptions.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.test.tsx similarity index 96% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/EditManualOptions.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.test.tsx index 5217e274d36..4e84a057ade 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditManualOptions/EditManualOptions.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.test.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { screen } from '@testing-library/react'; import { EditManualOptions } from './EditManualOptions'; -import { renderWithProviders } from '../../../../../testing/mocks'; +import { renderWithProviders } from '../../../../../../testing/mocks'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { ComponentType } from 'app-shared/types/ComponentType'; -import type { FormItem } from '../../../../../types/FormItem'; +import type { FormItem } from '../../../../../../types/FormItem'; import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; -import type { FormComponent } from '../../../../../types/FormComponent'; +import type { FormComponent } from '../../../../../../types/FormComponent'; import userEvent from '@testing-library/user-event'; const mockComponent: FormComponent = { diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.tsx new file mode 100644 index 00000000000..cc9d1222172 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.tsx @@ -0,0 +1,77 @@ +import React, { useMemo } from 'react'; +import type { IGenericEditComponent } from '../../../../componentConfig'; +import { addOptionToComponent, generateRandomOption } from '../../../../../../utils/component'; +import { StudioProperty } from '@studio/components'; +import type { SelectionComponentType } from '../../../../../../types/FormComponent'; +import { EditOption } from './EditOption'; +import { ArrayUtils } from '@studio/pure-functions'; +import type { Option } from 'app-shared/types/Option'; +import { useTranslation } from 'react-i18next'; + +export type EditManualOptionsProps = Pick< + IGenericEditComponent, + 'component' | 'handleComponentChange' +>; + +export function EditManualOptions({ component, handleComponentChange }: EditManualOptionsProps) { + const { t } = useTranslation(); + + const mappedOptionIds = useMemo( + () => component.options?.map((_, index) => `option_${index}`), + [component.options], + ); + + const handleOptionsChange = (options: Option[]) => { + handleComponentChange({ + ...component, + options, + }); + }; + + const handleOptionChange = (index: number) => (newOption: Option) => { + const newOptions = ArrayUtils.replaceByIndex(component.options || [], index, newOption); + return handleOptionsChange(newOptions); + }; + + const handleRemoveOption = (index: number) => { + const options = [...(component.options || [])]; + options.splice(index, 1); + handleOptionsChange(options); + }; + + const handleAddOption = () => { + if (component.optionsId) { + delete component.optionsId; + } + + handleComponentChange(addOptionToComponent(component, generateRandomOption())); + }; + + return ( + + {component.options?.map((option, index) => { + const removeItem = () => handleRemoveOption(index); + const key = mappedOptionIds[index]; + const optionNumber = index + 1; + const legend = + component.type === 'RadioButtons' + ? t('ux_editor.radios_option', { optionNumber }) + : t('ux_editor.checkboxes_option', { optionNumber }); + return ( + + ); + })} + !label)} + onClick={handleAddOption} + property={t('ux_editor.modal_new_option')} + /> + + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.module.css similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.module.css rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.module.css diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.test.tsx similarity index 96% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.test.tsx index fb5f00154ac..f744113cd75 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { renderWithProviders } from '../../../../testing/mocks'; +import { renderWithProviders } from '../../../../../../../testing/mocks'; import type { EditOptionProps } from './EditOption'; import { EditOption } from './EditOption'; import { screen, within } from '@testing-library/react'; @@ -91,9 +91,9 @@ describe('EditOption', () => { }); const textResourceLabels: KeyValuePairs = { - label: textMock('ux_editor.options_text_label'), - description: textMock('ux_editor.options_text_description'), - helpText: textMock('ux_editor.options_text_help_text'), + label: textMock('ux_editor.modal_properties_textResourceBindings_title'), + description: textMock('general.description'), + helpText: textMock('ux_editor.modal_properties_textResourceBindings_help'), }; it.each(Object.keys(textResourceLabels))( diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.tsx similarity index 92% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.tsx index c4f26e0c7ed..3cd712933c1 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/EditOption.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.tsx @@ -8,7 +8,7 @@ import { import { useTranslation } from 'react-i18next'; import type { Option } from 'app-shared/types/Option'; import { XMarkIcon } from '@studio/icons'; -import { TextResource } from '../../../TextResource/TextResource'; +import { TextResource } from '../../../../../../TextResource/TextResource'; import { deleteDescription, deleteHelpText, @@ -83,7 +83,7 @@ const OpenOption = ({ legend, onChange, option, onDelete, onClose }: OpenOptionP @@ -91,14 +91,14 @@ const OpenOption = ({ legend, onChange, option, onDelete, onClose }: OpenOptionP compact handleIdChange={handleDescriptionChange} handleRemoveTextResource={handleDeleteDescription} - label={t('ux_editor.options_text_description')} + label={t('general.description')} textResourceId={option.description} /> diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/OptionValue/OptionValue.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.module.css similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/OptionValue/OptionValue.module.css rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.module.css diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/OptionValue/OptionValue.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.tsx similarity index 88% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/OptionValue/OptionValue.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.tsx index fbe8bc6b403..afc10cf3feb 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/OptionValue/OptionValue.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { Option } from 'app-shared/types/Option'; -import { useTextResourcesSelector } from '../../../../../hooks'; -import { textResourceByLanguageAndIdSelector } from '../../../../../selectors/textResourceSelectors'; +import { useTextResourcesSelector } from '../../../../../../../../hooks'; +import { textResourceByLanguageAndIdSelector } from '../../../../../../../../selectors/textResourceSelectors'; import { DEFAULT_LANGUAGE } from 'app-shared/constants'; import { StudioCodeFragment } from '@studio/components'; import type { ITextResource } from 'app-shared/types/global'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/OptionValue/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/OptionValue/index.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/index.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/index.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/index.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/utils.test.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.test.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/utils.test.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.test.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOption/utils.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOption/utils.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts new file mode 100644 index 00000000000..5c20c260147 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts @@ -0,0 +1,2 @@ +export { EditManualOptions } from './EditManualOptions'; +export type { EditManualOptionsProps } from './EditManualOptions'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx new file mode 100644 index 00000000000..6ff9f1af430 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { EditManualOptionsWithEditor } from './EditManualOptionsWithEditor'; +import { renderWithProviders } from '../../../../../../testing/mocks'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import type { FormItem } from '../../../../../../types/FormItem'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import type { FormComponent } from '../../../../../../types/FormComponent'; +import userEvent from '@testing-library/user-event'; + +const mockComponent: FormComponent = { + id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96', + type: ComponentType.RadioButtons, + textResourceBindings: { + title: 'ServiceName', + }, + maxLength: 10, + itemType: 'COMPONENT', + dataModelBindings: { simpleBinding: '' }, +}; + +const renderEditManualOptionsWithEditor = < + T extends ComponentType.Checkboxes | ComponentType.RadioButtons, +>({ + componentProps, + handleComponentChange = jest.fn(), +}: { + componentProps?: Partial>; + handleComponentChange?: () => void; + queries?: Partial; +} = {}) => { + const component = { + ...mockComponent, + ...componentProps, + }; + renderWithProviders( + , + ); +}; + +describe('EditManualOptionsWithEditor', () => { + it('should display a button when no code list is defined in the layout', () => { + renderEditManualOptionsWithEditor(); + + const modalButton = screen.getByRole('button', { + name: textMock('ux_editor.modal_properties_code_list_custom_list'), + }); + + expect(modalButton).toBeInTheDocument(); + }); + + it('should display a button when a code list is defined in the layout', () => { + renderEditManualOptionsWithEditor({ + componentProps: { + options: [{ label: 'option1', value: 'option1' }], + }, + }); + + const modalButton = screen.getByRole('button', { + name: textMock('ux_editor.modal_properties_code_list_custom_list'), + }); + + expect(modalButton).toBeInTheDocument(); + }); + + it('should not display how many options have been defined, when no options are defined', () => { + renderEditManualOptionsWithEditor(); + + const optionText = screen.queryByText(textMock('ux_editor.options.single', { value: 1 })); + const optionsText = screen.queryByText(textMock('ux_editor.options.multiple', { value: 2 })); + + expect(optionText).not.toBeInTheDocument(); + expect(optionsText).not.toBeInTheDocument(); + }); + + it('should display how many options have been defined, when a single option is defined', () => { + renderEditManualOptionsWithEditor({ + componentProps: { + options: [{ label: 'option1', value: 'option1' }], + }, + }); + + const optionText = screen.getByText(textMock('ux_editor.options.single', { value: 1 })); + const optionsText = screen.queryByText(textMock('ux_editor.options.multiple', { value: 2 })); + + expect(optionText).toBeInTheDocument(); + expect(optionsText).not.toBeInTheDocument(); + }); + + it('should display how many options have been defined, when multiple options are defined', () => { + renderEditManualOptionsWithEditor({ + componentProps: { + options: [ + { label: 'option1', value: 'option1' }, + { label: 'option2', value: 'option2' }, + ], + }, + }); + + const optionText = screen.queryByText(textMock('ux_editor.options.single', { value: 1 })); + const optionsText = screen.getByText(textMock('ux_editor.options.multiple', { value: 2 })); + + expect(optionText).not.toBeInTheDocument(); + expect(optionsText).toBeInTheDocument(); + }); + + it('should open a modal when the trigger button is clicked', async () => { + const user = userEvent.setup(); + renderEditManualOptionsWithEditor(); + + const modalButton = screen.getByRole('button', { + name: textMock('ux_editor.modal_properties_code_list_custom_list'), + }); + + await user.click(modalButton); + + const modalDialog = screen.getByRole('dialog'); + + expect(modalDialog).toBeInTheDocument(); + }); + + it('should call handleComponentChange when there has been a change in the editor', async () => { + const mockHandleComponentChange = jest.fn(); + const user = userEvent.setup(); + renderEditManualOptionsWithEditor({ handleComponentChange: mockHandleComponentChange }); + + const modalButton = screen.getByRole('button', { + name: textMock('ux_editor.modal_properties_code_list_custom_list'), + }); + + await user.click(modalButton); + + const addNewButton = screen.getByRole('button', { + name: textMock('ux_editor.modal_new_option'), + }); + + await user.click(addNewButton); + + expect(mockHandleComponentChange).toHaveBeenCalledWith({ + ...mockComponent, + options: [{ label: '', value: '' }], + }); + }); + + it('should delete optionsId from the layout when using the manual editor', async () => { + const user = userEvent.setup(); + const mockHandleComponentChange = jest.fn(); + renderEditManualOptionsWithEditor({ + componentProps: { + optionsId: 'somePredefinedOptionsList', + }, + handleComponentChange: mockHandleComponentChange, + }); + + const modalButton = screen.getByRole('button', { + name: textMock('ux_editor.modal_properties_code_list_custom_list'), + }); + + await user.click(modalButton); + + const addNewButton = screen.getByRole('button', { + name: textMock('ux_editor.modal_new_option'), + }); + + await user.click(addNewButton); + + expect(mockHandleComponentChange).toHaveBeenCalledWith({ + ...mockComponent, // does not contain optionsId + options: [{ label: '', value: '' }], + }); + }); +}); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx new file mode 100644 index 00000000000..376385d2760 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx @@ -0,0 +1,50 @@ +import React, { useRef } from 'react'; +import classes from '../../EditOptions.module.css'; +import { StudioCodeListEditor, StudioModal, StudioProperty } from '@studio/components'; +import type { Option } from 'app-shared/types/Option'; +import { useTranslation } from 'react-i18next'; +import { useCodeListButtonValue, useCodeListEditorTexts } from '../hooks'; +import type { EditManualOptionsProps } from '../EditManualOptions'; + +export function EditManualOptionsWithEditor({ + component, + handleComponentChange, +}: EditManualOptionsProps) { + const { t } = useTranslation(); + const manualOptionsModalRef = useRef(null); + const buttonValue = useCodeListButtonValue(component.options); + const editorTexts = useCodeListEditorTexts(); + + const handleOptionsChange = (options: Option[]) => { + if (component.optionsId) { + delete component.optionsId; + } + + handleComponentChange({ + ...component, + options, + }); + }; + + return ( + <> + manualOptionsModalRef.current.showModal()} + property={t('ux_editor.modal_properties_code_list_custom_list')} + value={buttonValue} + /> + + handleOptionsChange(codeList)} + texts={editorTexts} + /> + + + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts new file mode 100644 index 00000000000..118cc12b2bc --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts @@ -0,0 +1 @@ +export { EditManualOptionsWithEditor } from './EditManualOptionsWithEditor'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx new file mode 100644 index 00000000000..feaa8a857fa --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx @@ -0,0 +1,127 @@ +import { getSelectedOptionsType } from '@altinn/ux-editor/utils/optionsUtils'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import classes from '@altinn/ux-editor/components/config/editModal/EditOptions/EditOptions.module.css'; +import { EditCodeList, EditCodeListReference } from './EditCodeList'; +import { SelectedOptionsType } from '@altinn/ux-editor/components/config/editModal/EditOptions/EditOptions'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { EditManualOptionsWithEditor } from './EditManualOptionsWithEditor'; +import { EditManualOptions } from './EditManualOptions'; +import { StudioTabs, StudioAlert, StudioErrorMessage } from '@studio/components'; +import { useComponentErrorMessage } from '@altinn/ux-editor/hooks'; +import type { IGenericEditComponent } from '@altinn/ux-editor/components/config/componentConfig'; +import type { SelectionComponentType } from '@altinn/ux-editor/types/FormComponent'; + +type OptionTabsProps = { + optionListIds: string[]; + renderOptions?: { + areLayoutOptionsSupported?: boolean; + }; +} & Pick, 'component' | 'handleComponentChange'>; + +export const OptionTabs = ({ + component, + handleComponentChange, + optionListIds, + renderOptions, +}: OptionTabsProps) => { + const initialSelectedOptionsType = getSelectedOptionsType( + component.optionsId, + component.options, + optionListIds || [], + ); + const [selectedOptionsType, setSelectedOptionsType] = useState(initialSelectedOptionsType); + const { t } = useTranslation(); + + useEffect(() => { + const updatedSelectedOptionsType = getSelectedOptionsType( + component.optionsId, + component.options, + optionListIds, + ); + setSelectedOptionsType(updatedSelectedOptionsType); + }, [optionListIds, component.optionsId, component.options, setSelectedOptionsType]); + + return ( + { + setSelectedOptionsType(value as SelectedOptionsType); + }} + > + + + {t('ux_editor.options.tab_codelist')} + + + {t('ux_editor.options.tab_manual')} + + + {t('ux_editor.options.tab_referenceId')} + + + + + + + + + + + + + ); +}; + +type RenderManualOptionsProps = { + areLayoutOptionsSupported: boolean; +} & Pick, 'component' | 'handleComponentChange'>; + +const RenderManualOptions = ({ + component, + handleComponentChange, + areLayoutOptionsSupported, +}: RenderManualOptionsProps) => { + const errorMessage = useComponentErrorMessage(component); + const { t } = useTranslation(); + + if (areLayoutOptionsSupported === false) { + return ( + + {t('ux_editor.options.codelist_only')} + + ); + } + + return ( + <> + {shouldDisplayFeature('codeListEditor') ? ( + + ) : ( + + )} + {errorMessage && ( + + {errorMessage} + + )} + + ); +}; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts new file mode 100644 index 00000000000..0ecd9b2cb39 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts @@ -0,0 +1,2 @@ +export { useCodeListButtonValue } from './useCodeListButtonValue'; +export { useCodeListEditorTexts } from './useCodeListEditorTexts'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListButtonValue.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListButtonValue.ts new file mode 100644 index 00000000000..5ea35d8cde0 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListButtonValue.ts @@ -0,0 +1,14 @@ +import { useTranslation } from 'react-i18next'; +import type { Option } from 'app-shared/types/Option'; + +export const useCodeListButtonValue = (options: Option[] | undefined): string | undefined => { + const { t } = useTranslation(); + + if (options?.length > 1) { + return t('ux_editor.options.multiple', { value: options.length }); + } else if (options?.length === 1) { + return t('ux_editor.options.single', { value: options.length }); + } else { + return undefined; + } +}; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListEditorTexts.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListEditorTexts.ts new file mode 100644 index 00000000000..6c765b5963c --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useCodeListEditorTexts.ts @@ -0,0 +1,29 @@ +import { useTranslation } from 'react-i18next'; +import type { CodeListEditorTexts } from '@studio/components'; + +export const useCodeListEditorTexts = (): CodeListEditorTexts => { + const { t } = useTranslation(); + + return { + add: t('ux_editor.modal_new_option'), + codeList: t('ux_editor.modal_add_options_codelist'), + delete: t('general.delete'), + deleteItem: (number: number) => + t('ux_editor.modal_properties_code_list_delete_item', { number }), + description: t('general.description'), + emptyCodeList: t('ux_editor.modal_properties_code_list_empty'), + valueErrors: { + duplicateValue: t('ux_editor.radios_error_DuplicateValues'), + }, + generalError: t('ux_editor.modal_properties_code_list_general_error'), + helpText: t('ux_editor.modal_properties_textResourceBindings_help'), + itemDescription: (number: number) => + t('ux_editor.modal_properties_code_list_item_description', { number }), + itemHelpText: (number: number) => + t('ux_editor.modal_properties_code_list_item_helpText', { number }), + itemLabel: (number: number) => t('ux_editor.modal_properties_code_list_item_label', { number }), + itemValue: (number: number) => t('ux_editor.modal_properties_code_list_item_value', { number }), + label: t('ux_editor.modal_properties_textResourceBindings_title'), + value: t('general.value'), + }; +}; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/index.ts new file mode 100644 index 00000000000..a141398ee3a --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/index.ts @@ -0,0 +1 @@ +export { OptionTabs } from './OptionTabs'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.module.css index 508854bae3d..329b459f623 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.module.css @@ -1,3 +1,3 @@ .texts { - margin-bottom: var(--fds-spacing-5); + margin-bottom: var(--fds-spacing-7); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.tsx index 763ac47d238..6679be6ad19 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditTextResourceBindings/EditTextResourceBindings.tsx @@ -6,7 +6,6 @@ import { StudioProperty } from '@studio/components'; import classes from './EditTextResourceBindings.module.css'; export interface EditTextResourceBindingBase { - editFormId?: string; component: FormComponent | FormContainer; handleComponentChange: (component: FormComponent | FormContainer) => void; layoutName?: string;