diff --git a/.github/workflows/auto-approve-pr.yaml b/.github/workflows/auto-approve-pr.yaml index 733ef84fb24..87e43f72e42 100644 --- a/.github/workflows/auto-approve-pr.yaml +++ b/.github/workflows/auto-approve-pr.yaml @@ -10,7 +10,7 @@ jobs: if: github.event.label.name == 'skip-manual-testing' steps: - name: Checkout PR code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} diff --git a/.github/workflows/frontend-unit-tests.yml b/.github/workflows/frontend-unit-tests.yml index cecec8528d1..4efcaa1853e 100644 --- a/.github/workflows/frontend-unit-tests.yml +++ b/.github/workflows/frontend-unit-tests.yml @@ -85,7 +85,7 @@ jobs: run: yarn test:ci - name: 'Upload coverage reports to Codecov' - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: diff --git a/backend/src/Designer/Controllers/AppDevelopmentController.cs b/backend/src/Designer/Controllers/AppDevelopmentController.cs index 37a2d40c08b..ae927c4eb05 100644 --- a/backend/src/Designer/Controllers/AppDevelopmentController.cs +++ b/backend/src/Designer/Controllers/AppDevelopmentController.cs @@ -334,6 +334,17 @@ public async Task GetLayoutSets(string org, string app, Cancellat return Ok(layoutSets); } + [HttpGet("layout-sets/extended")] + [UseSystemTextJson] + public async Task GetLayoutSetsExtended(string org, string app, CancellationToken cancellationToken) + { + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); + + LayoutSetsModel layoutSetsModel = await _appDevelopmentService.GetLayoutSetsExtended(editingContext, cancellationToken); + return layoutSetsModel; + } + /// /// Add a new layout set /// diff --git a/backend/src/Designer/Controllers/ResourceAdminController.cs b/backend/src/Designer/Controllers/ResourceAdminController.cs index 8a68aac10a5..29cca0e6408 100644 --- a/backend/src/Designer/Controllers/ResourceAdminController.cs +++ b/backend/src/Designer/Controllers/ResourceAdminController.cs @@ -14,6 +14,7 @@ using Altinn.Studio.Designer.ModelBinding.Constants; using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Services.Interfaces; +using Altinn.Studio.Designer.Services.Models; using Altinn.Studio.Designer.TypedHttpClients.ResourceRegistryOptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -34,9 +35,9 @@ public class ResourceAdminController : ControllerBase private readonly CacheSettings _cacheSettings; private readonly IOrgService _orgService; private readonly IResourceRegistry _resourceRegistry; - private readonly ResourceRegistryIntegrationSettings _resourceRegistrySettings; + private readonly IEnvironmentsService _environmentsService; - public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRegistryOptions resourceRegistryOptions, IMemoryCache memoryCache, IOptions cacheSettings, IOrgService orgService, IOptions resourceRegistryEnvironment, IResourceRegistry resourceRegistry) + public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRegistryOptions resourceRegistryOptions, IMemoryCache memoryCache, IOptions cacheSettings, IOrgService orgService, IResourceRegistry resourceRegistry, IEnvironmentsService environmentsService) { _giteaApi = gitea; _repository = repository; @@ -44,8 +45,8 @@ public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRe _memoryCache = memoryCache; _cacheSettings = cacheSettings.Value; _orgService = orgService; - _resourceRegistrySettings = resourceRegistryEnvironment.Value; _resourceRegistry = resourceRegistry; + _environmentsService = environmentsService; } [HttpPost] @@ -175,12 +176,14 @@ public async Task>> GetRepositoryReso if (includeEnvResources) { - foreach (string environment in _resourceRegistrySettings.Keys) + IEnumerable environments = await GetEnvironmentsForOrg(org); + foreach (string environment in environments) { string cacheKey = $"resourcelist_${environment}"; if (!_memoryCache.TryGetValue(cacheKey, out List environmentResources)) { environmentResources = await _resourceRegistry.GetResourceList(environment, false); + var cacheEntryOptions = new MemoryCacheEntryOptions() .SetPriority(CacheItemPriority.High) .SetAbsoluteExpiration(new TimeSpan(0, _cacheSettings.DataNorgeApiCacheTimeout, 0)); @@ -239,7 +242,8 @@ public async Task> GetPublishStatusById(stri PublishedVersions = [] }; - foreach (string envir in _resourceRegistrySettings.Keys) + IEnumerable environments = await GetEnvironmentsForOrg(org); + foreach (string envir in environments) { resourceStatus.PublishedVersions.Add(await AddEnvironmentResourceStatus(envir, id)); } @@ -643,5 +647,11 @@ private string GetRepositoryName(string org) { return string.Format("{0}-resources", org); } + + private async Task> GetEnvironmentsForOrg(string org) + { + IEnumerable environments = await _environmentsService.GetOrganizationEnvironments(org); + return environments.Select(environment => environment.Name == "production" ? "prod" : environment.Name); + } } } diff --git a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs index 93ddf76bcc4..5d3bd1c0fb4 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs @@ -8,6 +8,8 @@ using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; +using System.Xml.Serialization; +using Altinn.App.Core.Internal.Process.Elements; using Altinn.Studio.Designer.Configuration; using Altinn.Studio.Designer.Exceptions.AppDevelopment; using Altinn.Studio.Designer.Helpers; @@ -804,6 +806,13 @@ public Stream GetProcessDefinitionFile() return OpenStreamByRelativePath(ProcessDefinitionFilePath); } + public Definitions GetDefinitions() + { + Stream processDefinitionStream = GetProcessDefinitionFile(); + XmlSerializer serializer = new(typeof(Definitions)); + return (Definitions)serializer.Deserialize(processDefinitionStream); + } + /// /// Checks if image already exists in wwwroot /// diff --git a/backend/src/Designer/Models/Dto/LayoutSetModel.cs b/backend/src/Designer/Models/Dto/LayoutSetModel.cs new file mode 100644 index 00000000000..094541d4b12 --- /dev/null +++ b/backend/src/Designer/Models/Dto/LayoutSetModel.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace Altinn.Studio.Designer.Models.Dto; + +public class LayoutSetModel +{ + [JsonPropertyName("id")] + public string id { get; set; } + [JsonPropertyName("dataType")] + public string dataType { get; set; } + [JsonPropertyName("type")] + public string type { get; set; } + [JsonPropertyName("task")] + public TaskModel task { get; set; } +} + diff --git a/backend/src/Designer/Models/Dto/LayoutSets.cs b/backend/src/Designer/Models/Dto/LayoutSets.cs new file mode 100644 index 00000000000..3eb34221473 --- /dev/null +++ b/backend/src/Designer/Models/Dto/LayoutSets.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Altinn.Studio.Designer.Models.Dto; + +public class LayoutSetsModel +{ + [JsonPropertyName("sets")] + public List sets { get; set; } = []; +} diff --git a/backend/src/Designer/Models/Dto/TaskModel.cs b/backend/src/Designer/Models/Dto/TaskModel.cs new file mode 100644 index 00000000000..d5a7a212119 --- /dev/null +++ b/backend/src/Designer/Models/Dto/TaskModel.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace Altinn.Studio.Designer.Models.Dto; + +public class TaskModel +{ + [JsonPropertyName("id")] + public string id { get; set; } + [JsonPropertyName("type")] + public string type { get; set; } +} + diff --git a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs index 204e87eb9d9..569333c0633 100644 --- a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs +++ b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs @@ -6,12 +6,14 @@ using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; +using Altinn.App.Core.Internal.Process.Elements; using Altinn.App.Core.Models; using Altinn.Studio.DataModeling.Metamodel; using Altinn.Studio.Designer.Exceptions.AppDevelopment; using Altinn.Studio.Designer.Helpers; using Altinn.Studio.Designer.Infrastructure.GitRepository; using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Models.Dto; using Altinn.Studio.Designer.Services.Interfaces; using Microsoft.AspNetCore.Http; using NuGet.Versioning; @@ -265,6 +267,43 @@ public async Task GetLayoutSets(AltinnRepoEditingContext altinnRepoE "No layout set found for this app."); } + private static string TaskTypeFromDefinitions(Definitions definitions, string taskId) + { + return definitions.Process.Tasks.FirstOrDefault(task => task.Id == taskId)?.ExtensionElements?.TaskExtension?.TaskType ?? string.Empty; + } + + public async Task GetLayoutSetsExtended(AltinnRepoEditingContext altinnRepoEditingContext, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + AltinnAppGitRepository altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); + + LayoutSets layoutSetsFile = await altinnAppGitRepository.GetLayoutSetsFile(cancellationToken); + Definitions definitions = altinnAppGitRepository.GetDefinitions(); + + LayoutSetsModel layoutSetsModel = new(); + layoutSetsFile.Sets.ForEach(set => + { + LayoutSetModel layoutSetModel = new() + { + id = set.Id, + dataType = set.DataType, + type = set.Type, + }; + string taskId = set.Tasks?[0]; + if (taskId != null) + { + string taskType = TaskTypeFromDefinitions(definitions, taskId); + layoutSetModel.task = new TaskModel + { + id = taskId, + type = taskType + }; + } + layoutSetsModel.sets.Add(layoutSetModel); + }); + return layoutSetsModel; + } + /// public async Task GetLayoutSetConfig(AltinnRepoEditingContext altinnRepoEditingContext, string layoutSetId, CancellationToken cancellationToken = default) diff --git a/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs b/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs index 8080312fae7..e175fba77d0 100644 --- a/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs +++ b/backend/src/Designer/Services/Interfaces/IAppDevelopmentService.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Altinn.Studio.DataModeling.Metamodel; using Altinn.Studio.Designer.Models; +using Altinn.Studio.Designer.Models.Dto; using JetBrains.Annotations; namespace Altinn.Studio.Designer.Services.Interfaces @@ -106,6 +107,13 @@ public Task GetModelMetadata( /// A that observes if operation is canceled. public Task GetLayoutSets(AltinnRepoEditingContext altinnRepoEditingContext, CancellationToken cancellationToken = default); + /// + /// Extended version of layout sets with the intention of adding information not included in the raw layout-sets.json file. + /// + /// An . + /// A that observes if operation is canceled. + public Task GetLayoutSetsExtended(AltinnRepoEditingContext altinnRepoEditingContext, CancellationToken cancellationToken = default); + /// /// Gets a layoutSet config. /// diff --git a/backend/src/Designer/appsettings.json b/backend/src/Designer/appsettings.json index 21e00fad047..0bac281cb59 100644 --- a/backend/src/Designer/appsettings.json +++ b/backend/src/Designer/appsettings.json @@ -53,6 +53,9 @@ } }, "ResourceRegistryIntegrationSettings": { + "YT01": { + "ResourceRegistryEnvBaseUrl": "https://platform.yt01.altinn.cloud" + }, "AT22": { "ResourceRegistryEnvBaseUrl": "https://platform.at22.altinn.cloud", "SblBridgeBaseUrl": "https://at22.altinn.cloud/sblbridge/" diff --git a/charts/altinn-loadbalancer/values.yaml b/charts/altinn-loadbalancer/values.yaml index e0391ffee4a..1aff5006d57 100644 --- a/charts/altinn-loadbalancer/values.yaml +++ b/charts/altinn-loadbalancer/values.yaml @@ -58,7 +58,7 @@ loadbalancerIP: sidecar: enabled: true name: "exporter" - image: "ghcr.io/martin-helmich/prometheus-nginxlog-exporter/exporter@sha256:2174507adfc841990d4c51e6b73a4b948d16a4010845c74109b6858a3d0d2242" + image: "ghcr.io/martin-helmich/prometheus-nginxlog-exporter/exporter@sha256:62987d855bb07edc5d126b926751c733e6683a6a6d2844026d3af332bac654be" args: - "-config-file" - "/etc/prometheus-nginxlog-exporter/config.hcl" diff --git a/eidlogger/pom.xml b/eidlogger/pom.xml index 3d007f7f282..fee662753ad 100644 --- a/eidlogger/pom.xml +++ b/eidlogger/pom.xml @@ -5,7 +5,7 @@ org.springframework.boot spring-boot-starter-parent - 3.3.5 + 3.4.0 no.altinn @@ -15,7 +15,7 @@ Eid event logger 21 - 2.6.0 + 2.7.0 1.2.2 5.18.0 diff --git a/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.test.ts b/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.test.ts index 60809622836..914d3115646 100644 --- a/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.test.ts +++ b/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.test.ts @@ -1,6 +1,6 @@ import { renderHookWithProviders } from '../test/mocks'; import { - queryParamKey, + openSettingsModalWithTabQueryKey, useOpenSettingsModalBasedQueryParam, } from './useOpenSettingsModalBasedQueryParam'; import { useSearchParams } from 'react-router-dom'; @@ -44,7 +44,7 @@ function setupSearchParamMock(searchParams: URLSearchParams): jest.Mock { function buildSearchParams(queryParamValue: string): URLSearchParams { const searchParams: URLSearchParams = new URLSearchParams(); - searchParams.set(queryParamKey, queryParamValue); + searchParams.set(openSettingsModalWithTabQueryKey, queryParamValue); return searchParams; } diff --git a/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.ts b/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.ts index 05b562a7276..e2085d28707 100644 --- a/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.ts +++ b/frontend/app-development/hooks/useOpenSettingsModalBasedQueryParam.ts @@ -4,7 +4,7 @@ import { useSettingsModalContext } from '../contexts/SettingsModalContext'; import type { SettingsModalTabId } from '../types/SettingsModalTabId'; import { useSettingsModalMenuTabConfigs } from '../layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/hooks/useSettingsModalMenuTabConfigs'; -export const queryParamKey: string = 'openSettingsModalWithTab'; +export const openSettingsModalWithTabQueryKey: string = 'openSettingsModalWithTab'; export function useOpenSettingsModalBasedQueryParam(): void { const [searchParams] = useSearchParams(); @@ -14,7 +14,9 @@ export function useOpenSettingsModalBasedQueryParam(): void { const tabIds = settingsModalTabs.map(({ tabId }) => tabId); useEffect((): void => { - const tabToOpen: SettingsModalTabId = searchParams.get(queryParamKey) as SettingsModalTabId; + const tabToOpen: SettingsModalTabId = searchParams.get( + openSettingsModalWithTabQueryKey, + ) as SettingsModalTabId; const shouldOpenModal: boolean = isValidTab(tabToOpen, tabIds); if (shouldOpenModal) { settingsRef.current.openSettings(tabToOpen); diff --git a/frontend/app-development/layout/App.tsx b/frontend/app-development/layout/App.tsx index 885939d8aca..50dff075b8b 100644 --- a/frontend/app-development/layout/App.tsx +++ b/frontend/app-development/layout/App.tsx @@ -12,6 +12,9 @@ import { useRepoStatusQuery } from 'app-shared/hooks/queries'; import { appContentWrapperId } from '@studio/testing/testids'; i18next.use(initReactI18next).init({ + ns: 'translation', + defaultNS: 'translation', + fallbackNS: 'translation', lng: DEFAULT_LANGUAGE, resources: { nb: { translation: nb }, diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.test.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.test.tsx index b6527f98e11..ab726a77b00 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.test.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { AnsattportenLogin } from './AnsattportenLogin'; +import { AnsattportenLogin, getRedirectUrl } from './AnsattportenLogin'; import { textMock } from '@studio/testing/mocks/i18nMock'; jest.mock('app-shared/api/paths'); @@ -40,10 +40,22 @@ describe('AnsattportenLogin', () => { }); }); +describe('getRedirectUrl', () => { + it('should build and return correct redirect url', () => { + mockWindowLocationHref(); + const result = getRedirectUrl(); + expect(result).toBe('/path/to/page?openSettingsModalWithTab=maskinporten'); + }); +}); + function mockWindowLocationHref(): jest.Mock { const hrefMock = jest.fn(); delete window.location; - window.location = { href: '' } as Location; + window.location = { + href: '', + origin: 'https://unit-test-com', + pathname: '/path/to/page', + } as Location; Object.defineProperty(window.location, 'href', { set: hrefMock, }); diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.tsx index 3e542eb6ab1..8ad2097267b 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/components/Tabs/Maskinporten/AnsattportenLogin/AnsattportenLogin.tsx @@ -4,12 +4,14 @@ import { useTranslation } from 'react-i18next'; import { StudioButton, StudioParagraph } from '@studio/components'; import { EnterIcon } from '@studio/icons'; import { loginWithAnsattPorten } from 'app-shared/api/paths'; +import { openSettingsModalWithTabQueryKey } from '../../../../../../../../../hooks/useOpenSettingsModalBasedQueryParam'; +import type { SettingsModalTabId } from '../../../../../../../../../types/SettingsModalTabId'; export const AnsattportenLogin = (): ReactElement => { const { t } = useTranslation(); const handleLoginWithAnsattporten = (): void => { - window.location.href = loginWithAnsattPorten(window.location.pathname + window.location.search); + window.location.href = loginWithAnsattPorten(getRedirectUrl()); }; return ( @@ -39,3 +41,10 @@ const LoginIcon = (): ReactElement => { ); }; + +export function getRedirectUrl(): string { + const maskinportenTab: SettingsModalTabId = 'maskinporten'; + const url = new URL(window.location.origin + window.location.pathname); + url.searchParams.set(openSettingsModalWithTabQueryKey, maskinportenTab); + return url.pathname + url.search; +} diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 6b560e6af89..cf32f81e67c 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -737,6 +737,11 @@ "process_editor.sync_error_layout_sets_data_type": "Det oppsto en feil da datatype skulle synkroniseres i filen 'layoutsets.json'. Kontroller at 'layoutsets.json' kun inneholder gyldig JSON-struktur og prøv igjen.", "process_editor.sync_error_layout_sets_task_id": "Det oppsto en feil da oppgave-ID skulle synkroniseres i filen 'layoutsets.json'. Kontroller at 'layoutsets.json' kun inneholder gyldig JSON-struktur og prøv igjen.", "process_editor.sync_error_policy_file_task_id": "Det oppsto en feil da oppgave-ID skulle synkroniseres i filen 'policy.json'. Kontroller at 'policy.json' kun inneholder gyldig JSON-struktur og prøv igjen.", + "process_editor.task_type.confirmation": "Bekreftelse", + "process_editor.task_type.data": "Utfylling", + "process_editor.task_type.feedback": "Tilbakemelding", + "process_editor.task_type.payment": "Betaling", + "process_editor.task_type.signing": "Signering", "process_editor.too_old_version_helptext_content": "Du har nå versjon {{version}} av app-biblioteket vårt.\n\nVi lanserer muligheten til å redigere prosessen sammen med versjon 8 av biblioteket. Når du har oppgradert til versjon 8, får du funksjonalitet for å redigere prosessen.\n\nFør det kan du bare se prosessen og eventuelle oppsett som er knyttet til den.", "process_editor.too_old_version_helptext_title": "Informasjon om hvorfor du ikke kan redigere prosessen", "process_editor.too_old_version_title": "Du kan ikke redigere prosessen", @@ -1102,6 +1107,13 @@ "top_menu.preview_back_to_editing": "Til utforming", "top_menu.process_editor": "Prosess", "top_menu.texts": "Språk", + "ux_editor.add_item.add_component": "Legg til komponent", + "ux_editor.add_item.add_component_by_type": "Legg til {{type}}", + "ux_editor.add_item.close": "Lukk", + "ux_editor.add_item.component_info_generated_id_description": "Vi lager automatisk en unik ID for komponenten. Du kan endre den her til noe du selv ønsker, eller la den være som den er. Du kan også endre denne id-en senere.", + "ux_editor.add_item.component_more_info_description": "Klikk på en komponent for å se mer informasjon om den.", + "ux_editor.add_item.select_component_header": "Velg komponent", + "ux_editor.add_item.show_all": "Vis alle", "ux_editor.add_map_layer": "Legg til kartlag", "ux_editor.address_component.settings": "Innstillinger for adressekomponent", "ux_editor.adjust_zoom": "Standard zoom", @@ -1176,7 +1188,7 @@ "ux_editor.component_properties.action": "Handling", "ux_editor.component_properties.actions": "Handlinger", "ux_editor.component_properties.addButton": "Legg til-knapp", - "ux_editor.component_properties.alertOnChange": "Brukerne skal få advarsel når de gjør endring", + "ux_editor.component_properties.alertOnChange": "Gi brukerne et varsel når de endrer", "ux_editor.component_properties.alertOnDelete": "Brukerne skal få advarsel når de sletter", "ux_editor.component_properties.align": "Plassering av tekstinnhold", "ux_editor.component_properties.allowPopups": "Tillat popups", @@ -1267,7 +1279,7 @@ "ux_editor.component_properties.enum_year": "År", "ux_editor.component_properties.excludedChildren": "Komponenter som ikke skal være med i gruppens oppsummering", "ux_editor.component_properties.filter": "Filter", - "ux_editor.component_properties.forceShowInSummary": "Feltet skal alltid vises i oppsummeringer", + "ux_editor.component_properties.forceShowInSummary": " Vis alltid feltet i oppsummeringer", "ux_editor.component_properties.format": "Format", "ux_editor.component_properties.formatting": "Formatering (formatting)", "ux_editor.component_properties.geometryType": "Geometri-type", @@ -1325,11 +1337,11 @@ "ux_editor.component_properties.position": "Plassering av valuta", "ux_editor.component_properties.preselectedOptionIndex": "Indeks/plassering av forhåndsvalgt verdi (preselectedOptionIndex)", "ux_editor.component_properties.queryParameters": "Parametere i spørringen", - "ux_editor.component_properties.readOnly": "Feltet skal være skrivebeskyttet (kun leserettighet, readonly)", + "ux_editor.component_properties.readOnly": "Feltet kan kun leses", "ux_editor.component_properties.receiver": "Den som mottar skjemaet", "ux_editor.component_properties.referenceNumber": "Referansenummer", - "ux_editor.component_properties.renderAsSummary": "Feltet skal vises som en oppsummering", - "ux_editor.component_properties.required": "Feltet må fylles ut (required)", + "ux_editor.component_properties.renderAsSummary": "Vis som oppsummeringsfelt", + "ux_editor.component_properties.required": "Angi at feltet må fylles ut", "ux_editor.component_properties.rows": "Rader", "ux_editor.component_properties.rowsAfter": "Rader etter", "ux_editor.component_properties.rowsBefore": "Rader før", @@ -1338,7 +1350,7 @@ "ux_editor.component_properties.saveAndNextButton": "Lagre og neste-knapp", "ux_editor.component_properties.saveButton": "Lagre-knapp", "ux_editor.component_properties.saveWhileTyping": "Overstyr tidsintervallet for lagring når brukeren skriver", - "ux_editor.component_properties.secure": "Bruk sikret versjon av API for å hente valg (secure)", + "ux_editor.component_properties.secure": "Bruk sikret API-versjon for å hente valg ", "ux_editor.component_properties.select_all_attachments": "Alle vedlegg", "ux_editor.component_properties.select_attachments": "Velg vedlegg", "ux_editor.component_properties.select_pdf": "Inkluder skjemagenerert pdf", @@ -1792,6 +1804,9 @@ "ux_editor.text_resource_bindings.delete_confirm_question": "Er du sikker på at du vil fjerne denne teksten fra komponenten?", "ux_editor.text_resource_bindings.delete_info": "Merk at selve tekstressursen ikke slettes, kun knytningen fra denne komponenten.", "ux_editor.top_bar.export_form": "Eksporter skjema", + "ux_editor.top_bar.featureFlag_addComponentModal.helpText": "Vi tester en ny måte å legge til komponenter på, der vi fjerner kolonnen med komponenter på venstre side, og legger til komponenter direkte i sidevisningen. Du kan prøve den nye visningen ved å aktivere funksjonen under. Send gjerne tilbakemeldinger på Slack!", + "ux_editor.top_bar.featureFlag_addComponentModal.helpText_label": "Mer informasjon om: Prøv ny visning", + "ux_editor.top_bar.featureFlag_addComponentModal.title": "Prøv ny visning", "ux_editor.unknown_group_reference": "Referansen med ID {{id}} er ugyldig, da det ikke eksisterer noen komponent med denne ID-en. Vennligst slett denne referansen for å rette feilen.", "ux_editor.unknown_group_reference_help_text_title": "Ukjent referanse", "ux_editor.unsupported_version_message.too_new_1": "Denne siden er foreløpig ikke tilgjengelig for apper som kjører på {{version}} av app-frontend. Dette er fordi det er en del konfigurasjon i skjemaene som endrer seg fra {{closestSupportedVersion}} til {{version}}.", diff --git a/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.test.ts b/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.test.ts index a797b0cfbbb..9925795ee0b 100644 --- a/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.test.ts +++ b/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.test.ts @@ -81,4 +81,32 @@ describe('objectUtils', () => { expect(ObjectUtils.flattenObjectValues(object)).toEqual(['value1', 'value2', 'value3']); }); }); + + describe('sortEntriesInObjectByKeys', () => { + it('Sorts all entries in an object by its keys', () => { + const unsortedObject = { b: 'value1', a: 'value2', c: 'value3' }; + const sortedObject = ObjectUtils.sortEntriesInObjectByKeys(unsortedObject); + const sortedObjectKeys = Object.keys(sortedObject); + expect(sortedObjectKeys[0]).toBe('a'); + expect(sortedObjectKeys[1]).toBe('b'); + expect(sortedObjectKeys[2]).toBe('c'); + expect(sortedObject).toEqual(unsortedObject); + }); + + it('Returns same order if entries in object is already sorted', () => { + const unsortedObject = { a: 'value1', b: 'value2', c: 'value3' }; + const sortedObject = ObjectUtils.sortEntriesInObjectByKeys(unsortedObject); + const sortedObjectKeys = Object.keys(sortedObject); + expect(sortedObjectKeys[0]).toBe('a'); + expect(sortedObjectKeys[1]).toBe('b'); + expect(sortedObjectKeys[2]).toBe('c'); + expect(sortedObject).toEqual(unsortedObject); + }); + + it('Returns empty list if entries to sort is empty', () => { + const emptyObject = {}; + const sortedObject = ObjectUtils.sortEntriesInObjectByKeys(emptyObject); + expect(sortedObject).toEqual({}); + }); + }); }); diff --git a/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.ts b/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.ts index e911317a8f5..9f15a9743de 100644 --- a/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.ts +++ b/frontend/libs/studio-pure-functions/src/ObjectUtils/ObjectUtils.ts @@ -44,4 +44,14 @@ export class ObjectUtils { }) .flat(); }; + + /** + * Sorts all entries in an object by its keys. + * @param object The object to sort. + * @returns A new object with the entries sorted by key. + */ + static sortEntriesInObjectByKeys = (object: T): T => + Object.fromEntries( + Object.entries(object).sort(([keyA], [keyB]) => keyA.localeCompare(keyB)), + ) as T; } diff --git a/frontend/packages/schema-editor/src/components/SchemaEditor/SchemaEditor.tsx b/frontend/packages/schema-editor/src/components/SchemaEditor/SchemaEditor.tsx index d4ba814fcc1..b268d47ce47 100644 --- a/frontend/packages/schema-editor/src/components/SchemaEditor/SchemaEditor.tsx +++ b/frontend/packages/schema-editor/src/components/SchemaEditor/SchemaEditor.tsx @@ -34,12 +34,12 @@ export const SchemaEditor = () => { orientation='horizontal' localStorageContext={`datamodel:${user.id}:${org}`} > - + - +
diff --git a/frontend/packages/shared/src/api/mutations.ts b/frontend/packages/shared/src/api/mutations.ts index d09c54f5dbb..b6f5205313d 100644 --- a/frontend/packages/shared/src/api/mutations.ts +++ b/frontend/packages/shared/src/api/mutations.ts @@ -42,6 +42,7 @@ import { addImagePath, optionListUploadPath, optionListUpdatePath, + optionListIdUpdatePath, processEditorPath, selectedMaskinportenScopesPath, } from 'app-shared/api/paths'; @@ -120,6 +121,7 @@ export const updateAppConfig = (org: string, app: string, payload: AppConfig) => export const uploadDataModel = (org: string, app: string, form: FormData) => post(dataModelsUploadPath(org, app), form, { headers: { 'Content-Type': 'multipart/form-data' } }); export const uploadOptionList = (org: string, app: string, payload: FormData) => post(optionListUploadPath(org, app), payload, { headers: { 'Content-Type': 'multipart/form-data' } }); export const updateOptionList = (org: string, app: string, optionsListId: string, payload: Option[]) => put(optionListUpdatePath(org, app, optionsListId), payload); +export const updateOptionListId = (org: string, app: string, optionsListId: string, newOptionsListId: string) => put(optionListIdUpdatePath(org, app, optionsListId), JSON.stringify(newOptionsListId), { headers: { 'Content-Type': 'application/json' } }); export const upsertTextResources = (org: string, app: string, language: string, payload: ITextResourcesObjectFormat) => put(textResourcesPath(org, app, language), payload); // Resourceadm diff --git a/frontend/packages/shared/src/api/paths.js b/frontend/packages/shared/src/api/paths.js index 8e3d0b832f3..e5198d24394 100644 --- a/frontend/packages/shared/src/api/paths.js +++ b/frontend/packages/shared/src/api/paths.js @@ -41,6 +41,7 @@ export const widgetSettingsPath = (org, app) => `${basePath}/${org}/${app}/app-d export const optionListsPath = (org, app) => `${basePath}/${org}/${app}/options/option-lists`; // Get export const optionListIdsPath = (org, app) => `${basePath}/${org}/${app}/app-development/option-list-ids`; // Get export const optionListUpdatePath = (org, app, optionsListId) => `${basePath}/${org}/${app}/options/${optionsListId}`; // Put +export const optionListIdUpdatePath = (org, app, optionsListId) => `${basePath}/${org}/${app}/options/change-name/${optionsListId}`; // Put export const optionListUploadPath = (org, app) => `${basePath}/${org}/${app}/options/upload`; // Post export const ruleConfigPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/rule-config?${s({ layoutSetName })}`; // Get, Post export const appMetadataModelIdsPath = (org, app, onlyUnReferenced) => `${basePath}/${org}/${app}/app-development/model-ids?${s({ onlyUnReferenced })}`; // Get diff --git a/frontend/packages/shared/src/api/queries.ts b/frontend/packages/shared/src/api/queries.ts index 2fa91a8c041..57002072126 100644 --- a/frontend/packages/shared/src/api/queries.ts +++ b/frontend/packages/shared/src/api/queries.ts @@ -88,6 +88,7 @@ import type { RepoDiffResponse } from 'app-shared/types/api/RepoDiffResponse'; import type { ExternalImageUrlValidationResponse } from 'app-shared/types/api/ExternalImageUrlValidationResponse'; import type { MaskinportenScopes } from 'app-shared/types/MaskinportenScope'; import type { OptionsLists } from 'app-shared/types/api/OptionsLists'; +import type { LayoutSetsModel } from '../types/api/dto/LayoutSetsModel'; export const getIsLoggedInWithAnsattporten = () => get<{ isLoggedIn: boolean }>(authStatusAnsattporten()); export const getMaskinportenScopes = (org: string, app: string) => get(availableMaskinportenScopesPath(org, app)); @@ -112,6 +113,7 @@ export const getImageFileNames = (owner: string, app: string) => get(g export const getInstanceIdForPreview = (owner: string, app: string) => get(instanceIdForPreviewPath(owner, app)); export const getLayoutNames = (owner: string, app: string) => get(layoutNamesPath(owner, app)); export const getLayoutSets = (owner: string, app: string) => get(layoutSetsPath(owner, app)); +export const getLayoutSetsExtended = (owner: string, app: string) => get(layoutSetsPath(owner, app) + '/extended'); export const getOptionLists = (owner: string, app: string) => get(optionListsPath(owner, app)); export const getOptionListIds = (owner: string, app: string) => get(optionListIdsPath(owner, app)); export const getOrgList = () => get(orgListUrl()); diff --git a/frontend/packages/shared/src/hooks/mutations/index.ts b/frontend/packages/shared/src/hooks/mutations/index.ts index 719790e13c5..d23c7a9b268 100644 --- a/frontend/packages/shared/src/hooks/mutations/index.ts +++ b/frontend/packages/shared/src/hooks/mutations/index.ts @@ -1,5 +1,6 @@ export { useAddOptionListMutation } from './useAddOptionListMutation'; export { useUpdateOptionListMutation } from './useUpdateOptionListMutation'; +export { useUpdateOptionListIdMutation } from './useUpdateOptionListIdMutation'; export { useUpsertTextResourcesMutation } from './useUpsertTextResourcesMutation'; export { useUpsertTextResourceMutation } from './useUpsertTextResourceMutation'; export { useRepoCommitAndPushMutation } from './useRepoCommitAndPushMutation'; diff --git a/frontend/packages/shared/src/hooks/mutations/useUpdateOptionListIdMutation.test.ts b/frontend/packages/shared/src/hooks/mutations/useUpdateOptionListIdMutation.test.ts new file mode 100644 index 00000000000..04d4a9551fd --- /dev/null +++ b/frontend/packages/shared/src/hooks/mutations/useUpdateOptionListIdMutation.test.ts @@ -0,0 +1,81 @@ +import { app, org } from '@studio/testing/testids'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { renderHookWithProviders } from 'app-shared/mocks/renderHookWithProviders'; +import type { UpdateOptionListIdMutationArgs } from './useUpdateOptionListIdMutation'; +import { useUpdateOptionListIdMutation } from './useUpdateOptionListIdMutation'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import type { Option } from 'app-shared/types/Option'; +import type { OptionsLists } from 'app-shared/types/api/OptionsLists'; + +// Test data: +const optionListId: string = 'optionListId'; +const newOptionListId: string = 'newOptionListId'; +const optionListMock: Option[] = [{ value: 'value', label: 'label' }]; +const args: UpdateOptionListIdMutationArgs = { optionListId, newOptionListId }; + +describe('useUpdateOptionListIdMutation', () => { + test('Calls useUpdateOptionIdList with correct parameters', async () => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.OptionLists, org, app], [{ optionListId: optionListMock }]); + const renderUpdateOptionListMutationResult = renderHookWithProviders( + () => useUpdateOptionListIdMutation(org, app), + { queryClient }, + ).result; + await renderUpdateOptionListMutationResult.current.mutateAsync(args); + expect(queriesMock.updateOptionListId).toHaveBeenCalledTimes(1); + expect(queriesMock.updateOptionListId).toHaveBeenCalledWith( + org, + app, + optionListId, + newOptionListId, + ); + }); + + test('Sets the option lists cache with new id in correct alphabetical order', async () => { + const optionListA = 'optionListA'; + const optionListB = 'optionListB'; + const optionListC = 'optionListC'; + const optionListZ = 'optionListZ'; + const queryClient = createQueryClientMock(); + const oldData: OptionsLists = { + optionListA: optionListMock, + optionListB: optionListMock, + optionListZ: optionListMock, + }; + queryClient.setQueryData([QueryKey.OptionLists, org, app], oldData); + const renderUpdateOptionListMutationResult = renderHookWithProviders( + () => useUpdateOptionListIdMutation(org, app), + { queryClient }, + ).result; + await renderUpdateOptionListMutationResult.current.mutateAsync({ + optionListId: optionListA, + newOptionListId: optionListC, + }); + const cacheData = queryClient.getQueryData([QueryKey.OptionLists, org, app]); + const cacheDataKeys = Object.keys(cacheData); + expect(cacheDataKeys[0]).toEqual(optionListB); + expect(cacheDataKeys[1]).toEqual(optionListC); + expect(cacheDataKeys[2]).toEqual(optionListZ); + }); + + test('Invalidates the optionListIds query cache', async () => { + const queryClient = createQueryClientMock(); + const invalidateQueriesSpy = jest.spyOn(queryClient, 'invalidateQueries'); + const oldData: OptionsLists = { + firstOptionList: optionListMock, + optionListId: optionListMock, + lastOptionList: optionListMock, + }; + queryClient.setQueryData([QueryKey.OptionLists, org, app], oldData); + const renderUpdateOptionListMutationResult = renderHookWithProviders( + () => useUpdateOptionListIdMutation(org, app), + { queryClient }, + ).result; + await renderUpdateOptionListMutationResult.current.mutateAsync(args); + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(1); + expect(invalidateQueriesSpy).toHaveBeenCalledWith({ + queryKey: [QueryKey.OptionListIds, org, app], + }); + }); +}); diff --git a/frontend/packages/shared/src/hooks/mutations/useUpdateOptionListIdMutation.ts b/frontend/packages/shared/src/hooks/mutations/useUpdateOptionListIdMutation.ts new file mode 100644 index 00000000000..d56dddb6c51 --- /dev/null +++ b/frontend/packages/shared/src/hooks/mutations/useUpdateOptionListIdMutation.ts @@ -0,0 +1,41 @@ +import { QueryKey } from 'app-shared/types/QueryKey'; +import type { OptionsLists } from 'app-shared/types/api/OptionsLists'; +import { useQueryClient, useMutation } from '@tanstack/react-query'; +import { useServicesContext } from 'app-shared/contexts/ServicesContext'; +import { ObjectUtils } from '@studio/pure-functions'; + +export interface UpdateOptionListIdMutationArgs { + optionListId: string; + newOptionListId: string; +} + +export const useUpdateOptionListIdMutation = (org: string, app: string) => { + const queryClient = useQueryClient(); + const { updateOptionListId } = useServicesContext(); + + return useMutation({ + mutationFn: async ({ optionListId, newOptionListId }: UpdateOptionListIdMutationArgs) => { + return updateOptionListId(org, app, optionListId, newOptionListId).then(() => ({ + optionListId, + newOptionListId, + })); + }, + onSuccess: ({ optionListId, newOptionListId }) => { + const oldData: OptionsLists = queryClient.getQueryData([QueryKey.OptionLists, org, app]); + const ascSortedData = changeIdAndSortCacheData(optionListId, newOptionListId, oldData); + queryClient.setQueryData([QueryKey.OptionLists, org, app], ascSortedData); + queryClient.invalidateQueries({ queryKey: [QueryKey.OptionListIds, org, app] }); + }, + }); +}; + +const changeIdAndSortCacheData = ( + oldId: string, + newId: string, + oldData: OptionsLists, +): OptionsLists => { + const newData = { ...oldData }; + delete newData[oldId]; + newData[newId] = oldData[oldId]; + return ObjectUtils.sortEntriesInObjectByKeys(newData); +}; diff --git a/frontend/packages/shared/src/hooks/queries/useLayoutSetsExtendedQuery.ts b/frontend/packages/shared/src/hooks/queries/useLayoutSetsExtendedQuery.ts new file mode 100644 index 00000000000..1fcb729d698 --- /dev/null +++ b/frontend/packages/shared/src/hooks/queries/useLayoutSetsExtendedQuery.ts @@ -0,0 +1,15 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { useServicesContext } from '../../contexts/ServicesContext'; +import type { LayoutSetsModel } from '../../types/api/dto/LayoutSetsModel'; +import { QueryKey } from '../../types/QueryKey'; + +export const useLayoutSetsExtendedQuery = ( + org: string, + app: string, +): UseQueryResult => { + const { getLayoutSetsExtended } = useServicesContext(); + return useQuery({ + queryKey: [QueryKey.LayoutSetsExtended, org, app], + queryFn: () => getLayoutSetsExtended(org, app), + }); +}; diff --git a/frontend/packages/shared/src/mocks/queriesMock.ts b/frontend/packages/shared/src/mocks/queriesMock.ts index 12c74c2a180..6efd4b3f207 100644 --- a/frontend/packages/shared/src/mocks/queriesMock.ts +++ b/frontend/packages/shared/src/mocks/queriesMock.ts @@ -70,6 +70,8 @@ import type { RepoDiffResponse } from 'app-shared/types/api/RepoDiffResponse'; import type { ExternalImageUrlValidationResponse } from 'app-shared/types/api/ExternalImageUrlValidationResponse'; import type { MaskinportenScope } from 'app-shared/types/MaskinportenScope'; import type { OptionsLists } from 'app-shared/types/api/OptionsLists'; +import type { LayoutSetsModel } from '../types/api/dto/LayoutSetsModel'; +import { layoutSetsExtendedMock } from '@altinn/ux-editor/testing/layoutSetsMock'; export const queriesMock: ServicesContextProps = { // Queries @@ -103,6 +105,9 @@ export const queriesMock: ServicesContextProps = { getInstanceIdForPreview: jest.fn().mockImplementation(() => Promise.resolve('')), getLayoutNames: jest.fn().mockImplementation(() => Promise.resolve([])), getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve(layoutSets)), + getLayoutSetsExtended: jest + .fn() + .mockImplementation(() => Promise.resolve(layoutSetsExtendedMock)), getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve([])), getOptionLists: jest.fn().mockImplementation(() => Promise.resolve({})), getOrgList: jest.fn().mockImplementation(() => Promise.resolve(orgList)), @@ -224,6 +229,7 @@ export const queriesMock: ServicesContextProps = { updateAppMetadata: jest.fn().mockImplementation(() => Promise.resolve()), updateAppConfig: jest.fn().mockImplementation(() => Promise.resolve()), updateOptionList: jest.fn().mockImplementation(() => Promise.resolve()), + updateOptionListId: jest.fn().mockImplementation(() => Promise.resolve()), uploadDataModel: jest.fn().mockImplementation(() => Promise.resolve({})), uploadOptionList: jest.fn().mockImplementation(() => Promise.resolve()), upsertTextResources: jest diff --git a/frontend/packages/shared/src/types/QueryKey.ts b/frontend/packages/shared/src/types/QueryKey.ts index 49c0dd502e9..5195acbf358 100644 --- a/frontend/packages/shared/src/types/QueryKey.ts +++ b/frontend/packages/shared/src/types/QueryKey.ts @@ -26,6 +26,7 @@ export enum QueryKey { LayoutNames = 'LayoutNames', LayoutSchema = 'LayoutSchema', LayoutSets = 'LayoutSets', + LayoutSetsExtended = 'LayoutSetsExtended', OptionLists = 'OptionLists', OptionListIds = 'OptionListIds', OrgList = 'OrgList', diff --git a/frontend/packages/shared/src/types/api/dto/LayoutSetModel.ts b/frontend/packages/shared/src/types/api/dto/LayoutSetModel.ts new file mode 100644 index 00000000000..ef271fd8f55 --- /dev/null +++ b/frontend/packages/shared/src/types/api/dto/LayoutSetModel.ts @@ -0,0 +1,8 @@ +import type { TaskModel } from './TaskModel'; + +export type LayoutSetModel = { + id: string; + dataType: string; + type: string; + task: TaskModel; +}; diff --git a/frontend/packages/shared/src/types/api/dto/LayoutSetsModel.ts b/frontend/packages/shared/src/types/api/dto/LayoutSetsModel.ts new file mode 100644 index 00000000000..01e68fc8342 --- /dev/null +++ b/frontend/packages/shared/src/types/api/dto/LayoutSetsModel.ts @@ -0,0 +1,3 @@ +import type { LayoutSetModel } from './LayoutSetModel'; + +export type LayoutSetsModel = { sets: LayoutSetModel[] }; diff --git a/frontend/packages/shared/src/types/api/dto/TaskModel.ts b/frontend/packages/shared/src/types/api/dto/TaskModel.ts new file mode 100644 index 00000000000..54d24c075dc --- /dev/null +++ b/frontend/packages/shared/src/types/api/dto/TaskModel.ts @@ -0,0 +1,4 @@ +export type TaskModel = { + id: string; + type: string; +}; diff --git a/frontend/packages/shared/src/utils/layoutSetsUtils.test.ts b/frontend/packages/shared/src/utils/layoutSetsUtils.test.ts index 5ac5627ea46..561b37f29f9 100644 --- a/frontend/packages/shared/src/utils/layoutSetsUtils.test.ts +++ b/frontend/packages/shared/src/utils/layoutSetsUtils.test.ts @@ -1,8 +1,11 @@ import { getLayoutSetIdValidationErrorKey, getLayoutSetNameForCustomReceipt, + getLayoutSetTypeTranslationKey, } from 'app-shared/utils/layoutSetsUtils'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import type { LayoutSetModel } from '../types/api/dto/LayoutSetModel'; +import { PROTECTED_TASK_NAME_CUSTOM_RECEIPT } from '../constants'; // Test data const layoutSetName = 'layoutSet'; @@ -96,3 +99,26 @@ describe('getLayoutSetIdValidationErrorKey', () => { ).toBe(null); }); }); +describe('getLayoutSetTypeTranslationKey', () => { + it('should return "ux_editor.subform" when layoutSet type is "subform"', () => { + const layoutSet: LayoutSetModel = { + id: 'test', + dataType: null, + type: 'subform', + task: { id: null, type: null }, + }; + expect(getLayoutSetTypeTranslationKey(layoutSet)).toBe('ux_editor.subform'); + }); + + it('should return "process_editor.configuration_panel_custom_receipt_accordion_header" when layoutSet task id is "CustomReceipt"', () => { + const layoutSet: LayoutSetModel = { + id: 'test', + dataType: null, + type: null, + task: { id: PROTECTED_TASK_NAME_CUSTOM_RECEIPT, type: '' }, + }; + expect(getLayoutSetTypeTranslationKey(layoutSet)).toBe( + 'process_editor.configuration_panel_custom_receipt_accordion_header', + ); + }); +}); diff --git a/frontend/packages/shared/src/utils/layoutSetsUtils.ts b/frontend/packages/shared/src/utils/layoutSetsUtils.ts index 8c3e0058328..65ff873d8cd 100644 --- a/frontend/packages/shared/src/utils/layoutSetsUtils.ts +++ b/frontend/packages/shared/src/utils/layoutSetsUtils.ts @@ -1,6 +1,7 @@ import { PROTECTED_TASK_NAME_CUSTOM_RECEIPT } from 'app-shared/constants'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; import { validateLayoutNameAndLayoutSetName } from 'app-shared/utils/LayoutAndLayoutSetNameValidationUtils/validateLayoutNameAndLayoutSetName'; +import type { LayoutSetModel } from '../types/api/dto/LayoutSetModel'; export const getLayoutSetNameForCustomReceipt = (layoutSets: LayoutSets): string | undefined => { return layoutSets?.sets?.find((set) => set.tasks?.includes(PROTECTED_TASK_NAME_CUSTOM_RECEIPT)) @@ -21,3 +22,15 @@ export const getLayoutSetIdValidationErrorKey = ( return 'process_editor.configuration_panel_layout_set_id_not_unique'; return null; }; + +export const getLayoutSetTypeTranslationKey = (layoutSet: LayoutSetModel): string => { + if (layoutSet.type === 'subform') return 'ux_editor.subform'; + if (layoutSet.task?.type === '' && layoutSet.task?.id === PROTECTED_TASK_NAME_CUSTOM_RECEIPT) { + return 'process_editor.configuration_panel_custom_receipt_accordion_header'; + } + if (layoutSet.task?.type) { + return `process_editor.task_type.${layoutSet.task.type}`; + } + + return ''; +}; diff --git a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.test.tsx b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.test.tsx index 91c4f3d33e4..258cb6bd773 100644 --- a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.test.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.test.tsx @@ -8,6 +8,7 @@ import { layoutSet1NameMock, layoutSet2NameMock, layoutSet3SubformNameMock, + layoutSetsExtendedMock, layoutSetsMock, } from '../../testing/layoutSetsMock'; import { QueryKey } from 'app-shared/types/QueryKey'; @@ -18,6 +19,8 @@ import { removeFeatureFlagFromLocalStorage, } from 'app-shared/utils/featureToggleUtils'; import { textMock } from '@studio/testing/mocks/i18nMock'; +import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import type { LayoutSetsModel } from 'app-shared/types/api/dto/LayoutSetsModel'; // Test data const layoutSetName1 = layoutSet1NameMock; @@ -31,12 +34,16 @@ describe('LayoutSetsContainer', () => { const combobox = screen.getByRole('combobox'); await user.click(combobox); - expect(await screen.findByRole('option', { name: layoutSetName1 })).toBeInTheDocument(); - expect(await screen.findByRole('option', { name: layoutSetName2 })).toBeInTheDocument(); + expect( + await screen.findByRole('option', { name: new RegExp(layoutSetName1 + ' ') }), + ).toBeInTheDocument(); + expect( + await screen.findByRole('option', { name: new RegExp(layoutSetName2 + ' ') }), + ).toBeInTheDocument(); }); it('should not render combobox when there are no layoutSets', async () => { - render({ sets: null }); + render({ layoutSets: null, layoutSetsExtended: null }); expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); }); @@ -45,7 +52,7 @@ describe('LayoutSetsContainer', () => { const user = userEvent.setup(); const combobox = screen.getByRole('combobox'); await user.click(combobox); - await user.click(screen.getByRole('option', { name: layoutSetName2 })); + await user.click(screen.getByRole('option', { name: new RegExp(layoutSetName2 + ' ') })); await waitFor(() => expect(appContextMock.setSelectedFormLayoutSetName).toHaveBeenCalledTimes(1), @@ -59,10 +66,10 @@ describe('LayoutSetsContainer', () => { it('should render the delete subform button when feature is enabled and selected layoutset is a subform', () => { addFeatureFlagToLocalStorage('subform'); - render( - { sets: [{ id: layoutSet3SubformNameMock, type: 'subform' }] }, - { selectedlayoutSet: layoutSet3SubformNameMock }, - ); + render({ + layoutSets: { sets: [{ id: layoutSet3SubformNameMock, type: 'subform' }] }, + selectedLayoutSet: layoutSet3SubformNameMock, + }); const deleteSubformButton = screen.getByRole('button', { name: textMock('ux_editor.delete.subform'), }); @@ -72,10 +79,10 @@ describe('LayoutSetsContainer', () => { it('should not render the delete subform button when feature is enabled and selected layoutset is not a subform', () => { addFeatureFlagToLocalStorage('subform'); - render( - { sets: [{ id: layoutSet1NameMock, dataType: 'data-model' }] }, - { selectedlayoutSet: layoutSet1NameMock }, - ); + render({ + layoutSets: { sets: [{ id: layoutSet1NameMock, dataType: 'data-model' }] }, + selectedLayoutSet: layoutSet1NameMock, + }); const deleteSubformButton = screen.queryByRole('button', { name: textMock('ux_editor.delete.subform'), }); @@ -92,8 +99,19 @@ describe('LayoutSetsContainer', () => { }); }); -const render = (layoutSetsData = layoutSetsMock, options: { selectedlayoutSet?: string } = {}) => { - queryClientMock.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsData); - appContextMock.selectedFormLayoutSetName = options.selectedlayoutSet || layoutSetName1; +type renderProps = { + layoutSets?: LayoutSets; + layoutSetsExtended?: LayoutSetsModel; + selectedLayoutSet?: string; +}; + +const render = ({ + layoutSets = layoutSetsMock, + layoutSetsExtended = layoutSetsExtendedMock, + selectedLayoutSet = layoutSetName1, +}: renderProps = {}) => { + queryClientMock.setQueryData([QueryKey.LayoutSets, org, app], layoutSets); + queryClientMock.setQueryData([QueryKey.LayoutSetsExtended, org, app], layoutSetsExtended); + appContextMock.selectedFormLayoutSetName = selectedLayoutSet; return renderWithProviders(); }; diff --git a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx index 64a0bb3d73f..5604810d357 100644 --- a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx @@ -1,18 +1,21 @@ import React, { useEffect } from 'react'; -import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; -import { useText, useAppContext } from '../../hooks'; +import { useAppContext } from '../../hooks'; import classes from './LayoutSetsContainer.module.css'; import { ExportForm } from './ExportForm'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; import { StudioCombobox } from '@studio/components'; import { DeleteSubformWrapper } from './Subform/DeleteSubformWrapper'; +import { useLayoutSetsExtendedQuery } from 'app-shared/hooks/queries/useLayoutSetsExtendedQuery'; +import { getLayoutSetTypeTranslationKey } from 'app-shared/utils/layoutSetsUtils'; +import { useTranslation } from 'react-i18next'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; export function LayoutSetsContainer() { const { org, app } = useStudioEnvironmentParams(); const { data: layoutSetsResponse } = useLayoutSetsQuery(org, app); - const layoutSets = layoutSetsResponse?.sets; - const t = useText(); + const { data: layoutSets } = useLayoutSetsExtendedQuery(org, app); + const { t } = useTranslation(); const { selectedFormLayoutSetName, setSelectedFormLayoutSetName, @@ -26,7 +29,7 @@ export function LayoutSetsContainer() { onLayoutSetNameChange(selectedFormLayoutSetName); }, [onLayoutSetNameChange, selectedFormLayoutSetName]); - if (!layoutSets) return null; + if (!layoutSetsResponse || !layoutSets) return null; const handleLayoutSetChange = async (layoutSetName: string) => { if (selectedFormLayoutSetName !== layoutSetName && layoutSetName) { @@ -47,11 +50,11 @@ export function LayoutSetsContainer() { value={[selectedFormLayoutSetName]} onValueChange={([value]) => handleLayoutSetChange(value)} > - {layoutSets.map((layoutSet) => ( + {layoutSets.sets.map((layoutSet) => ( {layoutSet.id} diff --git a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx index 664b84cdd1f..61cca8fc1c3 100644 --- a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx +++ b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx @@ -54,6 +54,7 @@ export const FormComponentConfig = ({ 'dataTypeIds', 'target', 'tableColumns', + 'overrides', ]; const booleanPropertyKeys: string[] = getSupportedPropertyKeysForPropertyType( diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.module.css new file mode 100644 index 00000000000..5f01cdc7057 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.module.css @@ -0,0 +1,8 @@ +.addItemButtons { + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 20px auto; + background-color: var(--fds-semantic-surface-neutral-subtle); + width: 100%; +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.test.tsx new file mode 100644 index 00000000000..e43defd97c7 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { AddItem, type AddItemProps } from './AddItem'; +import { renderWithProviders } from '../../../testing/mocks'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import userEvent from '@testing-library/user-event'; + +describe('AddItem', () => { + it('should render AddItem', () => { + renderAddItem({}); + expect( + screen.getByRole('button', { name: textMock('ux_editor.add_item.add_component') }), + ).toBeInTheDocument(); + }); + + it('clicking add component should show default components', async () => { + const user = userEvent.setup(); + renderAddItem({}); + const addButton = screen.getByRole('button', { + name: textMock('ux_editor.add_item.add_component'), + }); + await user.click(addButton); + expect( + await screen.findByText(textMock('ux_editor.add_item.select_component_header')), + ).toBeInTheDocument(); + }); +}); + +const renderAddItem = (props: Partial) => { + const defaultProps: AddItemProps = { + containerId: BASE_CONTAINER_ID, + layout: { + order: { + BASE_CONTAINER_ID: [], + }, + containers: { + [BASE_CONTAINER_ID]: { + type: null, + itemType: 'CONTAINER', + id: BASE_CONTAINER_ID, + }, + }, + components: {}, + customDataProperties: {}, + customRootProperties: {}, + }, + }; + return renderWithProviders(); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemModal.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.tsx similarity index 50% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemModal.tsx rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.tsx index d545ea513f4..f02f154f9a5 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemModal.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItem.tsx @@ -1,7 +1,7 @@ -import React, { useCallback, useRef } from 'react'; +import React from 'react'; import { addItemOfType, - getAvailableChildComponentsForContainer, + getDefaultChildComponentsForContainer, getItem, } from '../../../utils/formLayoutUtils'; import { useAddItemToLayoutMutation } from '../../../hooks/mutations/useAddItemToLayoutMutation'; @@ -10,30 +10,30 @@ import { useAppContext } from '../../../hooks'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import type { IInternalLayout } from '../../../types/global'; import type { ComponentType, CustomComponentType } from 'app-shared/types/ComponentType'; -import { StudioButton, StudioModal } from '@studio/components'; +import { StudioButton } from '@studio/components'; import type { AddedItem } from './types'; -import { BASE_CONTAINER_ID } from 'app-shared/constants'; -import { AddItemContent } from './AddItemContent'; -import { PlusCircleIcon } from '@studio/icons'; +import { PlusIcon } from '@studio/icons'; import { usePreviewContext } from 'app-development/contexts/PreviewContext'; +import { DefaultItems } from './DefaultItems'; +import { AddItemModal } from './AddItemModal'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import classes from './AddItem.module.css'; +import { useTranslation } from 'react-i18next'; export type AddItemProps = { containerId: string; layout: IInternalLayout; }; -export const AddItemModal = ({ containerId, layout }: AddItemProps) => { - const [selectedItem, setSelectedItem] = React.useState(null); +export const AddItem = ({ containerId, layout }: AddItemProps) => { + const [showDefaultComponents, setShowDefaultComponents] = React.useState(false); const { doReloadPreview } = usePreviewContext(); - const handleCloseModal = () => { - setSelectedItem(null); - modalRef.current?.close(); - }; const { handleEdit } = useFormItemContext(); const { org, app } = useStudioEnvironmentParams(); const { selectedFormLayoutSetName } = useAppContext(); + const { t } = useTranslation(['translation', 'addComponentModal']); const { mutate: addItemToLayout } = useAddItemToLayoutMutation( org, @@ -41,8 +41,6 @@ export const AddItemModal = ({ containerId, layout }: AddItemProps) => { selectedFormLayoutSetName, ); - const modalRef = useRef(null); - const addItem = ( type: ComponentType | CustomComponentType, parentId: string, @@ -69,40 +67,57 @@ export const AddItemModal = ({ containerId, layout }: AddItemProps) => { layout.order[containerId].length, addedItem.componentId, ); - handleCloseModal(); }; - const handleOpenModal = useCallback(() => { - modalRef.current?.showModal(); - }, []); + const handleShowDefaultComponents = () => { + setShowDefaultComponents(true); + }; + + const handleHideDefaultComponents = () => { + setShowDefaultComponents(false); + }; + + const defaultComponents = getDefaultChildComponentsForContainer(layout, containerId); + const shouldShowAllComponentsButton = defaultComponents.length > 8; return ( -
- +
+ {!showDefaultComponents && ( } - onClick={handleOpenModal} + icon={} + onClick={handleShowDefaultComponents} variant='tertiary' - fullWidth + fullWidth={containerId === BASE_CONTAINER_ID} > - Legg til komponent + {t('ux_editor.add_item.add_component')} - - + + ) + } /> - - +
+ )}
); }; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemContent.module.css similarity index 81% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.module.css rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemContent.module.css index 1b78dd45868..d4c0fe70713 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.module.css +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemContent.module.css @@ -2,18 +2,12 @@ display: flex; flex-direction: row; flex-wrap: wrap; - height: 100%; + height: 75vh; flex: 3; padding: 20px; overflow-y: scroll; } -.componentButton { - height: 50px; - width: 180px; - margin: 8px; -} - .componentCategory { padding-top: 12px; } @@ -27,7 +21,7 @@ background-color: var(--fds-semantic-surface-info-subtle); padding: 20px; height: 600px; - position: sticky; + max-width: 500px; } .root { diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemContent.tsx similarity index 85% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.tsx rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemContent.tsx index def0d76a6f0..8703131ce45 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/AddItemContent.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemContent.tsx @@ -9,6 +9,7 @@ import { ItemInfo } from './ItemInfo'; import { useFormLayouts } from '../../../hooks'; import { generateComponentId } from '../../../utils/generateId'; import { StudioParagraph } from '@studio/components'; +import { useTranslation } from 'react-i18next'; export type AddItemContentProps = { item: AddedItem | null; @@ -26,12 +27,13 @@ export const AddItemContent = ({ availableComponents, }: AddItemContentProps) => { const layouts = useFormLayouts(); + const { t } = useTranslation(['translation', 'addComponentModal']); return (
- Klikk på en komponent for å se mer informasjon om den. + {t('ux_editor.add_item.component_more_info_description')} {Object.keys(availableComponents).map((key) => { return ( @@ -47,13 +49,7 @@ export const AddItemContent = ({ })}
- generateComponentId(type, layouts)} - item={item} - setItem={setItem} - /> +
); diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.module.css new file mode 100644 index 00000000000..c714ea08127 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.module.css @@ -0,0 +1,6 @@ +.componentButtonInline { + height: 80px; + width: 140px; + margin: 8px; + padding: 6px; +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.test.tsx new file mode 100644 index 00000000000..9ebba75ff34 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { AddItemModal, type AddItemModalProps } from './AddItemModal'; +import { BASE_CONTAINER_ID } from 'app-shared/constants'; +import { renderWithProviders } from '../../../testing/mocks'; +import { textMock } from '@studio/testing/mocks/i18nMock'; + +describe('AddItemModal', () => { + it('should render add item modal', () => { + renderAddItemModal({}); + expect( + screen.getByRole('button', { name: textMock('ux_editor.add_item.show_all') }), + ).toBeInTheDocument(); + }); + + it('should open modal dialog when show all button is clicked', async () => { + const user = userEvent.setup(); + renderAddItemModal({}); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: textMock('ux_editor.add_item.show_all') })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + }); + + it('should close modal dialog when cancel button is clicked', async () => { + const user = userEvent.setup(); + renderAddItemModal({}); + await user.click(screen.getByRole('button', { name: textMock('ux_editor.add_item.show_all') })); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'close modal' })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +const renderAddItemModal = (props: Partial) => { + const defaultProps: AddItemModalProps = { + containerId: BASE_CONTAINER_ID, + layout: { + order: { + [BASE_CONTAINER_ID]: [], + }, + containers: { + [BASE_CONTAINER_ID]: { + id: BASE_CONTAINER_ID, + itemType: 'CONTAINER', + type: null, + }, + }, + components: {}, + customDataProperties: {}, + customRootProperties: {}, + }, + onAddComponent: jest.fn(), + availableComponents: { + formComponents: [], + }, + }; + return renderWithProviders(); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.tsx new file mode 100644 index 00000000000..7489c7f4d90 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/AddItemModal.tsx @@ -0,0 +1,69 @@ +import React, { useRef } from 'react'; +import { getAvailableChildComponentsForContainer } from '../../../utils/formLayoutUtils'; +import type { IInternalLayout, IToolbarElement } from '../../../types/global'; +import { StudioButton, StudioModal } from '@studio/components'; +import type { AddedItem } from './types'; +import { AddItemContent } from './AddItemContent'; +import { PlusIcon } from '@studio/icons'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import classes from './AddItemModal.module.css'; +import { useTranslation } from 'react-i18next'; + +export type AddItemModalProps = { + containerId: string; + layout: IInternalLayout; + onAddComponent?: (addedItem: AddedItem) => void; + availableComponents?: KeyValuePairs; +}; + +export const AddItemModal = ({ containerId, layout, onAddComponent }: AddItemModalProps) => { + const [selectedItem, setSelectedItem] = React.useState(null); + const handleCloseModal = () => { + setSelectedItem(null); + modalRef.current?.close(); + }; + + const modalRef = useRef(null); + const { t } = useTranslation(['translation', 'addComponentModal']); + + const handleAddComponent = (addedItem: AddedItem) => { + onAddComponent(addedItem); + handleCloseModal(); + }; + + const handleOpenModal = () => { + modalRef.current?.showModal(); + }; + + const availableComponents = getAvailableChildComponentsForContainer(layout, containerId); + + return ( + + +
+ + {t('ux_editor.add_item.show_all')} +
+
+ + + +
+ ); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ComponentButton.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ComponentButton.module.css new file mode 100644 index 00000000000..b3306811c15 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ComponentButton.module.css @@ -0,0 +1,14 @@ +.componentButton { + margin: 4px; + width: 140px; + height: 100px; + padding: 2px; + overflow: wrap; +} + +.componentButtonInline { + height: 80px; + width: 140px; + margin: 8px; + padding: 2px; +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ComponentButton.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ComponentButton.tsx new file mode 100644 index 00000000000..d9904f77e44 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ComponentButton.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { StudioButton } from '@studio/components'; +import classes from './ComponentButton.module.css'; + +type ComponentButtonProps = { + tooltipContent: string; + selected: boolean; + icon: React.ComponentType; + onClick: () => void; + inline?: boolean; +}; +export function ComponentButton({ + tooltipContent, + selected, + icon, + onClick, + inline, +}: ComponentButtonProps) { + return ( + +
+ {React.createElement(icon, { fontSize: '1.5rem' } as any)} + {tooltipContent} +
+
+ ); +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.module.css new file mode 100644 index 00000000000..83a829d4538 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.module.css @@ -0,0 +1,29 @@ +.componentsWrapper { + width: 100%; + display: flex; + flex-direction: row; + flex-wrap: wrap; +} + +.root { + display: flex; + flex-direction: column; + overflow-y: hidden; + height: 100%; + padding-bottom: 12px; + width: 100%; +} + +.closeButtonContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding-right: 24px; + padding-top: 12px; + padding-bottom: 12px; +} + +.header { + padding-left: 12px; +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.test.tsx new file mode 100644 index 00000000000..061a655765a --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.test.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { DefaultItems, type DefaultItemsProps } from './DefaultItems'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import { CircleFillIcon } from '@studio/icons'; +import { StudioButton } from '@studio/components'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { renderWithProviders } from '@altinn/ux-editor/testing/mocks'; + +const availableComponents = [ + { + type: ComponentType.TextArea, + label: 'label', + icon: CircleFillIcon, + }, + { + type: ComponentType.Input, + label: 'label', + icon: CircleFillIcon, + }, +]; + +describe('DefaultItems', () => { + it('should render default items', () => { + renderDefaultItems({}); + expect( + screen.getByText(textMock('ux_editor.add_item.select_component_header')), + ).toBeInTheDocument(); + }); + + it('should render all available components', () => { + renderDefaultItems({}); + availableComponents.forEach((component) => { + expect( + screen.getByRole('button', { + name: textMock(`ux_editor.component_title.${component.type}`), + }), + ).toBeInTheDocument(); + }); + }); + + it('should render show all button', () => { + renderDefaultItems({}); + expect(screen.getByRole('button', { name: 'Show all' })).toBeInTheDocument(); + }); +}); + +const renderDefaultItems = (props: Partial) => { + const defaultProps: DefaultItemsProps = { + availableComponents, + onCancel: jest.fn(), + onAddItem: jest.fn(), + showAllButton: Show all, + }; + return renderWithProviders(); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.tsx new file mode 100644 index 00000000000..adfbcd73b91 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/DefaultItems.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import type { IToolbarElement } from '../../../types/global'; +import classes from './DefaultItems.module.css'; +import { StudioButton, StudioHeading } from '@studio/components'; +import { XMarkIcon } from '@studio/icons'; +import type { AddedItem } from './types'; +import { ComponentButton } from './ComponentButton'; +import { useFormLayouts } from '../../../hooks'; +import { generateComponentId } from '@altinn/ux-editor/utils/generateId'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { getTitleByComponentType } from '../../../utils/language'; +import { useTranslation } from 'react-i18next'; + +export type DefaultItemsProps = { + onAddItem: (addedItem: AddedItem) => void; + onCancel: () => void; + availableComponents: IToolbarElement[]; + showAllButton: React.ReactNode; +}; + +export const DefaultItems = ({ + onAddItem, + availableComponents, + onCancel, + showAllButton, +}: DefaultItemsProps) => { + const layouts = useFormLayouts(); + const { t } = useTranslation(['translation', 'addComponentModal']); + + return ( +
+
+ + {t('ux_editor.add_item.select_component_header')} + + } onClick={onCancel} variant='tertiary' /> +
+
+ {availableComponents.map((key) => { + return ( + + onAddItem({ + componentType: key.type, + componentId: generateComponentId(key.type as ComponentType, layouts), + }) + } + inline={true} + /> + ); + })} + {showAllButton} +
+
+ ); +}; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/ItemCategory.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/ItemCategory.module.css new file mode 100644 index 00000000000..de62678aa3a --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/ItemCategory.module.css @@ -0,0 +1,13 @@ +.componentsWrapper { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: start; + align-items: start; +} + +.itemCategory { + margin-bottom: var(--fds-spacing-1); + margin-right: var(--fds-spacing-1); + max-width: calc(50% - 12px); +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/ItemCategory.tsx similarity index 70% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.tsx rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/ItemCategory.tsx index 0a6056cd9c7..a22f6c6d4fd 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/ItemCategory.tsx @@ -1,11 +1,12 @@ import React from 'react'; -import { StudioButton, StudioCard, StudioHeading } from '@studio/components'; +import { StudioCard, StudioHeading } from '@studio/components'; import classes from './ItemCategory.module.css'; import { useTranslation } from 'react-i18next'; import type { IToolbarElement } from '../../../../types/global'; import type { AddedItem } from '../types'; import type { ComponentType, CustomComponentType } from 'app-shared/types/ComponentType'; import { getTitleByComponentType } from '../../../../utils/language'; +import { ComponentButton } from '../ComponentButton'; export type ItemCategoryProps = { items: IToolbarElement[]; @@ -48,25 +49,3 @@ export const ItemCategory = ({ ); }; - -type ComponentButtonProps = { - tooltipContent: string; - selected: boolean; - icon: React.ComponentType; - onClick: () => void; -}; -function ComponentButton({ tooltipContent, selected, icon, onClick }: ComponentButtonProps) { - return ( - - {tooltipContent} - - ); -} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/index.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/index.ts rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemCategory/index.ts diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/ItemInfo.module.css similarity index 86% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.module.css rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/ItemInfo.module.css index 9fc6bf7f4b7..ef2faa786c3 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.module.css +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/ItemInfo.module.css @@ -6,12 +6,6 @@ padding: 20px; } -.componentButton { - height: 50px; - width: 180px; - margin: 8px; -} - .componentCategory { padding-top: 12px; } diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.tsx b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/ItemInfo.tsx similarity index 80% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.tsx rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/ItemInfo.tsx index 6dab7d3eef2..f8a333fc05f 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/ItemInfo.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/ItemInfo.tsx @@ -19,17 +19,10 @@ export type ItemInfoProps = { onAddItem: (item: AddedItem) => void; onCancel: () => void; setItem: (item: AddedItem | null) => void; - generateComponentId: (type: string) => string; }; -export const ItemInfo = ({ - item, - onAddItem, - onCancel, - setItem, - generateComponentId, -}: ItemInfoProps) => { - const { t } = useTranslation(); +export const ItemInfo = ({ item, onAddItem, onCancel, setItem }: ItemInfoProps) => { + const { t } = useTranslation(['translation', 'addComponentModal']); return (
@@ -56,8 +49,10 @@ export const ItemInfo = ({ }} saveButtonText='Legg til' skipButtonText='Avbryt' - title={`Legg til ${getTitleByComponentType(item.componentType, t)}`} - description='Vi lager automatisk en unik ID for komponenten. Du kan endre den her til noe du selv ønsker, eller la den være som den er. Du kan også endre denne id-en senere.' + title={t('ux_editor.add_item.add_component_by_type', { + type: getTitleByComponentType(item.componentType, t), + })} + description={t('ux_editor.add_item.component_info_generated_id_description')} > } diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/index.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemInfo/index.ts rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/ItemInfo/index.ts diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/README.md b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/README.md similarity index 100% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/README.md rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/README.md diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/index.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/index.ts similarity index 55% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/index.ts rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/index.ts index f54ca4f5db0..c1b1bb783b7 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/index.ts +++ b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/index.ts @@ -1 +1,2 @@ export { AddItemModal } from './AddItemModal'; +export { AddItem } from './AddItem'; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/types.ts b/frontend/packages/ux-editor/src/containers/DesignView/AddItem/types.ts similarity index 100% rename from frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/types.ts rename to frontend/packages/ux-editor/src/containers/DesignView/AddItem/types.ts diff --git a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.module.css b/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.module.css deleted file mode 100644 index 0685e31c04e..00000000000 --- a/frontend/packages/ux-editor/src/containers/DesignView/AddItemModal/ItemCategory/ItemCategory.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.componentButton { - margin: 8px; - justify-content: start; - width: 270px; -} - -.componentsWrapper { - display: flex; - flex-direction: column; - height: 100%; - flex-wrap: wrap; - justify-content: start; - align-items: start; -} - -.itemCategory { - margin-bottom: 12px; - margin-right: 12px; - max-width: calc(50% - 12px); -} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx index 95ad44c25e6..36ec06ee09c 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Alert, Paragraph } from '@digdir/designsystemet-react'; import { FormLayoutWarning } from './FormLayoutWarning'; import { BASE_CONTAINER_ID } from 'app-shared/constants'; -import { AddItemModal } from './AddItemModal/AddItemModal'; +import { AddItem } from './AddItem'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; export interface FormLayoutProps { @@ -25,7 +25,7 @@ export const FormLayout = ({ layout, isInvalid, duplicateComponents }: FormLayou {/** The following check and component are added as part of a live user test behind a feature flag. Can be removed if we decide not to use after user test. */} {shouldDisplayFeature('addComponentModal') && ( - + )} ); diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItem.tsx b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItem.tsx index 7817ea38626..7fcfe5f7496 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItem.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItem.tsx @@ -1,7 +1,7 @@ import React from 'react'; import type { IInternalLayout } from '../../../../types/global'; import { getItem, isContainer } from '../../../../utils/formLayoutUtils'; -import { renderItemList } from '../renderItemList'; +import { renderItemList, renderItemListWithAddItemButton } from '../renderItemList'; import { StudioDragAndDropTree } from '@studio/components'; import { FormItemTitle } from './FormItemTitle'; import { formItemConfigs } from '../../../../data/formItemConfig'; @@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'; import { UnknownReferencedItem } from '../UnknownReferencedItem'; import { QuestionmarkDiamondIcon } from '@studio/icons'; import { useComponentTitle } from '@altinn/ux-editor/hooks'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; export type FormItemProps = { layout: IInternalLayout; @@ -37,6 +38,9 @@ export const FormItem = ({ layout, id, duplicateComponents }: FormItemProps) => ); + const shouldDisplayAddButton = + isContainer(layout, id) && shouldDisplayFeature('addComponentModal'); + return ( } @@ -46,7 +50,9 @@ export const FormItem = ({ layout, id, duplicateComponents }: FormItemProps) => labelWrapper={labelWrapper} nodeId={id} > - {renderItemList(layout, duplicateComponents, id)} + {shouldDisplayAddButton + ? renderItemListWithAddItemButton(layout, duplicateComponents, id) + : renderItemList(layout, duplicateComponents, id)} ); }; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/renderItemList.tsx b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/renderItemList.tsx index 2730aead17e..acab0f04ac2 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/renderItemList.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/renderItemList.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { getChildIds } from '../../../utils/formLayoutUtils'; import { FormItem } from './FormItem'; import type { IInternalLayout } from '../../../types/global'; +import { AddItem } from '../AddItem'; export const renderItemList = ( layout: IInternalLayout, @@ -17,3 +18,19 @@ export const renderItemList = ( ) : null; }; + +export const renderItemListWithAddItemButton = ( + layout: IInternalLayout, + duplicateComponents: string[], + parentId: string, +) => { + const childIds = getChildIds(layout, parentId); + return ( + <> + {childIds.map((id) => ( + + ))} + + + ); +}; diff --git a/frontend/packages/ux-editor/src/containers/FormDesignerToolbar.module.css b/frontend/packages/ux-editor/src/containers/FormDesignerToolbar.module.css index e1c0d8b41fc..9c947c58eff 100644 --- a/frontend/packages/ux-editor/src/containers/FormDesignerToolbar.module.css +++ b/frontend/packages/ux-editor/src/containers/FormDesignerToolbar.module.css @@ -3,4 +3,5 @@ display: flex; padding: 8px; border-bottom: 1px solid #c9c9c9; + justify-content: space-between; } diff --git a/frontend/packages/ux-editor/src/data/formItemConfig.ts b/frontend/packages/ux-editor/src/data/formItemConfig.ts index 444d4ea4939..878d3a335a4 100644 --- a/frontend/packages/ux-editor/src/data/formItemConfig.ts +++ b/frontend/packages/ux-editor/src/data/formItemConfig.ts @@ -1,5 +1,5 @@ import type React from 'react'; -import type { RefAttributes, SVGProps } from 'react'; +import { type RefAttributes, type SVGProps } from 'react'; import { ComponentType, CustomComponentType } from 'app-shared/types/ComponentType'; import { FormPanelVariant } from 'app-shared/types/FormPanelVariant'; import { @@ -564,6 +564,18 @@ export type ComponentCategory = | 'attachment' | 'advanced'; +export const defaultComponents: ComponentType[] = [ + ComponentType.Input, + ComponentType.TextArea, + ComponentType.RadioButtons, + ComponentType.Dropdown, + ComponentType.Datepicker, + ComponentType.FileUpload, + ComponentType.Header, + ComponentType.Paragraph, + ComponentType.Button, +]; + export const allComponents: KeyValuePairs = { form: [ComponentType.Input, ComponentType.TextArea, ComponentType.Datepicker], select: [ @@ -599,6 +611,7 @@ export const allComponents: KeyValuePairs = { ComponentType.Grid, ComponentType.Accordion, ComponentType.AccordionGroup, + ComponentType.ButtonGroup, ComponentType.List, ComponentType.RepeatingGroup, ], diff --git a/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts b/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts index 03176a71552..e19768e3a44 100644 --- a/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts +++ b/frontend/packages/ux-editor/src/testing/layoutSetsMock.ts @@ -1,3 +1,4 @@ +import type { LayoutSetsModel } from 'app-shared/types/api/dto/LayoutSetsModel'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; export const dataModelNameMock = 'test-data-model'; @@ -24,3 +25,26 @@ export const layoutSetsMock: LayoutSets = { }, ], }; + +export const layoutSetsExtendedMock: LayoutSetsModel = { + sets: [ + { + id: layoutSet1NameMock, + dataType: 'data-model', + type: null, + task: { id: 'Task_1', type: 'data' }, + }, + { + id: layoutSet2NameMock, + dataType: 'data-model-2', + type: null, + task: { id: 'Task_2', type: 'data' }, + }, + { + id: layoutSet3SubformNameMock, + dataType: 'data-model-3', + type: 'subform', + task: null, + }, + ], +}; diff --git a/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx b/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx index 676fbac8a86..3a8270f6323 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx +++ b/frontend/packages/ux-editor/src/utils/formLayoutUtils.test.tsx @@ -25,6 +25,8 @@ import { getAllDescendants, getAllFormItemIds, getAllLayoutComponents, + getAvailableChildComponentsForContainer, + getDefaultChildComponentsForContainer, } from './formLayoutUtils'; import { ComponentType } from 'app-shared/types/ComponentType'; import type { IInternalLayout } from '../types/global'; @@ -42,6 +44,7 @@ import { internalLayoutWithMultiPageGroup, } from '../testing/layoutWithMultiPageGroupMocks'; import { containerComponentTypes } from '../data/containerComponentTypes'; +import { allComponents, defaultComponents, formItemConfigs } from '../data/formItemConfig'; // Test data: const baseContainer: FormContainer = { @@ -687,6 +690,62 @@ describe('formLayoutUtils', () => { ]); }); + describe('getAvailableChildComponentsForContainer', () => { + it('Returns all component categories for the base container', () => { + const layout = { ...mockInternal }; + const result = getAvailableChildComponentsForContainer(layout, BASE_CONTAINER_ID); + expect(Object.keys(result)).toEqual(Object.keys(allComponents)); + }); + + it('Returns only available child component categories for the button group container', () => { + const layout = { ...mockInternal }; + const result = getAvailableChildComponentsForContainer(layout, buttonGroupId); + expect(Object.keys(result)).toEqual(['button']); + }); + }); + + describe('getDefaultChildComponentsForContainer', () => { + it('Returns all default components for the base container', () => { + const layout = { ...mockInternal }; + const result = getDefaultChildComponentsForContainer(layout, BASE_CONTAINER_ID); + expect(result.length).toEqual(defaultComponents.length); + defaultComponents.forEach((componentType) => { + expect(result.find((c) => c.type === componentType)).toBeDefined(); + }); + }); + + it('Returns all default components for the ButtonGroup container', () => { + const layout = { ...mockInternal }; + const result = getDefaultChildComponentsForContainer(layout, buttonGroupId); + const expectedComponents = formItemConfigs[ComponentType.ButtonGroup].validChildTypes; + expect(result.length).toEqual(expectedComponents.length); + expectedComponents.forEach((componentType) => { + expect(result.find((c) => c.type === componentType)).toBeDefined(); + }); + }); + + it('Returns all default components for the Group container', () => { + const layout = { ...mockInternal }; + const result = getDefaultChildComponentsForContainer(layout, groupId); + expect(result.length).toEqual(defaultComponents.length); + defaultComponents.forEach((componentType) => { + expect(result.find((c) => c.type === componentType)).toBeDefined(); + }); + }); + + it.each([ComponentType.ButtonGroup, ComponentType.Group])( + 'Returns all default components for the given container type', + (containerType) => { + const layout = { ...mockInternal }; + const result = getDefaultChildComponentsForContainer(layout, BASE_CONTAINER_ID); + expect(result.length).toEqual(defaultComponents.length); + defaultComponents.forEach((componentType) => { + expect(result.find((c) => c.type === componentType)).toBeDefined(); + }); + }, + ); + }); + describe('getAllLayoutComponents', () => { it('Returns all components in the given layout, excluding types in the exclude list', () => { const layout = { ...mockInternal }; diff --git a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts index 0b0e0819764..483f4e6e6a4 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts +++ b/frontend/packages/ux-editor/src/utils/formLayoutUtils.ts @@ -10,7 +10,7 @@ import { ComponentType, type CustomComponentType } from 'app-shared/types/Compon import type { FormComponent } from '../types/FormComponent'; import { generateFormItem } from './component'; import type { FormItemConfigs } from '../data/formItemConfig'; -import { formItemConfigs, allComponents } from '../data/formItemConfig'; +import { formItemConfigs, allComponents, defaultComponents } from '../data/formItemConfig'; import type { FormContainer } from '../types/FormContainer'; import type { FormItem } from '../types/FormItem'; import * as formItemUtils from './formItemUtils'; @@ -500,16 +500,62 @@ export const getAvailableChildComponentsForContainer = ( layout: IInternalLayout, containerId: string, ): KeyValuePairs => { - if (containerId !== BASE_CONTAINER_ID) return {}; const allComponentLists: KeyValuePairs = {}; - Object.keys(allComponents).forEach((key) => { - allComponentLists[key] = allComponents[key].map((element: ComponentType) => - mapComponentToToolbarElement(formItemConfigs[element]), - ); - }); + + if (containerId !== BASE_CONTAINER_ID) { + const containerType = layout.containers[containerId].type; + if (formItemConfigs[containerType]?.validChildTypes) { + Object.keys(allComponents).forEach((key) => { + const componentListForKey = []; + allComponents[key].forEach((element: ComponentType) => { + if (formItemConfigs[containerType].validChildTypes.includes(element)) { + componentListForKey.push(mapComponentToToolbarElement(formItemConfigs[element])); + } + }); + + if (componentListForKey.length > 0) { + allComponentLists[key] = componentListForKey; + } + }); + } + } else { + Object.keys(allComponents).forEach((key) => { + allComponentLists[key] = allComponents[key].map((element: ComponentType) => + mapComponentToToolbarElement(formItemConfigs[element]), + ); + }); + } return allComponentLists; }; +/** + * Gets all default componenent types to add for a given container + * @param layout + * @param containerId + * @returns + */ +export const getDefaultChildComponentsForContainer = ( + layout: IInternalLayout, + containerId: string, +): IToolbarElement[] => { + if (containerId !== BASE_CONTAINER_ID) { + const containerType = layout.containers[containerId].type; + if ( + formItemConfigs[containerType]?.validChildTypes && + formItemConfigs[containerType].validChildTypes.length < 10 + ) { + return formItemConfigs[containerType].validChildTypes.map((element: ComponentType) => + mapComponentToToolbarElement(formItemConfigs[element]), + ); + } + } + const defaultComponentLists: IToolbarElement[] = []; + defaultComponents.forEach((element) => { + defaultComponentLists.push(mapComponentToToolbarElement(formItemConfigs[element])); + }); + return defaultComponentLists; +}; + /** * Get all components in the given layout * @param layout The layout diff --git a/frontend/resourceadm/components/AccessListEnvLinks/AccessListEnvLinks.test.tsx b/frontend/resourceadm/components/AccessListEnvLinks/AccessListEnvLinks.test.tsx index 1536ab6e53e..d2747bfee55 100644 --- a/frontend/resourceadm/components/AccessListEnvLinks/AccessListEnvLinks.test.tsx +++ b/frontend/resourceadm/components/AccessListEnvLinks/AccessListEnvLinks.test.tsx @@ -15,10 +15,6 @@ const resourcePublishStatus = { policyVersion: null, resourceVersion: '1', publishedVersions: [ - { - version: null, - environment: 'at21', - }, { version: '1', environment: 'at22', diff --git a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx index 64160d57843..76fddb36c20 100644 --- a/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx +++ b/frontend/resourceadm/components/ImportResourceModal/ImportResourceModal.test.tsx @@ -54,10 +54,10 @@ describe('ImportResourceModal', () => { textMock('resourceadm.dashboard_import_modal_select_env'), ); await user.click(environmentSelect); - await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at21_env') })); + await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at22_env') })); await waitFor(() => - expect(environmentSelect).toHaveValue(textMock('resourceadm.deploy_at21_env')), + expect(environmentSelect).toHaveValue(textMock('resourceadm.deploy_at22_env')), ); expect(importButton).toHaveAttribute('aria-disabled', 'true'); @@ -103,7 +103,7 @@ describe('ImportResourceModal', () => { textMock('resourceadm.dashboard_import_modal_select_env'), ); await user.click(environmentSelect); - await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at21_env') })); + await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at22_env') })); // wait for the second combobox to appear, instead of waiting for the spinner to disappear. // (sometimes the spinner disappears) too quick and the test will fail @@ -137,7 +137,7 @@ describe('ImportResourceModal', () => { textMock('resourceadm.dashboard_import_modal_select_env'), ); await user.click(environmentSelect); - await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at21_env') })); + await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at22_env') })); // wait for the second combobox to appear, instead of waiting for the spinner to disappear. // (sometimes the spinner disappears) too quick and the test will fail @@ -183,7 +183,7 @@ describe('ImportResourceModal', () => { textMock('resourceadm.dashboard_import_modal_select_env'), ); await user.click(environmentSelect); - await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at21_env') })); + await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at22_env') })); // wait for the second combobox to appear, instead of waiting for the spinner to disappear. // (sometimes the spinner disappears) too quick and the test will fail @@ -215,7 +215,7 @@ describe('ImportResourceModal', () => { textMock('resourceadm.dashboard_import_modal_select_env'), ); await user.click(environmentSelect); - await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at21_env') })); + await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at22_env') })); // wait for the second combobox to appear, instead of waiting for the spinner to disappear. // (sometimes the spinner disappears) too quick and the test will fail @@ -247,7 +247,7 @@ describe('ImportResourceModal', () => { textMock('resourceadm.dashboard_import_modal_select_env'), ); await user.click(environmentSelect); - await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at21_env') })); + await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at22_env') })); // wait for the second combobox to appear, instead of waiting for the spinner to disappear. // (sometimes the spinner disappears) too quick and the test will fail @@ -286,7 +286,7 @@ describe('ImportResourceModal', () => { textMock('resourceadm.dashboard_import_modal_select_env'), ); await user.click(environmentSelect); - await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at21_env') })); + await user.click(screen.getByRole('option', { name: textMock('resourceadm.deploy_at22_env') })); // wait for the second combobox to appear, instead of waiting for the spinner to disappear. // (sometimes the spinner disappears) too quick and the test will fail diff --git a/frontend/resourceadm/pages/MigrationPage/MigrationPage.test.tsx b/frontend/resourceadm/pages/MigrationPage/MigrationPage.test.tsx index d66440c8439..4506db22b12 100644 --- a/frontend/resourceadm/pages/MigrationPage/MigrationPage.test.tsx +++ b/frontend/resourceadm/pages/MigrationPage/MigrationPage.test.tsx @@ -23,10 +23,6 @@ describe('MigrationPage', () => { policyVersion: null, resourceVersion: '1', publishedVersions: [ - { - version: null, - environment: 'at21', - }, { version: null, environment: 'at22', @@ -62,10 +58,6 @@ describe('MigrationPage', () => { policyVersion: null, resourceVersion: '2', publishedVersions: [ - { - version: null, - environment: 'at21', - }, { version: null, environment: 'at22', diff --git a/frontend/resourceadm/types/EnvironmentType.d.ts b/frontend/resourceadm/types/EnvironmentType.d.ts index d1144a0759f..37de4cdff7d 100644 --- a/frontend/resourceadm/types/EnvironmentType.d.ts +++ b/frontend/resourceadm/types/EnvironmentType.d.ts @@ -1 +1 @@ -export type EnvironmentType = 'AT21' | 'AT22' | 'AT23' | 'AT24' | 'TT02' | 'PROD'; +export type EnvironmentType = 'AT22' | 'AT23' | 'AT24' | 'TT02' | 'PROD'; diff --git a/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts b/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts index 58b7bd7ebe5..b9ed936d73c 100644 --- a/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts +++ b/frontend/resourceadm/utils/mapperUtils/mapperUtils.ts @@ -1,7 +1,7 @@ import type { Altinn2LinkService } from 'app-shared/types/Altinn2LinkService'; import type { ResourceListItem } from 'app-shared/types/ResourceAdm'; -const EnvOrder = ['prod', 'tt02', 'at21', 'at22', 'at23', 'at24', 'gitea']; +const EnvOrder = ['prod', 'tt02', 'at22', 'at23', 'at24', 'gitea']; /** * Sorts a resource list by the date so the newest is at the top * diff --git a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts index 585263ab6a4..19122d97839 100644 --- a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts +++ b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts @@ -45,7 +45,7 @@ export const availableForTypeMap: Record SelfRegisteredUser: 'resourceadm.about_resource_available_for_type_self_registered', }; -export type EnvId = 'tt02' | 'prod' | 'at21' | 'at22' | 'at23' | 'at24'; +export type EnvId = 'tt02' | 'prod' | 'at22' | 'at23' | 'at24'; export type EnvType = 'test' | 'prod'; export type Environment = { id: EnvId; @@ -54,11 +54,6 @@ export type Environment = { }; const environments: Record = { - ['at21']: { - id: 'at21' as EnvId, - label: 'resourceadm.deploy_at21_env', - envType: 'test' as EnvType, - }, ['at22']: { id: 'at22' as EnvId, label: 'resourceadm.deploy_at22_env', @@ -89,12 +84,7 @@ const environments: Record = { export const getAvailableEnvironments = (org: string): Environment[] => { const availableEnvs = [environments['tt02'], environments['prod']]; if (org === 'ttd') { - availableEnvs.push( - environments['at21'], - environments['at22'], - environments['at23'], - environments['at24'], - ); + availableEnvs.push(environments['at22'], environments['at23'], environments['at24']); } return availableEnvs; }; diff --git a/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml b/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml index 0be21402d69..6f32ac6cef0 100644 --- a/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml +++ b/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml @@ -32,7 +32,7 @@ org.springframework.boot spring-boot-starter-parent - 3.3.6 + 3.4.0