diff --git a/backend/src/Designer/Controllers/AppDevelopmentController.cs b/backend/src/Designer/Controllers/AppDevelopmentController.cs index 29c1c2afac5..274a9ccfa38 100644 --- a/backend/src/Designer/Controllers/AppDevelopmentController.cs +++ b/backend/src/Designer/Controllers/AppDevelopmentController.cs @@ -35,6 +35,7 @@ public class AppDevelopmentController : Controller private readonly IAltinnGitRepositoryFactory _altinnGitRepositoryFactory; private readonly ApplicationInsightsSettings _applicationInsightsSettings; private readonly IMediator _mediator; + private readonly IUserRequestsSynchronizationService _userRequestsSynchronizationService; /// @@ -45,7 +46,9 @@ public class AppDevelopmentController : Controller /// The source control service. /// /// An - public AppDevelopmentController(IAppDevelopmentService appDevelopmentService, IRepository repositoryService, ISourceControl sourceControl, IAltinnGitRepositoryFactory altinnGitRepositoryFactory, ApplicationInsightsSettings applicationInsightsSettings, IMediator mediator) + /// + /// An used to control parallel execution of user requests. + public AppDevelopmentController(IAppDevelopmentService appDevelopmentService, IRepository repositoryService, ISourceControl sourceControl, IAltinnGitRepositoryFactory altinnGitRepositoryFactory, ApplicationInsightsSettings applicationInsightsSettings, IMediator mediator, IUserRequestsSynchronizationService userRequestsSynchronizationService) { _appDevelopmentService = appDevelopmentService; _repository = repositoryService; @@ -53,6 +56,7 @@ public AppDevelopmentController(IAppDevelopmentService appDevelopmentService, IR _altinnGitRepositoryFactory = altinnGitRepositoryFactory; _applicationInsightsSettings = applicationInsightsSettings; _mediator = mediator; + _userRequestsSynchronizationService = userRequestsSynchronizationService; } /// @@ -213,9 +217,11 @@ public ActionResult UpdateFormLayoutName(string org, string app, [FromQuery] str [Route("layout-settings")] public async Task SaveLayoutSettings(string org, string app, [FromQuery] string layoutSetName, [FromBody] JsonNode layoutSettings, CancellationToken cancellationToken) { + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + SemaphoreSlim semaphore = _userRequestsSynchronizationService.GetRequestsSemaphore(org, app, developer); + await semaphore.WaitAsync(); try { - string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, app, developer); await _appDevelopmentService.SaveLayoutSettings(editingContext, layoutSettings, layoutSetName, cancellationToken); return Ok(); @@ -224,6 +230,10 @@ public async Task SaveLayoutSettings(string org, string app, [From { return NotFound(exception.Message); } + finally + { + semaphore.Release(); + } } /// diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 63724139157..5012be694a7 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -773,6 +773,7 @@ "right_menu.expressions_property_preview_required": "Sett {{componentName}} som påkrevd hvis …", "right_menu.expressions_property_read_only": "Sett felt som skrivebeskyttet dersom", "right_menu.expressions_property_required": "Sett felt som påkrevd dersom", + "right_menu.pdf": "PDF", "right_menu.read_more_about_expressions": "Les mer om dynamiske uttrykk i dokumentasjonen.", "right_menu.rules_calculations": "Regel for beregninger", "right_menu.rules_calculations_add_alt": "Legg til regel for beregninger", @@ -1600,6 +1601,13 @@ "ux_editor.options_text_help_text": "Hjelpetekst", "ux_editor.options_text_label": "Ledetekst", "ux_editor.page": "Side", + "ux_editor.page_config_pdf_abort_converting_page_to_pdf": "Avbryt å gjøre om siden til PDF", + "ux_editor.page_config_pdf_convert_existing_pdf": "Konverter eksisterende PDF til skjemaside", + "ux_editor.page_config_pdf_convert_info_when_custom_pdf_exists": "Du har allerede en egendefinert PDF. Dersom du fortsatt ønsker å konvertere denne siden til PDF, kan du velge om du vil slette den eksisterende PDF-en for godt eller om du vil konvertere den tilbake til en vanlig skjemaside.", + "ux_editor.page_config_pdf_convert_page_to_pdf": "Gjør om siden til PDF", + "ux_editor.page_config_pdf_delete_existing_pdf": "Slett eksisterende PDF", + "ux_editor.page_config_pdf_exclude_components_from_default_pdf": "Velg hvilke komponenter fra siden som skal skjules i standard PDF", + "ux_editor.page_config_pdf_exclude_page_from_default_pdf": "Ekskluder siden fra standard PDF", "ux_editor.page_delete_text": "Er du sikker på at du vil slette denne siden?\nAlt innholdet på siden vil bli fjernet.", "ux_editor.page_menu_down": "Flytt ned", "ux_editor.page_menu_edit": "Gi nytt navn", diff --git a/frontend/packages/shared/src/hooks/useSelectedFormLayoutName.ts b/frontend/packages/shared/src/hooks/useSelectedFormLayoutName.ts index 572c717687f..c5eb3a88e53 100644 --- a/frontend/packages/shared/src/hooks/useSelectedFormLayoutName.ts +++ b/frontend/packages/shared/src/hooks/useSelectedFormLayoutName.ts @@ -19,7 +19,9 @@ export const useSelectedFormLayoutName = ( const isValidLayout = (layoutName: string): boolean => { const layoutPagesOrder = formLayoutSettings?.pages?.order; - return layoutPagesOrder?.includes(layoutName); + const isExistingLayout = layoutPagesOrder?.includes(layoutName); + const isPdf = formLayoutSettings?.pages?.pdfLayoutName === layoutName; + return isExistingLayout || isPdf; }; const [selectedFormLayoutName, setSelectedFormLayoutName] = useSearchParamsState( diff --git a/frontend/packages/ux-editor-v3/src/containers/DesignView/ReceiptContent/ReceiptContent.tsx b/frontend/packages/ux-editor-v3/src/containers/DesignView/ReceiptContent/ReceiptContent.tsx index fb2ab40d769..46b79f782f4 100644 --- a/frontend/packages/ux-editor-v3/src/containers/DesignView/ReceiptContent/ReceiptContent.tsx +++ b/frontend/packages/ux-editor-v3/src/containers/DesignView/ReceiptContent/ReceiptContent.tsx @@ -63,7 +63,7 @@ export const ReceiptContent = ({ return (
- + { afterEach(() => jest.clearAllMocks()); - it('saves layoutSettings when pdfLayoutName is updated', () => { + it('saves layoutSettings when save function is called', () => { const savableLayoutSettings = setupLayoutSettings(); - const newPdfLayoutName = 'newPdfLayoutName'; - savableLayoutSettings.setPdfLayoutName(newPdfLayoutName); + savableLayoutSettings.save(); expect(saveFormLayoutSettings).toHaveBeenCalledTimes(1); expect(saveFormLayoutSettings).toHaveBeenCalledWith(savableLayoutSettings.getLayoutSettings()); - const actualNewPdfLayoutSetName = savableLayoutSettings.getPdfLayoutName(); - expect(actualNewPdfLayoutSetName).toBe(newPdfLayoutName); }); }); diff --git a/frontend/packages/ux-editor/src/classes/SavableFormLayoutSettings.ts b/frontend/packages/ux-editor/src/classes/SavableFormLayoutSettings.ts index aa671cc7a63..ac162da8fd8 100644 --- a/frontend/packages/ux-editor/src/classes/SavableFormLayoutSettings.ts +++ b/frontend/packages/ux-editor/src/classes/SavableFormLayoutSettings.ts @@ -15,9 +15,4 @@ export class SavableFormLayoutSettings extends FormLayoutSettings { this.saveFormLayoutSettings(this.getLayoutSettings()); return this; } - - public setPdfLayoutName(layoutName: string): SavableFormLayoutSettings { - super.setPdfLayoutName(layoutName); - return this.save(); - } } diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.module.css b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.module.css index bbe191ed129..df5185c8017 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.module.css +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.module.css @@ -1,3 +1,4 @@ +.pdf, .text { padding: var(--fds-spacing-5) 0; } diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.test.tsx index d1253aa00d3..8bbe3c3dc6d 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { screen, waitFor } from '@testing-library/react'; -import { renderWithProviders } from '../../../testing/mocks'; +import { formLayoutSettingsMock, renderWithProviders } from '../../../testing/mocks'; import { PageConfigPanel } from './PageConfigPanel'; import { QueryKey } from 'app-shared/types/QueryKey'; import { queryClientMock } from 'app-shared/mocks/queryClientMock'; @@ -124,6 +124,10 @@ const renderPageConfigPanel = ( ) => { queryClientMock.setQueryData([QueryKey.TextResources, org, app], textResources); queryClientMock.setQueryData([QueryKey.FormLayouts, org, app, layoutSet], layouts); + queryClientMock.setQueryData( + [QueryKey.FormLayoutSettings, org, app, layoutSet], + formLayoutSettingsMock, + ); queryClientMock.setQueryData( [QueryKey.DataModelMetadata, org, app, layoutSet, dataModelName], [], diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.tsx b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.tsx index c77754a5a93..b34ec6f84d3 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PageConfigPanel.tsx @@ -17,6 +17,7 @@ import { PageConfigWarning } from './PageConfigWarning'; import classes from './PageConfigPanel.module.css'; import { PageConfigWarningModal } from './PageConfigWarningModal'; import type { IInternalLayout } from '@altinn/ux-editor/types/global'; +import { PdfConfig } from '@altinn/ux-editor/components/Properties/PageConfigPanel/PdfConfig'; export const PageConfigPanel = () => { const { selectedFormLayoutName } = useAppContext(); @@ -83,6 +84,12 @@ export const PageConfigPanel = () => { + + {t('right_menu.pdf')} + + + + )} diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/ConvertChoicesModal.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/ConvertChoicesModal.test.tsx new file mode 100644 index 00000000000..b53ab0484e1 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/ConvertChoicesModal.test.tsx @@ -0,0 +1,107 @@ +import React, { createRef } from 'react'; +import { formLayoutSettingsMock, renderWithProviders } from '@altinn/ux-editor/testing/mocks'; +import { ConvertChoicesModal } from '@altinn/ux-editor/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/ConvertChoicesModal'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { app, org } from '@studio/testing/testids'; +import { layoutSet1NameMock } from '@altinn/ux-editor/testing/layoutSetsMock'; +import { layout1NameMock } from '@altinn/ux-editor/testing/layoutMock'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; +import type { ILayoutSettings } from 'app-shared/types/global'; +import type { AppContextProps } from '@altinn/ux-editor/AppContext'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { QueryKey } from 'app-shared/types/QueryKey'; + +const selectedLayoutSet = layoutSet1NameMock; +const handleModalActionMock = jest.fn(); + +describe('ConvertChoicesModal', () => { + afterEach(() => jest.clearAllMocks()); + it('converts existing pdf back to formLayout when clicking convert in conversion choices modal', async () => { + const user = userEvent.setup(); + const pdfLayoutNameMock = 'pdfLayoutNameMock'; + const mutateLayoutSettingsMock = jest.fn(); + await renderConvertChoicesModal( + { pages: { order: [layout1NameMock], pdfLayoutName: pdfLayoutNameMock } }, + {}, + { saveFormLayoutSettings: mutateLayoutSettingsMock }, + ); + const convertExistingPdfToFormLayout = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_convert_existing_pdf'), + }); + await user.click(convertExistingPdfToFormLayout); + expect(mutateLayoutSettingsMock).toHaveBeenCalledTimes(1); + expect(mutateLayoutSettingsMock).toHaveBeenCalledWith(org, app, layoutSet1NameMock, { + pages: { order: [pdfLayoutNameMock], pdfLayoutName: layout1NameMock }, + }); + }); + + it('deletes existing pdf when clicking delete in conversion choices modal', async () => { + const user = userEvent.setup(); + const pdfLayoutNameMock = 'pdfLayoutNameMock'; + const mutateLayoutSettingsMock = jest.fn(); + const deleteLayoutMock = jest.fn(); + await renderConvertChoicesModal( + { pages: { order: [layout1NameMock], pdfLayoutName: pdfLayoutNameMock } }, + {}, + { saveFormLayoutSettings: mutateLayoutSettingsMock, deleteFormLayout: deleteLayoutMock }, + ); + const deleteExistingPdf = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_delete_existing_pdf'), + }); + await user.click(deleteExistingPdf); + expect(mutateLayoutSettingsMock).toHaveBeenCalledTimes(2); // Once from pdfConfig and another from deleteLayout + expect(mutateLayoutSettingsMock).toHaveBeenCalledWith(org, app, layoutSet1NameMock, { + pages: { order: [], pdfLayoutName: layout1NameMock }, + }); + expect(deleteLayoutMock).toHaveBeenCalledTimes(1); + expect(deleteLayoutMock).toHaveBeenCalledWith(org, app, pdfLayoutNameMock, selectedLayoutSet); + }); + + it('calls handleModalAction when converting existing pdf', async () => { + const user = userEvent.setup(); + const pdfLayoutNameMock = 'pdfLayoutNameMock'; + await renderConvertChoicesModal({ + pages: { order: [layout1NameMock], pdfLayoutName: pdfLayoutNameMock }, + }); + const convertExistingPdfToFormLayout = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_convert_existing_pdf'), + }); + await user.click(convertExistingPdfToFormLayout); + expect(handleModalActionMock).toHaveBeenCalledTimes(1); + }); + + it('calls handleModalAction when deleting existing pdf', async () => { + const user = userEvent.setup(); + const pdfLayoutNameMock = 'pdfLayoutNameMock'; + await renderConvertChoicesModal({ + pages: { order: [layout1NameMock], pdfLayoutName: pdfLayoutNameMock }, + }); + const deleteExistingPdf = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_delete_existing_pdf'), + }); + await user.click(deleteExistingPdf); + expect(handleModalActionMock).toHaveBeenCalledTimes(1); + }); +}); + +const renderConvertChoicesModal = async ( + layoutSettings: Partial = {}, + appContextProps: Partial = {}, + queries: Partial = {}, +) => { + const ref = createRef(); + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.FormLayoutSettings, org, app, selectedLayoutSet], { + ...formLayoutSettingsMock, + ...layoutSettings, + }); + renderWithProviders(, { + queries, + queryClient, + appContextProps, + }); + ref.current?.showModal(); + await screen.findByRole('dialog'); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/ConvertChoicesModal.tsx b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/ConvertChoicesModal.tsx new file mode 100644 index 00000000000..1b655aa1fb8 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/ConvertChoicesModal.tsx @@ -0,0 +1,57 @@ +import React, { forwardRef } from 'react'; +import { StudioModal } from '@studio/components'; +import { useForwardedRef } from '@studio/hooks'; +import { OverrideCurrentPdfByConversionChoices } from './OverrideCurrentPdfByConversionChoices'; +import { useTranslation } from 'react-i18next'; +import { usePdf } from '@altinn/ux-editor/hooks/usePdf/usePdf'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { useAppContext } from '@altinn/ux-editor/hooks'; +import { useDeleteLayoutMutation } from '@altinn/ux-editor/hooks/mutations/useDeleteLayoutMutation'; +import { useSavableFormLayoutSettings } from '@altinn/ux-editor/hooks/useSavableFormLayoutSettings'; + +type ConvertChoicesModalProps = { + handleModalAction: () => void; +}; +export const ConvertChoicesModal = forwardRef( + ({ handleModalAction }, ref): JSX.Element => { + const { org, app } = useStudioEnvironmentParams(); + const { selectedFormLayoutSetName } = useAppContext(); + const { t } = useTranslation(); + const { mutate: deleteLayout } = useDeleteLayoutMutation(org, app, selectedFormLayoutSetName); + const { getPdfLayoutName, convertCurrentPageToPdf, convertExistingPdfToPage } = usePdf(); + const savableLayoutSettings = useSavableFormLayoutSettings(); + const dialogRef = useForwardedRef(ref); + + const handleConvertPageToPdfAndConvertCurrent = () => { + convertExistingPdfToPage(); + convertCurrentPageToPdf(); + savableLayoutSettings.save(); + handleModalAction(); + dialogRef.current?.close(); + }; + + const handleConvertPageToPdfAndDeleteCurrent = () => { + const currentPdfLayoutName = getPdfLayoutName(); + convertCurrentPageToPdf(); + deleteLayout(currentPdfLayoutName); + savableLayoutSettings.save(); + handleModalAction(); + dialogRef.current?.close(); + }; + + return ( + + + + ); + }, +); + +ConvertChoicesModal.displayName = 'ConvertChoicesModal'; diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/OverrideCurrentPdfByConversionChoices.module.css b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/OverrideCurrentPdfByConversionChoices.module.css new file mode 100644 index 00000000000..5ea7881d7b7 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/OverrideCurrentPdfByConversionChoices.module.css @@ -0,0 +1,6 @@ +.buttonContainer { + display: flex; + flex-direction: row; + gap: var(--fds-spacing-2); + padding-top: var(--fds-spacing-10); +} diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/OverrideCurrentPdfByConversionChoices.tsx b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/OverrideCurrentPdfByConversionChoices.tsx new file mode 100644 index 00000000000..7e5c55957a7 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/ConvertPageToPdfWhenExistingModal/OverrideCurrentPdfByConversionChoices.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { StudioButton } from '@studio/components'; +import { Paragraph } from '@digdir/design-system-react'; +import { useTranslation } from 'react-i18next'; +import classes from './OverrideCurrentPdfByConversionChoices.module.css'; + +export interface OverrideCurrentPdfByConversionChoicesProps { + onConvertPageToPdfAndConvertCurrent: () => void; + onConvertPageToPdfAndDeleteCurrent: () => void; +} + +export const OverrideCurrentPdfByConversionChoices = ({ + onConvertPageToPdfAndConvertCurrent, + onConvertPageToPdfAndDeleteCurrent, +}: OverrideCurrentPdfByConversionChoicesProps) => { + const { t } = useTranslation(); + + return ( +
+ {t('ux_editor.page_config_pdf_convert_info_when_custom_pdf_exists')} +
+ + {t('ux_editor.page_config_pdf_convert_existing_pdf')} + + + {t('ux_editor.page_config_pdf_delete_existing_pdf')} + +
+
+ ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/PdfConfig.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/PdfConfig.test.tsx new file mode 100644 index 00000000000..9d04f3b2059 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/PdfConfig.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { PdfConfig } from './PdfConfig'; +import { screen } from '@testing-library/react'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { formLayoutSettingsMock, renderWithProviders } from '@altinn/ux-editor/testing/mocks'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import { app, org } from '@studio/testing/testids'; +import type { ILayoutSettings } from 'app-shared/types/global'; +import { layoutSet1NameMock } from '@altinn/ux-editor/testing/layoutSetsMock'; +import type { AppContextProps } from '@altinn/ux-editor/AppContext'; +import userEvent from '@testing-library/user-event'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { layout1NameMock, layout2NameMock } from '@altinn/ux-editor/testing/layoutMock'; + +const selectedLayoutSet = layoutSet1NameMock; + +describe('PdfConfig', () => { + afterEach(() => jest.clearAllMocks()); + it('renders convertToPdf button when current page is not pdf', () => { + renderPdfConfig(); + const convertFormLayoutToPdfButton = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_convert_page_to_pdf'), + }); + expect(convertFormLayoutToPdfButton).toBeInTheDocument(); + }); + + it('renders convertToFormLayout button when current page is pdf', () => { + const pdfLayoutNameMock = 'pdfLayoutNameMock'; + renderPdfConfig( + { pages: { order: [], pdfLayoutName: pdfLayoutNameMock } }, + { selectedFormLayoutName: pdfLayoutNameMock }, + ); + const convertPdfToFormLayoutButton = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_convert_existing_pdf'), + }); + expect(convertPdfToFormLayoutButton).toBeInTheDocument(); + }); + + it('calls save on FormLayoutSettings when convertToPdf button is clicked', async () => { + const user = userEvent.setup(); + const mutateLayoutSettings = jest.fn(); + renderPdfConfig({}, {}, { saveFormLayoutSettings: mutateLayoutSettings }); + const convertFormLayoutToPdfButton = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_convert_page_to_pdf'), + }); + await user.click(convertFormLayoutToPdfButton); + expect(mutateLayoutSettings).toHaveBeenCalledTimes(1); + expect(mutateLayoutSettings).toHaveBeenCalledWith(org, app, layoutSet1NameMock, { + pages: { order: [layout2NameMock], pdfLayoutName: layout1NameMock }, + }); + }); + + it('calls save on FormLayoutSettings when convertToFormLayout button is clicked', async () => { + const user = userEvent.setup(); + const pdfLayoutNameMock = 'pdfLayoutNameMock'; + const mutateLayoutSettings = jest.fn(); + renderPdfConfig( + { pages: { order: [], pdfLayoutName: pdfLayoutNameMock } }, + { selectedFormLayoutName: pdfLayoutNameMock }, + { saveFormLayoutSettings: mutateLayoutSettings }, + ); + const convertPdfToFormLayoutButton = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_convert_existing_pdf'), + }); + await user.click(convertPdfToFormLayoutButton); + expect(mutateLayoutSettings).toHaveBeenCalledTimes(1); + expect(mutateLayoutSettings).toHaveBeenCalledWith(org, app, layoutSet1NameMock, { + pages: { order: [pdfLayoutNameMock] }, + }); + }); + + it('shows conversion choices modal when converting a layout to pdf when there exists a pdfLayout from before', async () => { + const user = userEvent.setup(); + const pdfLayoutNameMock = 'pdfLayoutNameMock'; + renderPdfConfig({ pages: { order: [], pdfLayoutName: pdfLayoutNameMock } }); + const convertFormLayoutToPdfButton = screen.getByRole('button', { + name: textMock('ux_editor.page_config_pdf_convert_page_to_pdf'), + }); + await user.click(convertFormLayoutToPdfButton); + const conversionChoicesModalHeading = screen.getByRole('heading', { + name: textMock('ux_editor.page_config_pdf_convert_page_to_pdf'), + }); + expect(conversionChoicesModalHeading).toBeInTheDocument(); + }); +}); + +const renderPdfConfig = ( + layoutSettings: Partial = {}, + appContextProps: Partial = {}, + queries: Partial = {}, +) => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.FormLayoutSettings, org, app, selectedLayoutSet], { + ...formLayoutSettingsMock, + ...layoutSettings, + }); + renderWithProviders(, { queries, queryClient, appContextProps }); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/PdfConfig.tsx b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/PdfConfig.tsx new file mode 100644 index 00000000000..9cc937f6832 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/PdfConfig.tsx @@ -0,0 +1,56 @@ +import React, { useRef, useState } from 'react'; +import { StudioProperty } from '@studio/components'; +import { useTranslation } from 'react-i18next'; +import { FileIcon } from '@studio/icons'; +import { usePdf } from '../../../../hooks/usePdf/usePdf'; +import { ConvertChoicesModal } from './ConvertPageToPdfWhenExistingModal/ConvertChoicesModal'; +import { useSavableFormLayoutSettings } from '@altinn/ux-editor/hooks/useSavableFormLayoutSettings'; + +export const PdfConfig = () => { + const { t } = useTranslation(); + const { isCurrentPagePdf, getPdfLayoutName, convertCurrentPageToPdf, convertExistingPdfToPage } = + usePdf(); + const [isPdfUpdated, setIsPdfUpdated] = useState(false); + const savableLayoutSettings = useSavableFormLayoutSettings(); + const convertChoicesDialogRef = useRef(null); + + const handleClickConvertButton = () => { + if (!!getPdfLayoutName()) { + convertChoicesDialogRef.current?.showModal(); + } else { + convertCurrentPageToPdf(); + savableLayoutSettings.save(); + } + }; + + const handleConvertExistingPdfToFormLayout = () => { + convertExistingPdfToPage(); + savableLayoutSettings.save(); + }; + + const handleModalAction = () => { + // Trigger re-render after modal action + setIsPdfUpdated(!isPdfUpdated); + }; + + return ( +
+ + {isCurrentPagePdf() ? ( + } + /> + ) : ( + } + /> + )} +
+ ); +}; diff --git a/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/index.ts b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/index.ts new file mode 100644 index 00000000000..0d42e7914e8 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/Properties/PageConfigPanel/PdfConfig/index.ts @@ -0,0 +1 @@ +export { PdfConfig } from './PdfConfig'; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css index 4b68a52d424..5598c525e2b 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css +++ b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.module.css @@ -7,6 +7,7 @@ .root > *:last-child { flex: 1; + align-content: flex-end; } .pageHeader { diff --git a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.test.tsx index 008d933fd70..62626fd3c3f 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.test.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.test.tsx @@ -8,11 +8,11 @@ import { DragAndDrop } from 'app-shared/components/dragAndDrop'; import { BASE_CONTAINER_ID } from 'app-shared/constants'; import userEvent from '@testing-library/user-event'; import { queriesMock } from 'app-shared/mocks/queriesMock'; -import { typedLocalStorage } from '@studio/components/src/hooks/webStorage'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; import { QueryKey } from 'app-shared/types/QueryKey'; import { externalLayoutsMock, + layout1Mock, layout1NameMock, layout2NameMock, } from '@altinn/ux-editor/testing/layoutMock'; @@ -20,6 +20,8 @@ import { layoutSet1NameMock } from '@altinn/ux-editor/testing/layoutSetsMock'; import { convertExternalLayoutsToInternalFormat } from '../../utils/formLayoutsUtils'; import { appContextMock } from '../../testing/appContextMock'; import { app, org } from '@studio/testing/testids'; +import type { ILayoutSettings } from 'app-shared/types/global'; +import type { FormLayoutsResponse } from 'app-shared/types/api'; const mockSelectedLayoutSet = layoutSet1NameMock; const mockPageName1: string = layout1NameMock; @@ -30,8 +32,8 @@ describe('DesignView', () => { jest.clearAllMocks(); }); - it('displays the correct number of accordions', async () => { - await render(); + it('displays the correct number of accordions', () => { + renderDesignView(); formLayoutSettingsMock.pages.order.forEach((page) => { const accordionButton = screen.getByRole('button', { name: page }); @@ -39,9 +41,50 @@ describe('DesignView', () => { }); }); + it('adds page with correct name', async () => { + const user = userEvent.setup(); + renderDesignView({ + ...formLayoutSettingsMock, + pages: { order: ['someName', 'someOtherName'] }, + }); + const addButton = screen.getByRole('button', { name: textMock('ux_editor.pages_add') }); + await user.click(addButton); + expect(queriesMock.saveFormLayout).toHaveBeenCalledWith( + org, + app, + `${textMock('ux_editor.page')}${3}`, + mockSelectedLayoutSet, + expect.any(Object), + ); + }); + + it('increments the page name for the new page if pdfLayoutName has the next incremental page name', async () => { + const user = userEvent.setup(); + const pdfLayoutName = `${textMock('ux_editor.page')}${3}`; + renderDesignView( + { + ...formLayoutSettingsMock, + pages: { + order: [`${textMock('ux_editor.page')}${1}`, `${textMock('ux_editor.page')}${2}`], + pdfLayoutName, + }, + }, + { [pdfLayoutName]: layout1Mock }, + ); + const addButton = screen.getByRole('button', { name: textMock('ux_editor.pages_add') }); + await user.click(addButton); + expect(queriesMock.saveFormLayout).toHaveBeenCalledWith( + org, + app, + `${textMock('ux_editor.page')}${4}`, + mockSelectedLayoutSet, + expect.any(Object), + ); + }); + it('calls "setSelectedFormLayoutName" with undefined when current page the accordion is clicked', async () => { const user = userEvent.setup(); - await render(); + renderDesignView(); const accordionButton1 = screen.getByRole('button', { name: mockPageName1 }); await user.click(accordionButton1); @@ -52,7 +95,7 @@ describe('DesignView', () => { it('calls "setSelectedFormLayoutName" with the new page when another page accordion is clicked', async () => { const user = userEvent.setup(); - await render(); + renderDesignView(); const accordionButton2 = screen.getByRole('button', { name: mockPageName2 }); await user.click(accordionButton2); @@ -63,7 +106,7 @@ describe('DesignView', () => { it('calls "saveFormLayout" when add page is clicked', async () => { const user = userEvent.setup(); - await render(); + renderDesignView(); const addButton = screen.getByRole('button', { name: textMock('ux_editor.pages_add') }); await user.click(addButton); @@ -71,21 +114,33 @@ describe('DesignView', () => { expect(queriesMock.saveFormLayout).toHaveBeenCalled(); }); - it('Displays the tree view version of the layout when the formTree feature flag is enabled', async () => { - typedLocalStorage.setItem('featureFlags', ['formTree']); - await render(); + it('Displays the tree view version of the layout', () => { + renderDesignView(); expect(screen.getByRole('tree')).toBeInTheDocument(); }); + + it('Renders the page accordion as a pdfAccordion when pdfLayoutName is set', () => { + const pdfLayoutName = 'pdfLayoutName'; + renderDesignView( + { ...formLayoutSettingsMock, pages: { order: [], pdfLayoutName } }, + { [pdfLayoutName]: layout1Mock }, + ); + const pdfAccordionButton = screen.getByRole('button', { name: pdfLayoutName }); + expect(pdfAccordionButton).toBeInTheDocument(); + }); }); -const render = async () => { +const renderDesignView = ( + layoutSettings: ILayoutSettings = formLayoutSettingsMock, + externalLayout: FormLayoutsResponse = externalLayoutsMock, +) => { const queryClient = createQueryClientMock(); queryClient.setQueryData( [QueryKey.FormLayouts, org, app, mockSelectedLayoutSet], - convertExternalLayoutsToInternalFormat(externalLayoutsMock), + convertExternalLayoutsToInternalFormat(externalLayout), ); queryClient.setQueryData( [QueryKey.FormLayoutSettings, org, app, mockSelectedLayoutSet], - formLayoutSettingsMock, + layoutSettings, ); return renderWithProviders( diff --git a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.tsx b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.tsx index 0dc44d85d04..8f3b81ebed3 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/DesignView.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/DesignView.tsx @@ -4,10 +4,7 @@ import classes from './DesignView.module.css'; import { useTranslation } from 'react-i18next'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { Accordion } from '@digdir/designsystemet-react'; -import type { IFormLayouts } from '../../types/global'; -import type { FormLayoutPage } from '../../types/FormLayoutPage'; import { useFormLayoutSettingsQuery } from '../../hooks/queries/useFormLayoutSettingsQuery'; -import { PlusIcon } from '@studio/icons'; import { useAddLayoutMutation } from '../../hooks/mutations/useAddLayoutMutation'; import { PageAccordion } from './PageAccordion'; import { useAppContext, useFormLayouts } from '../../hooks'; @@ -17,16 +14,14 @@ import { duplicatedIdsExistsInLayout, findLayoutsContainingDuplicateComponents, } from '../../utils/formLayoutUtils'; +import { PdfLayoutAccordion } from '@altinn/ux-editor/containers/DesignView/PdfLayout/PdfLayoutAccordion'; +import { mapFormLayoutsToFormLayoutPages } from '@altinn/ux-editor/utils/formLayoutsUtils'; +import { PlusIcon } from '@studio/icons'; +import { usePdf } from '../../hooks/usePdf/usePdf'; /** * Maps the IFormLayouts object to a list of FormLayouts */ -const mapFormLayoutsToFormLayoutPages = (formLayouts: IFormLayouts): FormLayoutPage[] => { - return Object.entries(formLayouts).map(([key, value]) => ({ - page: key, - data: value, - })); -}; /** * @component @@ -47,17 +42,22 @@ export const DesignView = (): ReactNode => { app, selectedFormLayoutSetName, ); - const layouts = useFormLayouts(); + // Referring to useFormLayoutSettingsQuery twice is a hack to ensure designView is re-rendered after converting + // a newly added layout to a PDF. See issue: https://github.com/Altinn/altinn-studio/issues/13679 + useFormLayoutSettingsQuery(org, app, selectedFormLayoutSetName); const { data: formLayoutSettings } = useFormLayoutSettingsQuery( org, app, selectedFormLayoutSetName, ); + const layouts = useFormLayouts(); + const { getPdfLayoutName } = usePdf(); const layoutOrder = formLayoutSettings?.pages?.order; const { t } = useTranslation(); const formLayoutData = mapFormLayoutsToFormLayoutPages(layouts); + /** * Handles the click of an accordion. It updates the URL and sets the * local storage for which page view that is open @@ -73,10 +73,10 @@ export const DesignView = (): ReactNode => { }; const handleAddPage = () => { - let newNum = 1; - let newLayoutName = `${t('ux_editor.page')}${layoutOrder.length + newNum}`; + let newNum = layoutOrder.length + 1; + let newLayoutName = `${t('ux_editor.page')}${newNum}`; - while (layoutOrder.indexOf(newLayoutName) > -1) { + while (layoutOrder.includes(newLayoutName) || getPdfLayoutName() === newLayoutName) { newNum += 1; newLayoutName = `${t('ux_editor.page')}${newNum}`; } @@ -105,7 +105,7 @@ export const DesignView = (): ReactNode => { if (layout === undefined) return null; // Check if the layout has unique component IDs - const isValidLayout = !duplicatedIdsExistsInLayout(layout.data); + const isInvalidLayout = duplicatedIdsExistsInLayout(layout.data); return ( { pageName={layout.page} isOpen={layout.page === selectedFormLayoutName} onClick={() => handleClickAccordion(layout.page)} - isValid={isValidLayout} - hasUniqueIds={!layoutsWithDuplicateComponents.duplicateLayouts.includes(layout.page)} + isInvalid={isInvalidLayout} + hasDuplicatedIds={layoutsWithDuplicateComponents.duplicateLayouts.includes(layout.page)} > {layout.page === selectedFormLayoutName && ( )} @@ -144,6 +144,20 @@ export const DesignView = (): ReactNode => { {t('ux_editor.pages_add')}
+ {getPdfLayoutName() && ( +
+
+ handleClickAccordion(getPdfLayoutName())} + hasDuplicatedIds={layoutsWithDuplicateComponents.duplicateLayouts.includes( + getPdfLayoutName(), + )} + /> +
+
+ )}
); }; diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.test.tsx b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.test.tsx index d58c6ddd910..bcdd2818caf 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.test.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.test.tsx @@ -12,7 +12,7 @@ import { internalLayoutWithMultiPageGroup } from '../../testing/layoutWithMultiP const defaultProps: FormLayoutProps = { layout: layoutMock, - isValid: true, + isInvalid: false, }; describe('FormLayout', () => { @@ -40,7 +40,7 @@ describe('FormLayout', () => { }, }; - render({ layout: layoutWithDuplicatedIds, isValid: false }); + render({ layout: layoutWithDuplicatedIds, isInvalid: true }); expect( screen.getByText(textMock('ux_editor.formLayout.warning_duplicates')), diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx index 0627bc6018e..eede7362302 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx @@ -8,12 +8,12 @@ import { FormLayoutWarning } from './FormLayoutWarning'; export interface FormLayoutProps { layout: IInternalLayout; - isValid: boolean; + isInvalid: boolean; duplicateComponents?: string[]; } -export const FormLayout = ({ layout, isValid, duplicateComponents }: FormLayoutProps) => { - if (!isValid) { +export const FormLayout = ({ layout, isInvalid, duplicateComponents }: FormLayoutProps) => { + if (isInvalid) { return ; } return ( diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx index 8f6c7b06b75..03d99c7ef3b 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/NavigationMenu/NavigationMenu.tsx @@ -66,24 +66,22 @@ export const NavigationMenu = ({ pageName }: NavigationMenuProps): JSX.Element = - <> - !disableUp && moveLayout('up')} - disabled={disableUp} - id='move-page-up-button' - > - - {t('ux_editor.page_menu_up')} - - !disableDown && moveLayout('down')} - disabled={disableDown} - id='move-page-down-button' - > - - {t('ux_editor.page_menu_down')} - - + !disableUp && moveLayout('up')} + disabled={disableUp} + id='move-page-up-button' + > + + {t('ux_editor.page_menu_up')} + + !disableDown && moveLayout('down')} + disabled={disableDown} + id='move-page-down-button' + > + + {t('ux_editor.page_menu_down')} + diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.module.css b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.module.css index 994a4b40924..895a5f0d879 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.module.css +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.module.css @@ -3,6 +3,7 @@ align-items: center; padding: var(--fds-spacing-1); background-color: var(--background-color); + border-top: 1px solid var(--fds-semantic-border-neutral-subtle); } .accordionHeader, @@ -26,3 +27,7 @@ .accordionHeaderRow { display: flex; } + +.pdfIcon { + font-size: var(--fds-sizing-6); +} diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx index 48c6f877792..2551e320399 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/PageAccordion/PageAccordion.tsx @@ -4,7 +4,7 @@ import classes from './PageAccordion.module.css'; import { Accordion } from '@digdir/designsystemet-react'; import { NavigationMenu } from './NavigationMenu'; import { pageAccordionContentId } from '@studio/testing/testids'; -import { TrashIcon } from '@studio/icons'; +import { FilePdfIcon, TrashIcon } from '@studio/icons'; import { useTranslation } from 'react-i18next'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { useAppContext } from '../../../hooks'; @@ -16,8 +16,10 @@ export type PageAccordionProps = { children: ReactNode; isOpen: boolean; onClick: () => void; - isValid?: boolean; - hasUniqueIds?: boolean; + isInvalid?: boolean; + hasDuplicatedIds?: boolean; + pageIsPdf?: boolean; + showNavigationMenu?: boolean; }; /** @@ -29,6 +31,7 @@ export type PageAccordionProps = { * @property {ReactNode}[children] - The children of the component * @property {boolean}[isOpen] - If the accordion is open or not * @property {function}[onClick] - Function to execute when the accordion is clicked + * @property {boolean}[pageIsPdf] - If the page is pdf or not * * @returns {ReactNode} - The rendered component */ @@ -37,8 +40,10 @@ export const PageAccordion = ({ children, isOpen, onClick, - isValid, - hasUniqueIds, + isInvalid, + hasDuplicatedIds, + pageIsPdf, + showNavigationMenu = true, }: PageAccordionProps): ReactNode => { const { t } = useTranslation(); const { org, app } = useStudioEnvironmentParams(); @@ -61,11 +66,11 @@ export const PageAccordion = ({ }; return ( - +
- + {pageIsPdf && } + {showNavigationMenu && } } diff --git a/frontend/packages/ux-editor/src/containers/DesignView/PdfLayout/PdfLayoutAccordion.tsx b/frontend/packages/ux-editor/src/containers/DesignView/PdfLayout/PdfLayoutAccordion.tsx new file mode 100644 index 00000000000..6ebcb0611b6 --- /dev/null +++ b/frontend/packages/ux-editor/src/containers/DesignView/PdfLayout/PdfLayoutAccordion.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { PageAccordion } from '@altinn/ux-editor/containers/DesignView/PageAccordion'; +import { duplicatedIdsExistsInLayout } from '@altinn/ux-editor/utils/formLayoutUtils'; +import { FormLayout } from '@altinn/ux-editor/containers/DesignView/FormLayout'; +import { Accordion } from '@digdir/design-system-react'; +import { useFormLayouts } from '@altinn/ux-editor/hooks'; +import { mapFormLayoutsToFormLayoutPages } from '@altinn/ux-editor/utils/formLayoutsUtils'; + +export interface PdfLayoutAccordionProps { + pdfLayoutName: string; + selectedFormLayoutName: string; + onAccordionClick: () => void; + hasDuplicatedIds: boolean; +} +export const PdfLayoutAccordion = ({ + pdfLayoutName, + selectedFormLayoutName, + onAccordionClick, + hasDuplicatedIds, +}: PdfLayoutAccordionProps): React.ReactNode => { + const layouts = useFormLayouts(); + const formLayoutData = mapFormLayoutsToFormLayoutPages(layouts); + const pdfLayoutData = formLayoutData.find((formLayout) => formLayout.page === pdfLayoutName); + + return ( + + + {pdfLayoutData.page === selectedFormLayoutName && ( + + )} + + + ); +}; diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.test.ts b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.test.ts index b0d494cb0c1..8a32d8fb5c9 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.test.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.test.ts @@ -1,5 +1,4 @@ import { queriesMock } from 'app-shared/mocks/queriesMock'; -import { queryClientMock } from 'app-shared/mocks/queryClientMock'; import { formLayoutSettingsMock, renderHookWithProviders } from '../../testing/mocks'; import { useDeleteLayoutMutation } from './useDeleteLayoutMutation'; import { @@ -12,6 +11,9 @@ import { QueryKey } from 'app-shared/types/QueryKey'; import { convertExternalLayoutsToInternalFormat } from '../../utils/formLayoutsUtils'; import { appContextMock } from '../../testing/appContextMock'; import { app, org } from '@studio/testing/testids'; +import type { ILayoutSettings } from 'app-shared/types/global'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { ObjectUtils } from '@studio/pure-functions'; // Test data: const selectedLayoutSet = layoutSet1NameMock; @@ -43,16 +45,34 @@ describe('useDeleteLayoutMutation', () => { const { result } = renderDeleteLayoutMutation(); await result.current.mutateAsync(layout2NameMock); }); + + it('Deletes the pdfLayoutName from settings.json if deleted layout was pdf', async () => { + const { result } = renderDeleteLayoutMutation({ + pages: { order: [], pdfLayoutName: layout1NameMock }, + }); + await result.current.mutateAsync(layout1NameMock); + expect(queriesMock.saveFormLayoutSettings).toHaveBeenCalledTimes(1); + expect(queriesMock.saveFormLayoutSettings).toHaveBeenCalledWith(org, app, selectedLayoutSet, { + pages: { order: [] }, + }); + }); }); -const renderDeleteLayoutMutation = () => { - queryClientMock.setQueryData( +const createFormLayoutSettingsMock = () => ObjectUtils.deepCopy(formLayoutSettingsMock); + +const renderDeleteLayoutMutation = ( + layoutSettings: ILayoutSettings = createFormLayoutSettingsMock(), +) => { + const queryClient = createQueryClientMock(); + queryClient.setQueryData( [QueryKey.FormLayouts, org, app, selectedLayoutSet], convertExternalLayoutsToInternalFormat(externalLayoutsMock), ); - queryClientMock.setQueryData( + queryClient.setQueryData( [QueryKey.FormLayoutSettings, org, app, selectedLayoutSet], - formLayoutSettingsMock, + layoutSettings, ); - return renderHookWithProviders(() => useDeleteLayoutMutation(org, app, selectedLayoutSet)); + return renderHookWithProviders(() => useDeleteLayoutMutation(org, app, selectedLayoutSet), { + queryClient, + }); }; diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts index 2ea7a9eeb76..37e3dde90dd 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useDeleteLayoutMutation.ts @@ -3,25 +3,20 @@ import { useServicesContext } from 'app-shared/contexts/ServicesContext'; import { QueryKey } from 'app-shared/types/QueryKey'; import type { IInternalLayout } from '../../types/global'; import { ObjectUtils } from '@studio/pure-functions'; -import { useFormLayoutSettingsQuery } from '../queries/useFormLayoutSettingsQuery'; -import type { ILayoutSettings } from 'app-shared/types/global'; -import { useFormLayoutSettingsMutation } from './useFormLayoutSettingsMutation'; import { useFormLayoutsQuery } from '../queries/useFormLayoutsQuery'; import { addOrRemoveNavigationButtons, firstAvailableLayout } from '../../utils/formLayoutsUtils'; import type { ExternalFormLayout } from 'app-shared/types/api/FormLayoutsResponse'; import { internalLayoutToExternal } from '../../converters/formLayoutConverters'; import { useAppContext } from '../'; +import { useSavableFormLayoutSettings } from '@altinn/ux-editor/hooks/useSavableFormLayoutSettings'; export const useDeleteLayoutMutation = (org: string, app: string, layoutSetName: string) => { const { deleteFormLayout, saveFormLayout } = useServicesContext(); - const { data: formLayouts } = useFormLayoutsQuery(org, app, layoutSetName); - const { data: formLayoutSettings } = useFormLayoutSettingsQuery(org, app, layoutSetName); + const layoutSettings = useSavableFormLayoutSettings(); const { selectedFormLayoutName, setSelectedFormLayoutName } = useAppContext(); - const formLayoutSettingsMutation = useFormLayoutSettingsMutation(org, app, layoutSetName); const queryClient = useQueryClient(); - const layoutOrder = formLayoutSettings?.pages?.order; const saveLayout = async (updatedLayoutName: string, updatedLayout: IInternalLayout) => { const convertedLayout: ExternalFormLayout = internalLayoutToExternal(updatedLayout); @@ -38,20 +33,14 @@ export const useDeleteLayoutMutation = (org: string, app: string, layoutSetName: await deleteFormLayout(org, app, layoutName, layoutSetName); return { layoutName, layouts }; }, - onSuccess: async ({ layoutName, layouts }) => { - const layoutSettings: ILayoutSettings = ObjectUtils.deepCopy(formLayoutSettings); - const { order } = layoutSettings?.pages; - - if (order.includes(layoutName)) { - order.splice(order.indexOf(layoutName), 1); - } - - formLayoutSettingsMutation.mutate(layoutSettings); + onSuccess: ({ layoutName, layouts }) => { + layoutSettings.deleteLayoutByName(layoutName); + layoutSettings.save(); queryClient.setQueryData([QueryKey.FormLayouts, org, app, layoutSetName], () => layouts); if (selectedFormLayoutName === layoutName) { - const layoutToSelect = firstAvailableLayout(layoutName, layoutOrder); + const layoutToSelect = firstAvailableLayout(layoutName, layoutSettings.getLayoutsOrder()); setSelectedFormLayoutName(layoutToSelect); } }, diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutSettingsMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutSettingsMutation.ts index f9cc28bb19f..9bd40f2872b 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutSettingsMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useFormLayoutSettingsMutation.ts @@ -3,7 +3,7 @@ import type { ILayoutSettings } from 'app-shared/types/global'; import { useServicesContext } from 'app-shared/contexts/ServicesContext'; import { QueryKey } from 'app-shared/types/QueryKey'; import { usePreviewConnection } from 'app-shared/providers/PreviewConnectionContext'; -import { useAppContext } from '..'; +import { useAppContext } from '../useAppContext'; export const useFormLayoutSettingsMutation = (org: string, app: string, layoutSetName: string) => { const previewConnection = usePreviewConnection(); @@ -13,7 +13,7 @@ export const useFormLayoutSettingsMutation = (org: string, app: string, layoutSe return useMutation({ mutationFn: (settings: ILayoutSettings) => saveFormLayoutSettings(org, app, layoutSetName, settings).then(() => settings), - onSuccess: async (savedSettings) => { + onSuccess: async (savedSettings: ILayoutSettings) => { if (previewConnection && previewConnection.state === 'Connected') { await previewConnection.send('sendMessage', 'reload-layouts').catch(function (err) { return console.error(err.toString()); diff --git a/frontend/packages/ux-editor/src/hooks/mutations/useUpdateLayoutNameMutation.ts b/frontend/packages/ux-editor/src/hooks/mutations/useUpdateLayoutNameMutation.ts index d329dcb10fb..7aa653acd10 100644 --- a/frontend/packages/ux-editor/src/hooks/mutations/useUpdateLayoutNameMutation.ts +++ b/frontend/packages/ux-editor/src/hooks/mutations/useUpdateLayoutNameMutation.ts @@ -25,7 +25,7 @@ export const useUpdateLayoutNameMutation = (org: string, app: string, layoutSetN oldName, newName, })), - onSuccess: async ({ oldName, newName }) => { + onSuccess: ({ oldName, newName }) => { queryClient.setQueryData( [QueryKey.FormLayouts, org, app, layoutSetName], (oldLayouts: IFormLayouts) => { @@ -36,8 +36,9 @@ export const useUpdateLayoutNameMutation = (org: string, app: string, layoutSetN }, ); const layoutSettings: ILayoutSettings = ObjectUtils.deepCopy(formLayoutSettingsQuery.data); - const { order } = layoutSettings?.pages; + const { order, pdfLayoutName } = layoutSettings?.pages; if (order.includes(oldName)) order[order.indexOf(oldName)] = newName; + if (pdfLayoutName === oldName) layoutSettings.pages.pdfLayoutName = newName; formLayoutSettingsMutation.mutate(layoutSettings); setSelectedFormLayoutName(newName); diff --git a/frontend/packages/ux-editor/src/hooks/usePdf/usePdf.ts b/frontend/packages/ux-editor/src/hooks/usePdf/usePdf.ts new file mode 100644 index 00000000000..3b66095a7e8 --- /dev/null +++ b/frontend/packages/ux-editor/src/hooks/usePdf/usePdf.ts @@ -0,0 +1,34 @@ +import { useAppContext } from '@altinn/ux-editor/hooks'; +import { useSavableFormLayoutSettings } from '@altinn/ux-editor/hooks/useSavableFormLayoutSettings'; + +export const usePdf = () => { + const { selectedFormLayoutName } = useAppContext(); + const savableLayoutSettings = useSavableFormLayoutSettings(); + const layoutSettings = savableLayoutSettings.getFormLayoutSettings(); + + const getPdfLayoutName = (): string => { + return layoutSettings.getPdfLayoutName(); + }; + + const isCurrentPagePdf = (): boolean => { + return layoutSettings.getPdfLayoutName() === selectedFormLayoutName; + }; + + const convertCurrentPageToPdf = (): void => { + layoutSettings.setPdfLayoutName(selectedFormLayoutName); + layoutSettings.deletePageFromOrder(selectedFormLayoutName); + }; + + const convertExistingPdfToPage = (): void => { + const existingPdfLayout = layoutSettings.getPdfLayoutName(); + layoutSettings.addPageToOrder(existingPdfLayout); + layoutSettings.deletePdfLayoutName(); + }; + + return { + getPdfLayoutName, + isCurrentPagePdf, + convertCurrentPageToPdf, + convertExistingPdfToPage, + }; +}; diff --git a/frontend/packages/ux-editor/src/hooks/useSavableFormLayoutSettings.ts b/frontend/packages/ux-editor/src/hooks/useSavableFormLayoutSettings.ts index 029dfef70a5..eb81a0b1639 100644 --- a/frontend/packages/ux-editor/src/hooks/useSavableFormLayoutSettings.ts +++ b/frontend/packages/ux-editor/src/hooks/useSavableFormLayoutSettings.ts @@ -1,14 +1,14 @@ import { SavableFormLayoutSettings } from '@altinn/ux-editor/classes/SavableFormLayoutSettings'; -import { useFormLayoutsQuery } from '@altinn/ux-editor/hooks/queries/useFormLayoutsQuery'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { useAppContext } from '@altinn/ux-editor/hooks/useAppContext'; import { useFormLayoutSettingsMutation } from './mutations/useFormLayoutSettingsMutation'; import { FormLayoutSettings } from '@altinn/ux-editor/classes/FormLayoutSettings'; +import { useFormLayoutSettingsQuery } from './queries/useFormLayoutSettingsQuery'; export const useSavableFormLayoutSettings = () => { const { org, app } = useStudioEnvironmentParams(); const { selectedFormLayoutSetName } = useAppContext(); - const { data: layoutSettings } = useFormLayoutsQuery(org, app, selectedFormLayoutSetName); + const { data: layoutSettings } = useFormLayoutSettingsQuery(org, app, selectedFormLayoutSetName); const { mutate: saveLayoutSettings } = useFormLayoutSettingsMutation( org, app, diff --git a/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts b/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts index 3d55d351d7c..ce5bb72b01b 100644 --- a/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts +++ b/frontend/packages/ux-editor/src/utils/formLayoutsUtils.ts @@ -12,6 +12,7 @@ import { ObjectUtils, ArrayUtils } from '@studio/pure-functions'; import { DEFAULT_SELECTED_LAYOUT_NAME } from 'app-shared/constants'; import type { FormLayoutsResponse } from 'app-shared/types/api/FormLayoutsResponse'; import { externalLayoutToInternal } from '../converters/formLayoutConverters'; +import type { FormLayoutPage } from '@altinn/ux-editor/types/FormLayoutPage'; /** * Update layouts to have navigation buttons if there are multiple layouts, or remove them if this is the only one. @@ -103,3 +104,17 @@ export const firstAvailableLayout = (deletedLayoutName: string, layoutPagesOrder export function idExists(id: string, formLayouts: IFormLayouts): boolean { return Object.values(formLayouts).some((layout) => idExistsInLayout(id, layout)); } + +/** + * Maps the content of all layouts in a layout set to an array of objects with layout name and all components on layout + * + * @param formLayouts all layouts as key value pairs where the key is the layout name + * + * @returns an array of objects with layout name and components + */ +export const mapFormLayoutsToFormLayoutPages = (formLayouts: IFormLayouts): FormLayoutPage[] => { + return Object.entries(formLayouts).map(([key, value]) => ({ + page: key, + data: value, + })); +};