diff --git a/backend/src/Designer/Controllers/AppDevelopmentController.cs b/backend/src/Designer/Controllers/AppDevelopmentController.cs index 680b7de8350..3084723842e 100644 --- a/backend/src/Designer/Controllers/AppDevelopmentController.cs +++ b/backend/src/Designer/Controllers/AppDevelopmentController.cs @@ -324,6 +324,31 @@ public async Task UpdateLayoutSet(string org, string app, [FromRou return Ok(layoutSets); } + /// + /// Delete an existing layout set + /// + /// Unique identifier of the organisation responsible for the app. + /// Application identifier which is unique within an organisation. + /// The id of the layout set to delete + /// An that observes if operation is cancelled. + [HttpDelete] + [UseSystemTextJson] + [Route("layout-set/{layoutSetIdToUpdate}")] + public async Task DeleteLayoutSet(string org, string app, [FromRoute] string layoutSetIdToUpdate, CancellationToken cancellationToken) + { + try + { + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); + LayoutSets layoutSets = await _appDevelopmentService.DeleteLayoutSet(editingContext, layoutSetIdToUpdate, cancellationToken); + return Ok(layoutSets); + } + catch (FileNotFoundException exception) + { + return NotFound($"Layout-sets.json not found: {exception}"); + } + } + /// /// Get rule handler in JSON structure /// diff --git a/backend/src/Designer/Models/App/LayoutSets.cs b/backend/src/Designer/Models/App/LayoutSets.cs deleted file mode 100644 index 5a1b6006c6e..00000000000 --- a/backend/src/Designer/Models/App/LayoutSets.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Altinn.Studio.Designer.Models.App; - -/// -/// Studio facade for layout sets model -/// -public class LayoutSets : Altinn.App.Core.Models.LayoutSets -{ - public LayoutSets() : base() - { - } -} diff --git a/backend/src/Designer/Models/LayoutSets.cs b/backend/src/Designer/Models/LayoutSets.cs index 5bd4347a85c..41622808034 100644 --- a/backend/src/Designer/Models/LayoutSets.cs +++ b/backend/src/Designer/Models/LayoutSets.cs @@ -11,7 +11,7 @@ public class LayoutSets : Altinn.App.Core.Models.LayoutSets public string Schema { get; set; } [JsonPropertyName("sets")] - public List Sets { get; set; } + public new List Sets { get; set; } [JsonExtensionData] public IDictionary UnknownProperties { get; set; } diff --git a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs index 8fc277368af..e2e1af45da0 100644 --- a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs +++ b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs @@ -309,6 +309,40 @@ public async Task UpdateLayoutSet(AltinnRepoEditingContext altinnRep return await UpdateExistingLayoutSet(altinnAppGitRepository, layoutSets, layoutSetToReplace, newLayoutSet); } + public async Task DeleteLayoutSet(AltinnRepoEditingContext altinnRepoEditingContext, + string layoutSetToDeleteId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + AltinnAppGitRepository altinnAppGitRepository = + _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, + altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); + LayoutSets layoutSets = await altinnAppGitRepository.GetLayoutSetsFile(cancellationToken); + var layoutSetToDelete = layoutSets.Sets.Find(set => set.Id == layoutSetToDeleteId); + var dataTypeNameToRemoveTaskIdRef = layoutSetToDelete?.DataType; + if (!string.IsNullOrEmpty(dataTypeNameToRemoveTaskIdRef)) + { + await DeleteTaskRefInApplicationMetadata(altinnAppGitRepository, dataTypeNameToRemoveTaskIdRef); + } + + return await DeleteExistingLayoutSet(altinnAppGitRepository, layoutSets, layoutSetToDeleteId); + } + + private async Task DeleteTaskRefInApplicationMetadata(AltinnAppGitRepository altinnAppGitRepository, string dataTypeId) + { + var applicationMetadata = await altinnAppGitRepository.GetApplicationMetadata(); + var dataType = applicationMetadata.DataTypes.Find(dataType => dataType.Id == dataTypeId); + dataType.TaskId = null; + await altinnAppGitRepository.SaveApplicationMetadata(applicationMetadata); + } + + private static async Task DeleteExistingLayoutSet(AltinnAppGitRepository altinnAppGitRepository, LayoutSets layoutSets, string layoutSetToDeleteId) + { + LayoutSetConfig layoutSetToDelete = layoutSets.Sets.Find(set => set.Id == layoutSetToDeleteId); + layoutSets.Sets.Remove(layoutSetToDelete); + await altinnAppGitRepository.SaveLayoutSetsFile(layoutSets); + return layoutSets; + } + private static async Task AddNewLayoutSet(AltinnAppGitRepository altinnAppGitRepository, LayoutSets layoutSets, LayoutSetConfig layoutSet) { layoutSets.Sets.Add(layoutSet); @@ -328,7 +362,6 @@ private static async Task UpdateExistingLayoutSet(AltinnAppGitReposi return layoutSets; } - /// public async Task GetRuleHandler(AltinnRepoEditingContext altinnRepoEditingContext, string layoutSetName, CancellationToken cancellationToken = default) diff --git a/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs b/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs index 44623d830b7..c61baf81a87 100644 --- a/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs +++ b/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs @@ -111,6 +111,14 @@ public Task GetModelMetadata( /// An that observes if operation is cancelled. public Task UpdateLayoutSet(AltinnRepoEditingContext altinnRepoEditingContext, string layoutSetToUpdateId, LayoutSetConfig newLayoutSet, CancellationToken cancellationToken = default); + /// + /// Deletes an existing layout set in layout-sets.json based on layoutSetId and deletes connection between related dataType/task in application metadata + /// + /// An . + /// The id of the layout set to replace + /// An that observes if operation is cancelled. + public Task DeleteLayoutSet(AltinnRepoEditingContext altinnRepoEditingContext, string layoutSetToDeleteId, CancellationToken cancellationToken = default); + /// /// Gets the rule handler for a specific organization, application, developer, and layout set name. /// diff --git a/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/DeleteLayoutSetTests.cs b/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/DeleteLayoutSetTests.cs new file mode 100644 index 00000000000..f1b6995f44a --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/AppDevelopmentController/DeleteLayoutSetTests.cs @@ -0,0 +1,138 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Altinn.Platform.Storage.Interface.Models; +using Altinn.Studio.Designer.Factories; +using Altinn.Studio.Designer.Infrastructure.GitRepository; +using Altinn.Studio.Designer.Models; +using Designer.Tests.Controllers.ApiTests; +using Designer.Tests.Utils; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using SharedResources.Tests; +using Xunit; + +namespace Designer.Tests.Controllers.AppDevelopmentController +{ + public class DeleteLayoutSetTests(WebApplicationFactory factory) + : DisagnerEndpointsTestsBase(factory), IClassFixture> + { + private static string VersionPrefix(string org, string repository) => + $"/designer/api/{org}/{repository}/app-development"; + + [Theory] + [InlineData("ttd", "app-with-layoutsets", "testUser", "layoutSet2")] + public async Task DeleteLayoutSets_SetWithoutDataTypeConnection_ReturnsOk(string org, string app, string developer, + string layoutSetToDeleteId) + { + string targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest(org, app, developer, targetRepository); + LayoutSets layoutSetsBefore = await GetLayoutSetsFile(org, targetRepository, developer); + + string url = $"{VersionPrefix(org, targetRepository)}/layout-set/{layoutSetToDeleteId}"; + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, url); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + LayoutSets layoutSetsAfter = await GetLayoutSetsFile(org, targetRepository, developer); + + layoutSetsBefore.Sets.Should().HaveCount(3); + Assert.True(layoutSetsBefore.Sets.Exists(set => set.Id == layoutSetToDeleteId)); + layoutSetsAfter.Sets.Should().HaveCount(2); + Assert.False(layoutSetsAfter.Sets.Exists(set => set.Id == layoutSetToDeleteId)); + } + + [Theory] + [InlineData("ttd", "app-with-layoutsets", "testUser", "layoutSet1")] + public async Task DeleteLayoutSets_SetWithDataTypeConnection_ReturnsOk(string org, string app, string developer, + string layoutSetToDeleteId) + { + string targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest(org, app, developer, targetRepository); + string connectedDataType = "datamodel"; + string connectedTaskId = "Task_1"; + LayoutSets layoutSetsBefore = await GetLayoutSetsFile(org, targetRepository, developer); + Application appMetadataBefore = await GetApplicationMetadataFile(org, targetRepository, developer); + + string url = $"{VersionPrefix(org, targetRepository)}/layout-set/{layoutSetToDeleteId}"; + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, url); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + LayoutSets layoutSetsAfter = await GetLayoutSetsFile(org, targetRepository, developer); + Application appMetadataAfter = await GetApplicationMetadataFile(org, targetRepository, developer); + + layoutSetsBefore.Sets.Should().HaveCount(3); + appMetadataBefore.DataTypes.Find(dataType => dataType.Id == connectedDataType).TaskId.Should() + .Be(connectedTaskId); + Assert.True(layoutSetsBefore.Sets.Exists(set => set.Id == layoutSetToDeleteId)); + layoutSetsAfter.Sets.Should().HaveCount(2); + appMetadataAfter.DataTypes.Find(dataType => dataType.Id == connectedDataType).TaskId.Should().BeNull(); + Assert.False(layoutSetsAfter.Sets.Exists(set => set.Id == layoutSetToDeleteId)); + } + + [Theory] + [InlineData("ttd", "app-with-layoutsets", "testUser", "non-existing-layout-set")] + public async Task DeleteLayoutSet_IdNotFound_ReturnsUnAlteredLayoutSets(string org, string app, string developer, + string layoutSetToDeleteId) + { + string targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest(org, app, developer, targetRepository); + + string layoutSetsBefore = TestDataHelper.GetFileFromRepo(org, app, developer, "App/ui/layout-sets.json"); + + string url = $"{VersionPrefix(org, targetRepository)}/layout-set/{layoutSetToDeleteId}"; + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, url); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.OK); + + string responseContent = await response.Content.ReadAsStringAsync(); + + Assert.True(JsonUtils.DeepEquals(layoutSetsBefore, responseContent)); + } + + [Theory] + [InlineData("ttd", "app-without-layoutsets", "testUser", null)] + public async Task AddLayoutSet_AppWithoutLayoutSets_ReturnsNotFound(string org, string app, string developer, + string layoutSetToDeleteId) + { + string targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest(org, app, developer, targetRepository); + + string url = $"{VersionPrefix(org, targetRepository)}/layout-set/{layoutSetToDeleteId}"; + + using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, url); + + using var response = await HttpClient.SendAsync(httpRequestMessage); + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private async Task GetLayoutSetsFile(string org, string app, string developer) + { + AltinnGitRepositoryFactory altinnGitRepositoryFactory = + new(TestDataHelper.GetTestDataRepositoriesRootDirectory()); + AltinnAppGitRepository altinnAppGitRepository = + altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, app, developer); + + return await altinnAppGitRepository.GetLayoutSetsFile(); + } + + private async Task GetApplicationMetadataFile(string org, string app, string developer) + { + AltinnGitRepositoryFactory altinnGitRepositoryFactory = + new(TestDataHelper.GetTestDataRepositoriesRootDirectory()); + AltinnAppGitRepository altinnAppGitRepository = + altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, app, developer); + + return await altinnAppGitRepository.GetApplicationMetadata(); + } + } +} + diff --git a/backend/tests/Designer.Tests/Controllers/ProcessModelingController/FileSync/TaskIdChangeTests/LayoutSetsFileSyncTests.cs b/backend/tests/Designer.Tests/Controllers/ProcessModelingController/FileSync/TaskIdChangeTests/LayoutSetsFileSyncTests.cs index 29038ff1261..70050eb570b 100644 --- a/backend/tests/Designer.Tests/Controllers/ProcessModelingController/FileSync/TaskIdChangeTests/LayoutSetsFileSyncTests.cs +++ b/backend/tests/Designer.Tests/Controllers/ProcessModelingController/FileSync/TaskIdChangeTests/LayoutSetsFileSyncTests.cs @@ -7,7 +7,7 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; -using Altinn.Studio.Designer.Models.App; +using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Models.Dto; using Designer.Tests.Controllers.ApiTests; using Designer.Tests.Utils; diff --git a/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.test.ts b/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.test.ts new file mode 100644 index 00000000000..8325957421e --- /dev/null +++ b/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.test.ts @@ -0,0 +1,26 @@ +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { renderHookWithMockStore } from '../../test/mocks'; +import { act, waitFor } from '@testing-library/react'; +import { useDeleteLayoutSetMutation } from './useDeleteLayoutSetMutation'; + +// Test data: +const org = 'org'; +const app = 'app'; +const layoutSetToDeleteId = 'oldLayoutSetName'; + +describe('useDeleteLayoutSetMutation', () => { + it('Calls deleteLayoutSetMutation with correct arguments and payload', async () => { + const deleteLayoutSetResult = renderHookWithMockStore()(() => + useDeleteLayoutSetMutation(org, app), + ).renderHookResult.result; + await act(() => + deleteLayoutSetResult.current.mutateAsync({ + layoutSetIdToUpdate: layoutSetToDeleteId, + }), + ); + await waitFor(() => expect(deleteLayoutSetResult.current.isSuccess).toBe(true)); + + expect(queriesMock.deleteLayoutSet).toHaveBeenCalledTimes(1); + expect(queriesMock.deleteLayoutSet).toHaveBeenCalledWith(org, app, layoutSetToDeleteId); + }); +}); diff --git a/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.ts b/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.ts new file mode 100644 index 00000000000..e8024cbc129 --- /dev/null +++ b/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.ts @@ -0,0 +1,16 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import { QueryKey } from 'app-shared/types/QueryKey'; + +export const useDeleteLayoutSetMutation = (org: string, app: string) => { + const { deleteLayoutSet } = useServicesContext(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ layoutSetIdToUpdate }: { layoutSetIdToUpdate: string }) => + deleteLayoutSet(org, app, layoutSetIdToUpdate), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSets, org, app] }); + }, + }); +}; diff --git a/frontend/packages/shared/src/api/mutations.ts b/frontend/packages/shared/src/api/mutations.ts index a9a4e4e5cf2..cc4ed52bd39 100644 --- a/frontend/packages/shared/src/api/mutations.ts +++ b/frontend/packages/shared/src/api/mutations.ts @@ -64,6 +64,7 @@ const headers = { export const addAppAttachmentMetadata = (org: string, app: string, payload: ApplicationAttachmentMetadata) => post(appMetadataAttachmentPath(org, app), payload); export const addLanguageCode = (org: string, app: string, language: string, payload: AddLanguagePayload) => post(textResourcesPath(org, app, language), payload); export const addLayoutSet = (org: string, app: string, layoutSetIdToUpdate: string, payload: LayoutSetConfig) => post(layoutSetPath(org, app, layoutSetIdToUpdate), payload); +export const deleteLayoutSet = (org: string, app: string, layoutSetIdToUpdate: string) => del(layoutSetPath(org, app, layoutSetIdToUpdate)); export const updateLayoutSet = (org: string, app: string, layoutSetIdToUpdate: string, payload: LayoutSetConfig) => put(layoutSetPath(org, app, layoutSetIdToUpdate), payload); export const addRepo = (repoToAdd: AddRepoParams) => post(`${createRepoPath()}${buildQueryParams(repoToAdd)}`); export const addXsdFromRepo = (org: string, app: string, modelPath: string) => post(datamodelAddXsdFromRepoPath(org, app, modelPath)); diff --git a/frontend/packages/shared/src/api/paths.js b/frontend/packages/shared/src/api/paths.js index 1a398a0e650..e12c7bf6844 100644 --- a/frontend/packages/shared/src/api/paths.js +++ b/frontend/packages/shared/src/api/paths.js @@ -34,7 +34,7 @@ export const ruleConfigPath = (org, app, layoutSetName) => `${basePath}/${org}/$ export const datamodelMetadataPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/model-metadata?${s({ layoutSetName })}`; // Get export const layoutNamesPath = (org, app) => `${basePath}/${org}/${app}/app-development/layout-names`; // Get export const layoutSetsPath = (org, app) => `${basePath}/${org}/${app}/app-development/layout-sets`; // Get -export const layoutSetPath = (org, app, layoutSetIdToUpdate) => `${basePath}/${org}/${app}/app-development/layout-set/${layoutSetIdToUpdate}`; // Put +export const layoutSetPath = (org, app, layoutSetIdToUpdate) => `${basePath}/${org}/${app}/app-development/layout-set/${layoutSetIdToUpdate}`; // Put, Delete export const layoutSettingsPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/layout-settings?${s({ layoutSetName })}`; // Get, Post export const formLayoutsPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/form-layouts?${s({ layoutSetName })}`; // Get export const formLayoutPath = (org, app, layout, layoutSetName) => `${basePath}/${org}/${app}/app-development/form-layout/${layout}?${s({ layoutSetName })}`; // Post, Delete diff --git a/frontend/packages/shared/src/mocks/queriesMock.ts b/frontend/packages/shared/src/mocks/queriesMock.ts index 6b4698c7720..d8555fb323b 100644 --- a/frontend/packages/shared/src/mocks/queriesMock.ts +++ b/frontend/packages/shared/src/mocks/queriesMock.ts @@ -181,6 +181,7 @@ export const queriesMock: ServicesContextProps = { deleteDatamodel: jest.fn().mockImplementation(() => Promise.resolve()), deleteFormLayout: jest.fn().mockImplementation(() => Promise.resolve()), deleteLanguageCode: jest.fn().mockImplementation(() => Promise.resolve()), + deleteLayoutSet: jest.fn().mockImplementation(() => Promise.resolve()), generateModels: jest.fn().mockImplementation(() => Promise.resolve()), logout: jest.fn().mockImplementation(() => Promise.resolve()), pushRepoChanges: jest.fn().mockImplementation(() => Promise.resolve()),