From 5783122f3b694a445fb4679ba33bde215b9a5b22 Mon Sep 17 00:00:00 2001 From: Thomas Bakken <70642698+tba76@users.noreply.github.com> Date: Fri, 6 Dec 2024 10:23:01 +0100 Subject: [PATCH 01/35] chore: Patch azure security keyvault (#14232) --- src/Altinn.Platform/Altinn.Platform.PDF/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml b/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml index 6f32ac6cef0..ccff787f8a5 100644 --- a/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml +++ b/src/Altinn.Platform/Altinn.Platform.PDF/pom.xml @@ -68,7 +68,7 @@ com.azure azure-security-keyvault-secrets - 4.9.0 + 4.9.1 com.microsoft.azure From d7461e3798384279eb1404d268ac6420e63046aa Mon Sep 17 00:00:00 2001 From: Martin Gunnerud Date: Fri, 6 Dec 2024 14:42:32 +0100 Subject: [PATCH 02/35] chore(resource-adm): Use new StudioContentMenu in resource admin (#14147) --- .../GoBackButton/GoBackButton.module.css | 17 -- .../GoBackButton/GoBackButton.test.tsx | 28 --- .../GoBackButton/GoBackButton.tsx | 50 ----- .../LeftNavigationBar/GoBackButton/index.ts | 1 - .../LeftNavigationBar.module.css | 47 ----- .../LeftNavigationBar.test.tsx | 178 ------------------ .../LeftNavigationBar/LeftNavigationBar.tsx | 96 ---------- .../Tab/LeftNavigationTab.ts | 22 --- .../LeftNavigationBar/Tab/Tab.module.css | 31 --- .../LeftNavigationBar/Tab/Tab.test.tsx | 125 ------------ .../components/LeftNavigationBar/Tab/Tab.tsx | 63 ------- .../Tab/TabContent/TabContent.test.tsx | 127 ------------- .../Tab/TabContent/TabContent.tsx | 99 ---------- .../LeftNavigationBar/Tab/TabContent/index.ts | 1 - .../components/LeftNavigationBar/Tab/index.ts | 1 - .../components/LeftNavigationBar/index.ts | 2 - .../ResourcePage/ResourcePage.module.css | 5 - .../pages/ResourcePage/ResourcePage.tsx | 108 ++++++----- .../resourceadm/types/NavigationBarPage.d.ts | 8 +- .../resourceadm/utils/resourceUtils/index.ts | 2 - .../resourceUtils/resourceUtils.test.tsx | 41 ---- .../utils/resourceUtils/resourceUtils.ts | 47 ----- 22 files changed, 60 insertions(+), 1039 deletions(-) delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.module.css delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.test.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/GoBackButton/index.ts delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.module.css delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.test.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/LeftNavigationTab.ts delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.module.css delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.test.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.test.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.tsx delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/index.ts delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/Tab/index.ts delete mode 100644 frontend/resourceadm/components/LeftNavigationBar/index.ts diff --git a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.module.css b/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.module.css deleted file mode 100644 index e6a7d63b8b2..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.module.css +++ /dev/null @@ -1,17 +0,0 @@ -.icon { - font-size: 1.8rem; - color: var(--fds-semantic-text-neutral-default); -} - -.backButton { - border-bottom: 2px solid var(--fds-semantic-border-divider-default); -} - -.backButton:hover { - background-color: var(--fds-semantic-surface-neutral-subtle-hover); -} - -.buttonText { - color: var(--fds-semantic-text-neutral-default); - margin-left: 10px; -} diff --git a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.test.tsx b/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.test.tsx deleted file mode 100644 index c160d839424..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import type { GoBackButtonProps } from './GoBackButton'; -import { GoBackButton } from './GoBackButton'; -import { MemoryRouter } from 'react-router-dom'; - -const mockBackButtonText: string = 'Go back'; - -describe('GoBackButton', () => { - afterEach(jest.clearAllMocks); - - const defaultProps: GoBackButtonProps = { - className: '.navigationElement', - to: '/back', - text: mockBackButtonText, - }; - - it('calls the "onClickBackButton" function when the button is clicked', () => { - render( - - - , - ); - - const backButton = screen.getByRole('link', { name: mockBackButtonText }); - expect(backButton).toBeInTheDocument(); - }); -}); diff --git a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.tsx b/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.tsx deleted file mode 100644 index 1c86417a303..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/GoBackButton.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import type { ReactNode } from 'react'; -import React from 'react'; -import classes from './GoBackButton.module.css'; -import cn from 'classnames'; -import { ArrowLeftIcon } from '@studio/icons'; -import { Paragraph } from '@digdir/designsystemet-react'; -import { NavLink } from 'react-router-dom'; - -export type GoBackButtonProps = { - /** - * Classname for navigation element - */ - className?: string; - /** - * Text on the back button - */ - text: string; - /** - * Where to navigate to - */ - to: string; -}; - -/** - * @component - * Displays the back button on top of the LeftNavigationBar - * - * @example - * - * - * @property {string}[className] - Classname for navigation element - * @property {string}[text] - Text on the back button - * @property {string}[to] - Where to navigate to - * - * @returns {ReactNode} - The rendered component - */ -export const GoBackButton = ({ className, text, to }: GoBackButtonProps): ReactNode => { - return ( - - - - {text} - - - ); -}; diff --git a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/index.ts b/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/index.ts deleted file mode 100644 index 0d72abda6ef..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/GoBackButton/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { GoBackButton } from './GoBackButton'; diff --git a/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.module.css b/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.module.css deleted file mode 100644 index 29697430968..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.module.css +++ /dev/null @@ -1,47 +0,0 @@ -.navigationBar { - background-color: var(--fds-semantic-background-subtle); - width: 250px; - height: 100%; - left: 0; - border-right: 1px solid var(--fds-semantic-border-divider-default); - border-top: none; -} - -.navigationElements { - display: flex; - flex-direction: column; -} - -.navigationElement { - display: flex; - align-items: center; - padding-left: 15px; - padding-block: 20px; - border: none; - border-bottom: 1px solid var(--fds-semantic-border-divider-default); - background-color: var(--fds-semantic-background-subtle); - cursor: pointer; -} - -.navigationElement:hover { - background-color: var(--fds-semantic-background-default); - z-index: 1; -} - -.navigationElement:focus-visible { - background-color: var(--fds-semantic-background-default); - z-index: 2; - border-bottom: none; - - outline: var(--focus-border-width) solid var(--focus-border-outline-color); - outline-offset: var(--focus-border-width); - box-shadow: - 8px 0 0 0 var(--fds-semantic-border-action-active) inset, - 0 0 0 var(--focus-border-width) var(--focus-border-inner-color); -} - -.navigationElement:focus:not(:focus-visible) { - outline: none; - box-shadow: none; - background-color: var(--fds-semantic-background-subtle); -} diff --git a/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.test.tsx b/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.test.tsx deleted file mode 100644 index f9a56bc1647..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.test.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import React from 'react'; -import { render as rtlRender, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { LeftNavigationBarProps } from './LeftNavigationBar'; -import { LeftNavigationBar } from './LeftNavigationBar'; -import type { LeftNavigationTab, TabAction } from './Tab/LeftNavigationTab'; -import { TestFlaskIcon } from '@studio/icons'; -import { MemoryRouter } from 'react-router-dom'; -import { textMock } from '@studio/testing/mocks/i18nMock'; - -const mockOnClick = jest.fn(); - -const mockTo: string = '/test'; - -const mockLinkAction1: TabAction = { - type: 'link', - to: mockTo, - onClick: mockOnClick, -}; - -const mockLinkAction2: TabAction = { - type: 'link', - to: mockTo, -}; - -const mockButtonAction: TabAction = { - type: 'button', - onClick: mockOnClick, -}; - -const mockTabId1: string = 'tab1'; -const mockTabId2: string = 'tab2'; -const mockTabId3: string = 'tab3'; - -const mockTabs: LeftNavigationTab[] = [ - { - icon: , - tabName: `test.test_${mockTabId1}`, - tabId: mockTabId1, - action: mockLinkAction1, - isActiveTab: true, - }, - { - icon: , - tabName: `test.test_${mockTabId2}`, - tabId: mockTabId2, - action: mockButtonAction, - isActiveTab: false, - }, - { - icon: , - tabName: `test.test_${mockTabId3}`, - tabId: mockTabId3, - action: mockLinkAction2, - isActiveTab: false, - }, -]; - -const mockBackButtonText: string = 'Go back'; -const mockBackButtonHref: string = '/back'; - -describe('LeftNavigationBar', () => { - afterEach(jest.clearAllMocks); - - const defaultProps: LeftNavigationBarProps = { - tabs: mockTabs, - upperTab: 'backButton', - backLink: mockBackButtonHref, - backLinkText: mockBackButtonText, - selectedTab: mockTabId1, - }; - - it('calls the onClick function when a tab is clicked and action type is button', async () => { - const user = userEvent.setup(); - render(defaultProps); - - const nextTab = mockTabs[1]; - - const tab2 = screen.getByRole('tab', { name: textMock(nextTab.tabName) }); - await user.click(tab2); - expect(nextTab.action.onClick).toHaveBeenCalledTimes(1); - }); - - it('calls the onClick function when a tab is clicked and action type is link and onClick is present', async () => { - const user = userEvent.setup(); - render(defaultProps); - - const nextTab = mockTabs[1]; - const tab2 = screen.getByRole('tab', { name: textMock(nextTab.tabName) }); - await user.click(tab2); - - const newNextTab = mockTabs[0]; - const tab1 = screen.getByRole('tab', { name: textMock(newNextTab.tabName) }); - await user.click(tab1); - - expect(newNextTab.action.onClick).toHaveBeenCalledTimes(1); - }); - - it('does not call the onClick function when a tab is clicked and action type is link and onClick is not present', async () => { - const user = userEvent.setup(); - render(defaultProps); - - const nextTab = mockTabs[2]; - - const tab3 = screen.getByRole('tab', { name: textMock(nextTab.tabName) }); - await user.click(tab3); - expect(mockOnClick).not.toHaveBeenCalled(); - }); - - it('does not call the onClick function when the active tab is clicked', async () => { - const user = userEvent.setup(); - render(defaultProps); - - const currentTab = mockTabs[0]; - - const tab1 = screen.getByRole('tab', { name: textMock(currentTab.tabName) }); - await user.click(tab1); - expect(currentTab.action.onClick).toHaveBeenCalledTimes(0); - }); - - it('displays back button when "upperTab" is backButton and "backButtonHref" and "backButtonText" is present', () => { - render(defaultProps); - - const backButton = screen.getByRole('link', { name: mockBackButtonText }); - expect(backButton).toBeInTheDocument(); - }); - - it('does not display the back button when "upperTab" is backButton and "backButtonHref" or "backButtonText" is not present', () => { - render({ tabs: mockTabs, selectedTab: mockTabId1 }); - - const backButton = screen.queryByRole('link', { name: mockBackButtonText }); - expect(backButton).not.toBeInTheDocument(); - }); - - it('handles tab navigation correctly', async () => { - const user = userEvent.setup(); - render({ tabs: mockTabs, selectedTab: mockTabId1 }); - - await user.tab(); - expect(getTabItem(mockTabId1)).toHaveFocus(); - await user.keyboard('{arrowdown}'); - expect(getTabItem(mockTabId2)).toHaveFocus(); - await user.keyboard('{arrowdown}'); - expect(getTabItem(mockTabId3)).toHaveFocus(); - await user.keyboard('{arrowdown}'); - expect(getTabItem(mockTabId1)).toHaveFocus(); - await user.keyboard('{arrowup}'); - expect(getTabItem(mockTabId3)).toHaveFocus(); - await user.keyboard('{arrowup}'); - expect(getTabItem(mockTabId2)).toHaveFocus(); - }); - - it('selects a tab when pressing "enter"', async () => { - const user = userEvent.setup(); - render({ tabs: mockTabs, selectedTab: mockTabId1 }); - - await user.tab(); - expect(getTabItem(mockTabId1)).toHaveFocus(); - await user.keyboard('{arrowdown}'); - expect(getTabItem(mockTabId2)).toHaveFocus(); - - await user.keyboard('{enter}'); - expect(mockTabs[1].action.onClick).toHaveBeenCalledTimes(1); - }); -}); - -const getTabItem = (tabId: string) => { - const tabName: string = `test.test_${tabId}`; - return screen.getByRole('tab', { name: textMock(tabName) }); -}; - -const render = (props: LeftNavigationBarProps) => { - return rtlRender( - - - , - ); -}; diff --git a/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.tsx b/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.tsx deleted file mode 100644 index 394191ce269..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/LeftNavigationBar.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import type { ReactNode } from 'react'; -import React, { useRef, useState } from 'react'; -import classes from './LeftNavigationBar.module.css'; -import type { LeftNavigationTab } from './Tab/LeftNavigationTab'; -import { GoBackButton } from './GoBackButton'; -import { Tab } from './Tab'; -import cn from 'classnames'; -import { useUpdate } from 'app-shared/hooks/useUpdate'; - -export type LeftNavigationBarProps = { - tabs: LeftNavigationTab[]; - upperTab?: 'backButton' | undefined; - backLink?: string; - backLinkText?: string; - className?: string; - selectedTab: string; -}; - -export const LeftNavigationBar = ({ - tabs, - upperTab = undefined, - backLink, - backLinkText = '', - className, - selectedTab, -}: LeftNavigationBarProps): ReactNode => { - const tablistRef = useRef(null); - - const initialTab = selectedTab ?? tabs[0].tabId; - const lastIndex = tabs.length - 1; - - const findTabIndexByValue = (value: string) => tabs.findIndex((tab) => tab.tabId === value); - const [focusIndex, setFocusIndex] = useState(findTabIndexByValue(initialTab)); - - const [newTabIdClicked, setNewTabIdClicked] = useState(null); - - useUpdate(() => { - tablistRef.current?.querySelectorAll('[role="tab"]')[focusIndex].focus(); - }, [focusIndex]); - - const handleClick = (tabId: string) => { - const tabClicked = tabs.find((tab: LeftNavigationTab) => tab.tabId === tabId); - if (tabClicked && !tabClicked.isActiveTab) { - setNewTabIdClicked(tabId); - tabClicked.action.onClick(tabId); - } - }; - - const displayUpperTab = () => { - if (upperTab === 'backButton' && backLink && backLinkText) { - return ( - - ); - } - return null; - }; - - const moveFocusDown = () => setFocusIndex(focusIndex === lastIndex ? 0 : focusIndex + 1); - - const moveFocusUp = () => setFocusIndex(focusIndex === 0 ? lastIndex : focusIndex - 1); - - const onKeyDown = (event: React.KeyboardEvent) => { - switch (event.key) { - case 'ArrowDown': - event.preventDefault(); - moveFocusDown(); - break; - case 'ArrowUp': - event.preventDefault(); - moveFocusUp(); - break; - } - }; - - const displayTabs = tabs.map((tab: LeftNavigationTab, i: number) => ( - handleClick(tab.tabId)} - key={tab.tabId} - navElementClassName={cn(classes.navigationElement)} - onBlur={() => setNewTabIdClicked(null)} - newTabIdClicked={newTabIdClicked} - tabIndex={focusIndex === i ? 0 : -1} - onKeyDown={onKeyDown} - /> - )); - - return ( -
-
- {displayUpperTab()} - {displayTabs} -
-
- ); -}; diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/LeftNavigationTab.ts b/frontend/resourceadm/components/LeftNavigationBar/Tab/LeftNavigationTab.ts deleted file mode 100644 index 4ce267f42cb..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/LeftNavigationTab.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ReactNode } from 'react'; - -interface TabLink { - type: 'link'; - onClick?: (tabId: string) => void; - to: string; -} - -interface TabButton { - type: 'button'; - onClick: (tabId: string) => void; -} - -export type TabAction = TabLink | TabButton; - -export interface LeftNavigationTab { - icon: ReactNode; - tabName: string; - tabId: string; - action: TabAction; - isActiveTab: boolean; -} diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.module.css b/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.module.css deleted file mode 100644 index 5a9fa863538..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.module.css +++ /dev/null @@ -1,31 +0,0 @@ -.selected { - --fdsc-alert-box-shadow-left: 8px 0 0 0 var(--fds-semantic-border-action-default) inset; - box-shadow: var(--fdsc-alert-box-shadow-left); -} - -.selected:focus:not(:focus-visible) { - --fdsc-alert-box-shadow-left: 8px 0 0 0 var(--fds-semantic-border-action-active) inset; - box-shadow: var(--fdsc-alert-box-shadow-left); -} - -.newPage { - box-shadow: none; - display: flex; - align-items: center; - padding-left: 15px; - padding-block: 20px; - border: none; - border-bottom: 1px solid var(--fds-semantic-border-divider-default); - background-color: var(--fds-semantic-background-subtle); - cursor: pointer; -} - -.newPage:focus-within { - --fdsc-alert-box-shadow-left: 8px 0 0 0 var(--fds-semantic-border-action-active) inset; - box-shadow: var(--fdsc-alert-box-shadow-left); -} - -.buttonText { - color: var(--fds-semantic-text-neutral-default); - margin-left: 10px; -} diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.test.tsx b/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.test.tsx deleted file mode 100644 index cbe9158bd9a..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -import React from 'react'; -import { render as rtlRender, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { LeftNavigationTab, TabAction } from './LeftNavigationTab'; -import { TestFlaskIcon } from '@studio/icons'; -import type { TabProps } from './Tab'; -import { Tab } from './Tab'; -import { MemoryRouter } from 'react-router-dom'; -import { textMock } from '@studio/testing/mocks/i18nMock'; - -const mockOnClick = jest.fn(); -const mockOnKeyDown = jest.fn(); -const mockOnBlur = jest.fn(); - -const mockTo: string = '/test'; - -const mockLinkAction: TabAction = { - type: 'link', - to: mockTo, - onClick: mockOnClick, -}; - -const mockLinkAction2: TabAction = { - type: 'link', - to: mockTo, -}; - -const mockButtonAction: TabAction = { - type: 'button', - onClick: mockOnClick, -}; - -const mockTabId1: string = 'tab1'; -const mockTabId2: string = 'tab2'; - -const mockTab1: LeftNavigationTab = { - icon: , - tabName: `test.test_${mockTabId1}`, - tabId: mockTabId1, - action: mockLinkAction, - isActiveTab: true, -}; - -const mockTab2: LeftNavigationTab = { - ...mockTab1, - action: mockLinkAction2, -}; - -const mockTab3: LeftNavigationTab = { - ...mockTab1, - action: mockButtonAction, -}; - -describe('Tab', () => { - afterEach(jest.clearAllMocks); - - const defaultProps: TabProps = { - tab: mockTab1, - navElementClassName: '.navigationElement', - onBlur: mockOnBlur, - onClick: mockOnClick, - newTabIdClicked: mockTabId2, - tabIndex: 0, - onKeyDown: mockOnKeyDown, - }; - - it('calls the onClick function when onClick is present and type is link', async () => { - const user = userEvent.setup(); - render(defaultProps); - - const tabLink = screen.getByRole('tab', { name: textMock(mockTab1.tabName) }); - await user.click(tabLink); - - expect(mockTab1.action.onClick).toHaveBeenCalledTimes(1); - }); - - it('does not call the onClick function when onClick is not present and type is link', async () => { - const user = userEvent.setup(); - render({ ...defaultProps, tab: mockTab2 }); - - const tabLink = screen.getByRole('tab', { name: textMock(mockTab2.tabName) }); - await user.click(tabLink); - - expect(mockOnClick).not.toHaveBeenCalled(); - }); - - it('calls the onClick function when type is button', async () => { - const user = userEvent.setup(); - render({ ...defaultProps, tab: mockTab3 }); - - const tabButton = screen.getByRole('tab', { name: textMock(mockTab3.tabName) }); - await user.click(tabButton); - - expect(mockTab3.action.onClick).toHaveBeenCalledTimes(1); - }); - - it('calls the "onKeyDown" function when a tab is clicked with keyboard', async () => { - const user = userEvent.setup(); - render({ ...defaultProps, tab: mockTab3 }); - - const tabButton = screen.getByRole('tab', { name: textMock(mockTab3.tabName) }); - await user.click(tabButton); - await user.keyboard('{Tab}'); - expect(mockOnKeyDown).toHaveBeenCalledTimes(1); - }); - - it('calls the onBlur function when the tab is blurred', async () => { - const user = userEvent.setup(); - render(defaultProps); - - const tabLink = screen.getByRole('tab', { name: textMock(mockTab1.tabName) }); - await user.click(tabLink); - await user.tab(); - - expect(mockOnBlur).toHaveBeenCalledTimes(1); - }); -}); - -const render = (props: TabProps) => { - return rtlRender( - - - , - ); -}; diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.tsx b/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.tsx deleted file mode 100644 index aa70fd908d1..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/Tab.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import type { ReactNode } from 'react'; -import React from 'react'; -import classes from './Tab.module.css'; -import cn from 'classnames'; -import type { LeftNavigationTab } from './LeftNavigationTab'; -import { Paragraph } from '@digdir/designsystemet-react'; -import { useTranslation } from 'react-i18next'; -import { TabContent } from './TabContent'; - -export type TabProps = { - tab: LeftNavigationTab; - navElementClassName: string; - newTabIdClicked: string; - onBlur: () => void; - onClick: () => void; - tabIndex: number; - onKeyDown: (event: React.KeyboardEvent) => void; -}; - -/** - * @component - * Displays a tab in the left navigation bar. - * - * @property {LeftNavigationTab}[tab] - The navigation tab - * @property {string}[navElementClassName] - Classname for navigation element - * @property {string}[newTabIdClicked] - Id of the new tab clicked - * @property {function}[onBlur] - Function to execute on blur - * @property {function}[onClick] - Function to execute on click - * @property {number}[tabIndex] - The index of the tab - * @property {function}[onKeyDown] - Function to be executed on key press - * - * @returns {ReactNode} - The rendered component - */ -export const Tab = ({ - tab, - navElementClassName, - newTabIdClicked, - onBlur, - onClick, - tabIndex, - onKeyDown, -}: TabProps): ReactNode => { - const { t } = useTranslation(); - - return ( - - {tab.icon} - - {t(tab.tabName)} - - - ); -}; diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.test.tsx b/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.test.tsx deleted file mode 100644 index f125b8ae9b3..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.test.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import React from 'react'; -import { render as rtlRender, screen } from '@testing-library/react'; -import type { TabContentProps } from './TabContent'; -import { TabContent } from './TabContent'; -import type { TabAction } from '../LeftNavigationTab'; -import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; - -const mockOnClick = jest.fn(); -const mockOnBlur = jest.fn(); -const mockOnKeyDown = jest.fn(); - -const mockTo: string = '/test'; - -const mockLinkAction: TabAction = { - type: 'link', - to: mockTo, - onClick: mockOnClick, -}; - -const mockLinkAction2: TabAction = { - type: 'link', - to: mockTo, -}; - -const mockButtonAction: TabAction = { - type: 'button', - onClick: mockOnClick, -}; - -const mockTabName: string = 'Tab 1'; - -describe('TabWrapper', () => { - afterEach(jest.clearAllMocks); - - const defaultProps: TabContentProps = { - className: '.navElement', - onBlur: mockOnBlur, - onClick: mockOnClick, - action: mockLinkAction, - children:

{mockTabName}

, - tabIndex: 0, - onKeyDown: mockOnKeyDown, - }; - - it('renders a link wrapper when action type is link', () => { - render(defaultProps); - - const linkWrapper = screen.getByRole('tab', { name: mockTabName }); - expect(linkWrapper).toBeInTheDocument(); - expect(linkWrapper).toHaveAttribute('href', mockLinkAction.to); - }); - - it('calls onClick when onClick is present and type is link', async () => { - const user = userEvent.setup(); - render(defaultProps); - - const linkWrapper = screen.getByRole('tab', { name: mockTabName }); - - await user.click(linkWrapper); - expect(mockLinkAction.onClick).toHaveBeenCalledTimes(1); - }); - - it('does not call onClick when onClick is not present and type is link', async () => { - const user = userEvent.setup(); - render({ ...defaultProps, action: mockLinkAction2 }); - - const linkWrapper = screen.getByRole('tab', { name: mockTabName }); - - await user.click(linkWrapper); - expect(mockOnClick).not.toHaveBeenCalled(); - }); - - it('renders a button wrapper when action type is button', () => { - render({ ...defaultProps, action: mockButtonAction }); - - const buttonWrapper = screen.getByRole('tab', { name: mockTabName }); - expect(buttonWrapper).toBeInTheDocument(); - }); - - it('executes the onClick handler when button wrapper is clicked', async () => { - const user = userEvent.setup(); - render({ ...defaultProps, action: mockButtonAction }); - - const buttonWrapper = screen.getByRole('tab', { name: mockTabName }); - await user.click(buttonWrapper); - expect(mockButtonAction.onClick).toHaveBeenCalledTimes(1); - }); - - it('calls the "onKeyDown" function when a tab is clicked with keyboard', async () => { - const user = userEvent.setup(); - render({ ...defaultProps, action: mockButtonAction }); - - const buttonWrapper = screen.getByRole('tab', { name: mockTabName }); - await user.click(buttonWrapper); - await user.keyboard('{Tab}'); - expect(mockOnKeyDown).toHaveBeenCalledTimes(1); - }); - - it('executes the onBlur when the wrapper is tabbed through and type is button', async () => { - const user = userEvent.setup(); - render({ ...defaultProps, action: mockButtonAction }); - - const buttonWrapper = screen.getByRole('tab', { name: mockTabName }); - await user.click(buttonWrapper); - await user.tab(); - expect(mockOnBlur).toHaveBeenCalledTimes(1); - }); - - it('executes the onBlur when the wrapper is tabbed through and type is link', async () => { - const user = userEvent.setup(); - render({ ...defaultProps }); - - const linkWrapper = screen.getByRole('tab', { name: mockTabName }); - await user.click(linkWrapper); - await user.tab(); - expect(mockOnBlur).toHaveBeenCalledTimes(1); - }); -}); - -const render = (props: TabContentProps) => { - return rtlRender( - - - , - ); -}; diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.tsx b/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.tsx deleted file mode 100644 index fb1f0a1ed9c..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/TabContent.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import type { TabAction } from '../LeftNavigationTab'; -import type { ReactNode } from 'react'; -import React from 'react'; -import { NavLink } from 'react-router-dom'; - -export type TabContentProps = { - className: string; - onBlur: () => void; - onClick?: () => void; - action: TabAction; - children: ReactNode; - tabIndex: number; - onKeyDown: (event: React.KeyboardEvent) => void; -}; - -/** - * @component - * Displays a Wrapper around each tab. The type of the wrapper - * is decided by the type of the action provided to the component. - * - * @example - * - * {children} - * - * - * @property {string}[className] - Classname of the component - * @property {function}[onBlur] - Fucntion to execute on blur - * @property {function}[onClick] - Function to execute on click - * @property {TabAction}[action] - The tab action - * @property {ReactNode}[children] - Children of the component - * @property {number}[tabIndex] - The index of the tab - * @property {function}[onKeyDown] - Function to be executed on key press - * - * @returns {ReactNode} - The rendered component - */ -export const TabContent = ({ - className, - onBlur, - onClick, - action, - children, - tabIndex, - - onKeyDown, -}: TabContentProps): ReactNode => { - /** - * Executes the on click of the action if it exists and type is link - */ - const handleClickLink = (e: React.MouseEvent) => { - if (action.onClick && onClick) { - e.preventDefault(); - onClick(); - } - }; - - /** - * Based on the type of the action, render different components as wrapper - */ - switch (action.type) { - case 'link': { - return ( - - {children} - - ); - } - case 'button': { - return ( - - ); - } - } -}; diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/index.ts b/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/index.ts deleted file mode 100644 index 7bb6116e906..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/TabContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TabContent } from './TabContent'; diff --git a/frontend/resourceadm/components/LeftNavigationBar/Tab/index.ts b/frontend/resourceadm/components/LeftNavigationBar/Tab/index.ts deleted file mode 100644 index 308083188b2..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/Tab/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Tab } from './Tab'; diff --git a/frontend/resourceadm/components/LeftNavigationBar/index.ts b/frontend/resourceadm/components/LeftNavigationBar/index.ts deleted file mode 100644 index 9049d0e593d..00000000000 --- a/frontend/resourceadm/components/LeftNavigationBar/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { LeftNavigationBar } from './LeftNavigationBar'; -export type { LeftNavigationTab } from './Tab/LeftNavigationTab'; diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.module.css b/frontend/resourceadm/pages/ResourcePage/ResourcePage.module.css index 90fc393b327..f5da729f0b8 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.module.css +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.module.css @@ -15,8 +15,3 @@ .spinnerWrapper { padding: 10%; } - -.icon { - color: var(--fds-semantic-text-neutral-default); - font-size: 1.8rem; -} diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx index 72309e691fb..722aa856835 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import type { NavigationBarPage } from '../../types/NavigationBarPage'; import classes from './ResourcePage.module.css'; import { PolicyEditorPage } from '../PolicyEditorPage'; @@ -17,21 +17,22 @@ import { useEditResourceMutation } from '../../hooks/mutations'; import { MigrationPage } from '../MigrationPage'; import type { Resource } from 'app-shared/types/ResourceAdm'; import { useTranslation } from 'react-i18next'; -import type { LeftNavigationTab } from '../../components/LeftNavigationBar'; import { + ArrowLeftIcon, GavelSoundBlockIcon, InformationSquareIcon, MigrationIcon, UploadIcon, } from '@studio/icons'; -import { LeftNavigationBar } from '../../components/LeftNavigationBar'; -import { createNavigationTab, deepCompare, getAltinn2Reference } from '../../utils/resourceUtils'; +import { deepCompare, getAltinn2Reference } from '../../utils/resourceUtils'; import type { EnvId } from '../../utils/resourceUtils'; import { ResourceAccessLists } from '../../components/ResourceAccessLists'; import { AccessListDetail } from '../../components/AccessListDetails'; import { useGetAccessListQuery } from '../../hooks/queries/useGetAccessListQuery'; import { useUrlParams } from '../../hooks/useUrlParams'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { StudioContentMenu } from '@studio/components'; +import type { StudioContentMenuButtonTabProps } from '@studio/components'; /** * @component @@ -144,8 +145,8 @@ export const ResourcePage = (): React.JSX.Element => { }; const closeNavigationModals = (): void => { - policyErrorModalRef.current.close(); - resourceErrorModalRef.current.close(); + policyErrorModalRef.current?.close(); + resourceErrorModalRef.current?.close(); }; /** @@ -184,48 +185,6 @@ export const ResourcePage = (): React.JSX.Element => { const migrationPageId = 'migration'; const accessListsPageId = 'accesslists'; - const leftNavigationTabs: LeftNavigationTab[] = [ - createNavigationTab( - , - aboutPageId, - () => navigateToPage(aboutPageId), - currentPage, - getResourcePageURL(org, app, resourceId, 'about'), - ), - createNavigationTab( - , - policyPageId, - () => navigateToPage(policyPageId), - currentPage, - getResourcePageURL(org, app, resourceId, 'policy'), - ), - createNavigationTab( - , - deployPageId, - () => navigateToPage(deployPageId), - currentPage, - getResourcePageURL(org, app, resourceId, 'deploy'), - ), - ]; - - const migrationTab: LeftNavigationTab = createNavigationTab( - , - migrationPageId, - () => navigateToPage(migrationPageId), - currentPage, - getResourcePageURL(org, app, resourceId, 'migration'), - ); - - /** - * Gets the tabs to display. If showMigrate is true, the migration tab - * is added, otherwise it displays the three initial tabs. - * - * @returns the tabs to display in the LeftNavigationBar - */ - const getTabs = (): LeftNavigationTab[] => { - return isMigrateEnabled() ? [...leftNavigationTabs, migrationTab] : leftNavigationTabs; - }; - /** * Saves the resource */ @@ -236,18 +195,57 @@ export const ResourcePage = (): React.JSX.Element => { } }; + const getContentMenuItems = (): StudioContentMenuButtonTabProps[] => { + const contentMenuItems: StudioContentMenuButtonTabProps[] = [ + { + tabId: 'about', + tabName: t('resourceadm.left_nav_bar_about'), + icon: , + }, + { + tabId: 'policy', + tabName: t('resourceadm.left_nav_bar_policy'), + icon: , + }, + { + tabId: 'deploy', + tabName: t('resourceadm.left_nav_bar_deploy'), + icon: , + }, + ]; + if (isMigrateEnabled()) { + contentMenuItems.push({ + tabId: 'migration', + tabName: t('resourceadm.left_nav_bar_migration'), + icon: , + }); + } + return contentMenuItems; + }; + return (
- { + if (tabId !== 'back') { + navigateToPage(tabId); + } + }} + selectedTabId={ currentPage === migrationPageId && !isMigrateEnabled() ? aboutPageId : currentPage } - /> + > + } + renderTab={(props) => } + /> + {getContentMenuItems().map((menuItem) => { + return ; + })} +
{resourcePending || !resourceData ? (
diff --git a/frontend/resourceadm/types/NavigationBarPage.d.ts b/frontend/resourceadm/types/NavigationBarPage.d.ts index 998f945b7fd..ed2d6cf82f5 100644 --- a/frontend/resourceadm/types/NavigationBarPage.d.ts +++ b/frontend/resourceadm/types/NavigationBarPage.d.ts @@ -1 +1,7 @@ -export type NavigationBarPage = 'about' | 'policy' | 'deploy' | 'migration' | 'accesslists'; +export type NavigationBarPage = + | 'about' + | 'policy' + | 'deploy' + | 'migration' + | 'accesslists' + | 'back'; diff --git a/frontend/resourceadm/utils/resourceUtils/index.ts b/frontend/resourceadm/utils/resourceUtils/index.ts index 49f1c9dd78e..5ae0eac1dad 100644 --- a/frontend/resourceadm/utils/resourceUtils/index.ts +++ b/frontend/resourceadm/utils/resourceUtils/index.ts @@ -1,8 +1,6 @@ export { mapLanguageKeyToLanguageText, getMissingInputLanguageString, - getIsActiveTab, - createNavigationTab, getResourceIdentifierErrorMessage, deepCompare, getAvailableEnvironments, diff --git a/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx b/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx index 99c6088e80b..266b8fa6d05 100644 --- a/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx +++ b/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx @@ -1,6 +1,4 @@ import { - createNavigationTab, - getIsActiveTab, getMissingInputLanguageString, mapLanguageKeyToLanguageText, deepCompare, @@ -9,9 +7,6 @@ import { validateResource, } from './'; import type { EnvId } from './resourceUtils'; -import type { LeftNavigationTab } from '../../components/LeftNavigationBar'; -import { TestFlaskIcon } from '@studio/icons'; -import React from 'react'; import type { Resource, SupportedLanguage } from 'app-shared/types/ResourceAdm'; describe('mapKeywordStringToKeywordTypeArray', () => { @@ -107,42 +102,6 @@ describe('getMissingInputLanguageString', () => { }); }); -describe('getIsActiveTab', () => { - it('returns true when current page and tab id mathces', () => { - const isActive = getIsActiveTab('about', 'about'); - expect(isActive).toBeTruthy(); - }); - - it('returns false when current page and tab id does not match', () => { - const isActive = getIsActiveTab('about', 'policy'); - expect(isActive).toBeFalsy(); - }); -}); - -describe('createNavigationTab', () => { - const mockOnClick = jest.fn(); - - const mockTo: string = '/about'; - - const mockTab: LeftNavigationTab = { - icon: , - tabName: 'resourceadm.left_nav_bar_about', - tabId: 'about', - action: { - type: 'link', - onClick: mockOnClick, - to: mockTo, - }, - isActiveTab: true, - }; - - it('creates a new tab when the function is called', () => { - const newTab = createNavigationTab(, 'about', mockOnClick, 'about', mockTo); - - expect(newTab).toEqual(mockTab); - }); -}); - describe('deepCompare', () => { it('should return true for equal objects', () => { const obj1 = { diff --git a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts index 19122d97839..bad205cb034 100644 --- a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts +++ b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts @@ -1,5 +1,4 @@ import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; -import type { LeftNavigationTab } from '../../components/LeftNavigationBar'; import type { ResourceTypeOption, ResourceStatusOption, @@ -10,8 +9,6 @@ import type { Resource, ResourceFormError, } from 'app-shared/types/ResourceAdm'; -import type { ReactNode } from 'react'; -import type { NavigationBarPage } from '../../types/NavigationBarPage'; import { isAppPrefix, isSePrefix } from '../stringUtils'; /** @@ -154,50 +151,6 @@ export const mapKeywordStringToKeywordTypeArray = (keywrodString: string): Resou .map((val) => ({ language: 'nb', word: val.trim() })); }; -/** - * Gets the status for if a tab is active or not based on the - * current page and the tabs id. - * - * @param currentPage the currently selected tab - * @param tabId the id of the tab to check - * - * @returns if the tab is active or not - */ -export const getIsActiveTab = (currentPage: NavigationBarPage, tabId: string): boolean => { - return currentPage === tabId; -}; - -/** - * Creates a new navigation tab to be used in the LeftNavigationBar - * - * @param icon the icon to display - * @param tabId the id of the tab - * @param onClick function to be executed on click - * @param currentPage the current selected page - * @param to where to navigate to - * - * @returns a LeftNavigationTab - */ -export const createNavigationTab = ( - icon: ReactNode, - tabId: string, - onClick: (tabId: string) => void, - currentPage: NavigationBarPage, - to: string, -): LeftNavigationTab => { - return { - icon, - tabName: `resourceadm.left_nav_bar_${tabId}`, - tabId, - action: { - type: 'link', - onClick, - to, - }, - isActiveTab: getIsActiveTab(currentPage, tabId), - }; -}; - export const getResourceIdentifierErrorMessage = (identifier: string, isConflict?: boolean) => { const hasAppPrefix = isAppPrefix(identifier); const hasSePrefix = isSePrefix(identifier); From 270057e876341fbd365489386aea943af24b2aee Mon Sep 17 00:00:00 2001 From: andreastanderen <71079896+standeren@users.noreply.github.com> Date: Fri, 6 Dec 2024 14:54:16 +0100 Subject: [PATCH 03/35] fix: allow appmetadata to be undefined when validating name when data model is uploaded (#14236) --- .../TopToolbar/utils/validationUtils.test.ts | 22 ++++++++++++++++++- .../TopToolbar/utils/validationUtils.ts | 2 +- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.test.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.test.ts index 658a8330d1b..ce2c8d2cb55 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.test.ts +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.test.ts @@ -1,5 +1,5 @@ import { mockAppMetadata, mockDataTypeId } from '../../../../../test/applicationMetadataMock'; -import { extractDataTypeNamesFromAppMetadata } from './validationUtils'; +import { extractDataTypeNamesFromAppMetadata, findFileNameError } from './validationUtils'; describe('extractDataTypeNamesFromAppMetadata', () => { it('should extract data type names when application metadata is provided', () => { @@ -21,3 +21,23 @@ describe('extractDataTypeNamesFromAppMetadata', () => { expect(dataTypeNames).toEqual([]); }); }); + +describe('findFileNameError', () => { + it('should validate name as invalid if name does not match regEx', () => { + const fileName = 'æ'; + const validationResult = findFileNameError(fileName, mockAppMetadata); + expect(validationResult).toEqual('invalidFileName'); + }); + + it('should validate name as invalid if name exists in appMetadata', () => { + const fileName = mockAppMetadata.dataTypes[0].id; + const validationResult = findFileNameError(fileName, mockAppMetadata); + expect(validationResult).toEqual('fileExists'); + }); + + it('should validate name as valid if appMetadata is undefined', () => { + const fileName = 'fileName'; + const validationResult = findFileNameError(fileName, undefined); + expect(validationResult).toEqual(null); + }); +}); diff --git a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.ts b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.ts index d65112503a6..d47438c321d 100644 --- a/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.ts +++ b/frontend/app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils.ts @@ -46,7 +46,7 @@ const isNameFormatValid = (fileNameWithoutExtension: string): boolean => { const doesFileExistInMetadata = ( appMetadata: ApplicationMetadata, fileNameWithoutExtension: string, -): boolean => appMetadata.dataTypes?.some((dataType) => dataType.id === fileNameWithoutExtension); +): boolean => appMetadata?.dataTypes?.some((dataType) => dataType.id === fileNameWithoutExtension); export const extractDataTypeNamesFromAppMetadata = ( appMetadata?: ApplicationMetadata, From 6e309a897ed1f48bba342cc53775147b5233f028 Mon Sep 17 00:00:00 2001 From: Martin Gunnerud Date: Fri, 6 Dec 2024 17:19:35 +0100 Subject: [PATCH 04/35] chore(resource-adm): performance improvements / minor changes (#14156) --- .../run-playwright-resourceadm-on-pr.yml | 2 +- .../packages/shared/src/enums/ServerCodes.ts | 1 + .../useAddAccessListMemberMutation.ts | 3 ++ .../useRemoveAccessListMemberMutation.ts | 3 ++ frontend/resourceadm/package.json | 3 +- .../AboutResourcePage.test.tsx | 49 +++++++++++++++++-- .../AboutResourcePage/AboutResourcePage.tsx | 9 ++-- .../DeployResourcePage.test.tsx | 18 +++++++ .../DeployResourcePage/DeployResourcePage.tsx | 4 +- .../pages/ResourcePage/ResourcePage.test.tsx | 18 +++---- .../pages/ResourcePage/ResourcePage.tsx | 34 ++++--------- .../utils/resourceUtils/resourceUtils.ts | 5 +- yarn.lock | 1 - 13 files changed, 97 insertions(+), 53 deletions(-) diff --git a/.github/workflows/run-playwright-resourceadm-on-pr.yml b/.github/workflows/run-playwright-resourceadm-on-pr.yml index 734eca103f0..df66ed55ad6 100644 --- a/.github/workflows/run-playwright-resourceadm-on-pr.yml +++ b/.github/workflows/run-playwright-resourceadm-on-pr.yml @@ -61,4 +61,4 @@ jobs: if: failure() with: name: playwright-resourceadm-screenshots - path: frontend/testing/playwright/test-results + path: frontend/resourceadm/testing/playwright/test-results diff --git a/frontend/packages/shared/src/enums/ServerCodes.ts b/frontend/packages/shared/src/enums/ServerCodes.ts index c60679e9227..5356e5f29ca 100644 --- a/frontend/packages/shared/src/enums/ServerCodes.ts +++ b/frontend/packages/shared/src/enums/ServerCodes.ts @@ -1,4 +1,5 @@ export enum ServerCodes { + Ok = 200, Unauthorized = 401, Forbidden = 403, Conflict = 409, diff --git a/frontend/resourceadm/hooks/mutations/useAddAccessListMemberMutation.ts b/frontend/resourceadm/hooks/mutations/useAddAccessListMemberMutation.ts index 80da7258ada..3cb4206fba9 100644 --- a/frontend/resourceadm/hooks/mutations/useAddAccessListMemberMutation.ts +++ b/frontend/resourceadm/hooks/mutations/useAddAccessListMemberMutation.ts @@ -26,6 +26,9 @@ export const useAddAccessListMemberMutation = ( queryClient.invalidateQueries({ queryKey: [QueryKey.AccessListMembers, env, listIdentifier], }); + queryClient.invalidateQueries({ + queryKey: [QueryKey.AccessList, env, listIdentifier], + }); }, }); }; diff --git a/frontend/resourceadm/hooks/mutations/useRemoveAccessListMemberMutation.ts b/frontend/resourceadm/hooks/mutations/useRemoveAccessListMemberMutation.ts index bc7e5a157e0..a97d2b65805 100644 --- a/frontend/resourceadm/hooks/mutations/useRemoveAccessListMemberMutation.ts +++ b/frontend/resourceadm/hooks/mutations/useRemoveAccessListMemberMutation.ts @@ -26,6 +26,9 @@ export const useRemoveAccessListMemberMutation = ( queryClient.invalidateQueries({ queryKey: [QueryKey.AccessListMembers, env, listIdentifier], }); + queryClient.invalidateQueries({ + queryKey: [QueryKey.AccessList, env, listIdentifier], + }); }, }); }; diff --git a/frontend/resourceadm/package.json b/frontend/resourceadm/package.json index 93c38da6d5c..96a3f74dbfb 100644 --- a/frontend/resourceadm/package.json +++ b/frontend/resourceadm/package.json @@ -10,8 +10,7 @@ ], "dependencies": { "react": "18.3.1", - "react-dom": "18.3.1", - "react-router-dom": "6.27.0" + "react-dom": "18.3.1" }, "devDependencies": { "@svgr/webpack": "8.1.0", diff --git a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx index 80cf1c8f550..034b3f87ece 100644 --- a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx +++ b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.test.tsx @@ -78,7 +78,7 @@ describe('AboutResourcePage', () => { const mockOnSaveResource = jest.fn(); const defaultProps: AboutResourcePageProps = { - showAllErrors: false, + validationErrors: [], resourceData: mockResource1, onSaveResource: mockOnSaveResource, id: mockId, @@ -306,8 +306,47 @@ describe('AboutResourcePage', () => { }); }); - it('displays errors for the required translation fields when showAllErrors are true', async () => { - render(); + it('displays errors for the required translation fields', async () => { + render( + , + ); expect( screen.getAllByText(textMock('resourceadm.about_resource_resource_type_error')), @@ -345,7 +384,7 @@ describe('AboutResourcePage', () => { render( , ); @@ -378,7 +417,7 @@ describe('AboutResourcePage', () => { render( , ); diff --git a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx index b7e9b5338f7..bb262d01aca 100644 --- a/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx +++ b/frontend/resourceadm/pages/AboutResourcePage/AboutResourcePage.tsx @@ -10,6 +10,7 @@ import type { ResourceContactPoint, SupportedLanguage, ResourceReference, + ResourceFormError, } from 'app-shared/types/ResourceAdm'; import { availableForTypeMap, @@ -17,7 +18,6 @@ import { mapKeywordStringToKeywordTypeArray, mapKeywordsArrayToString, resourceTypeMap, - validateResource, } from '../../utils/resourceUtils'; import { useTranslation } from 'react-i18next'; import { @@ -32,8 +32,8 @@ import { ResourceReferenceFields } from '../../components/ResourceReferenceField import { AccessListEnvLinks } from '../../components/AccessListEnvLinks'; export type AboutResourcePageProps = { - showAllErrors: boolean; resourceData: Resource; + validationErrors: ResourceFormError[]; onSaveResource: (r: Resource) => void; id: string; }; @@ -42,7 +42,6 @@ export type AboutResourcePageProps = { * @component * Page that displays information about a resource * - * @property {boolean}[showAllErrors] - Flag to decide if all errors should be shown or not * @property {Resource}[resourceData] - The metadata for the resource * @property {function}[onSaveResource] - Function to be handled when saving the resource * @property {string}[id] - The id of the page @@ -50,8 +49,8 @@ export type AboutResourcePageProps = { * @returns {React.JSX.Element} - The rendered component */ export const AboutResourcePage = ({ - showAllErrors, resourceData, + validationErrors, onSaveResource, id, }: AboutResourcePageProps): React.JSX.Element => { @@ -93,8 +92,6 @@ export const AboutResourcePage = ({ onSaveResource(res); }; - const validationErrors = showAllErrors ? validateResource(resourceData, t) : []; - /** * Displays the content on the page */ diff --git a/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.test.tsx b/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.test.tsx index 3185b101a75..a9c5d50ec70 100644 --- a/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.test.tsx +++ b/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.test.tsx @@ -277,6 +277,24 @@ describe('DeployResourcePage', () => { expect(prodButton).toBeDisabled(); }); + it('disables the deploy buttons when there is no policy', async () => { + await resolveAndWaitForSpinnerToDisappear({ + getValidatePolicy: () => Promise.resolve({ status: 404, errors: [] }), + }); + const tt02 = textMock('resourceadm.deploy_test_env'); + const prod = textMock('resourceadm.deploy_prod_env'); + + const tt02Button = screen.getByRole('button', { + name: textMock('resourceadm.deploy_card_publish', { env: tt02 }), + }); + const prodButton = screen.getByRole('button', { + name: textMock('resourceadm.deploy_card_publish', { env: prod }), + }); + + expect(tt02Button).toBeDisabled(); + expect(prodButton).toBeDisabled(); + }); + it('disables the deploy buttons when there is validate policy error', async () => { await resolveAndWaitForSpinnerToDisappear({ getValidatePolicy: () => Promise.resolve(mockValidatePolicyData2), diff --git a/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.tsx b/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.tsx index b8af8828b24..a9cc14e029a 100644 --- a/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.tsx +++ b/frontend/resourceadm/pages/DeployResourcePage/DeployResourcePage.tsx @@ -24,6 +24,7 @@ import { useTranslation, Trans } from 'react-i18next'; import { mergeQueryStatuses } from 'app-shared/utils/tanstackQueryUtils'; import { useUrlParams } from '../../hooks/useUrlParams'; import { getAvailableEnvironments } from '../../utils/resourceUtils'; +import { ServerCodes } from 'app-shared/enums/ServerCodes'; export type DeployResourcePageProps = { navigateToPageWithError: (page: NavigationBarPage) => void; @@ -177,7 +178,8 @@ export const DeployResourcePage = ({ * @returns a boolean for if it is possible */ const isDeployPossible = (envVersion: string): boolean => { - const policyError = validatePolicyData === undefined || validatePolicyData.status === 400; + const policyError = + validatePolicyData === undefined || validatePolicyData.status !== ServerCodes.Ok; const canDeploy = validateResourceData.status === 200 && !policyError && diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx b/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx index 0ac1858a711..306efcc6677 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx @@ -25,6 +25,11 @@ const mockResource1: Resource = { { reference: '1', referenceType: 'ServiceCode', referenceSource: 'Altinn2' }, { reference: '2', referenceType: 'ServiceEditionCode', referenceSource: 'Altinn2' }, ], + delegable: false, + resourceType: 'GenericAccessResource', + status: 'Completed', + contactPoints: [{ category: 'test', contactPage: '', email: '', telephone: '' }], + availableForType: ['Company'], }; const mockResource2: Resource = { @@ -57,11 +62,6 @@ describe('ResourcePage', () => { expect(queriesMock.getValidatePolicy).toHaveBeenCalledTimes(1); }); - it('fetches validate resource on mount', () => { - renderResourcePage(); - expect(queriesMock.getValidateResource).toHaveBeenCalledTimes(1); - }); - it('fetches resource on mount', () => { renderResourcePage(); expect(queriesMock.getResource).toHaveBeenCalledTimes(1); @@ -183,14 +183,8 @@ describe('ResourcePage', () => { const getResource = jest .fn() .mockImplementation(() => Promise.resolve(mockResource1)); - const getValidateResource = jest.fn().mockImplementation(() => - Promise.resolve({ - status: 200, - errors: {}, - }), - ); - renderResourcePage({ getResource, getValidateResource }); + renderResourcePage({ getResource }); await waitForElementToBeRemoved(() => screen.queryByTitle(textMock('resourceadm.about_resource_spinner')), ); diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx index 722aa856835..ec780461b0f 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx @@ -5,11 +5,7 @@ import classes from './ResourcePage.module.css'; import { PolicyEditorPage } from '../PolicyEditorPage'; import { getResourceDashboardURL, getResourcePageURL } from '../../utils/urlUtils'; import { DeployResourcePage } from '../DeployResourcePage'; -import { - useSinlgeResourceQuery, - useValidatePolicyQuery, - useValidateResourceQuery, -} from '../../hooks/queries'; +import { useSinlgeResourceQuery, useValidatePolicyQuery } from '../../hooks/queries'; import { AboutResourcePage } from '../AboutResourcePage'; import { NavigationModal } from '../../components/NavigationModal'; import { Spinner } from '@digdir/designsystemet-react'; @@ -24,7 +20,7 @@ import { MigrationIcon, UploadIcon, } from '@studio/icons'; -import { deepCompare, getAltinn2Reference } from '../../utils/resourceUtils'; +import { deepCompare, getAltinn2Reference, validateResource } from '../../utils/resourceUtils'; import type { EnvId } from '../../utils/resourceUtils'; import { ResourceAccessLists } from '../../components/ResourceAccessLists'; import { AccessListDetail } from '../../components/AccessListDetails'; @@ -64,14 +60,11 @@ export const ResourcePage = (): React.JSX.Element => { // Get metadata for policy const { refetch: refetchValidatePolicy } = useValidatePolicyQuery(org, app, resourceId); - // Get metadata for resource - const { refetch: refetchValidateResource } = useValidateResourceQuery(org, app, resourceId); - - const { - data: loadedResourceData, - refetch: refetchResource, - isPending: resourcePending, - } = useSinlgeResourceQuery(org, app, resourceId); + const { data: loadedResourceData, isPending: resourcePending } = useSinlgeResourceQuery( + org, + app, + resourceId, + ); const { data: accessList } = useGetAccessListQuery(org, accessListId, env); @@ -97,15 +90,9 @@ export const ResourcePage = (): React.JSX.Element => { */ const navigateToPage = async (page: NavigationBarPage) => { if (currentPage !== page) { - await editResource(resourceData); - await refetchResource(); - // Validate Resource and display errors + modal if (currentPage === 'about') { - const data = await refetchValidateResource(); - const validationStatus = data?.data?.status ?? null; - - if (validationStatus === 200) { + if (validationErrors.length === 0) { setShowResourceErrors(false); handleNavigation(page); } else { @@ -157,8 +144,6 @@ export const ResourcePage = (): React.JSX.Element => { */ const navigateToPageWithError = async (page: NavigationBarPage) => { if (page === 'about') { - await refetchResource(); - await refetchValidateResource(); setShowResourceErrors(true); } if (page === 'policy') { @@ -171,6 +156,7 @@ export const ResourcePage = (): React.JSX.Element => { handleNavigation(nextPage); }; + const validationErrors = validateResource(resourceData, t); const altinn2References = getAltinn2Reference(resourceData); /** * Decide if the migration page should be accessible or not @@ -259,8 +245,8 @@ export const ResourcePage = (): React.JSX.Element => {
{currentPage === aboutPageId && ( diff --git a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts index bad205cb034..467f56cefd9 100644 --- a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts +++ b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts @@ -199,11 +199,14 @@ export const deepCompare = (original: any, changed: any) => { }; export const validateResource = ( - resourceData: Resource, + resourceData: Resource | undefined, t: (key: string, params?: KeyValuePairs) => string, ): ResourceFormError[] => { const errors: ResourceFormError[] = []; + if (!resourceData) { + return []; + } // validate resourceType if (!Object.keys(resourceTypeMap).includes(resourceData.resourceType)) { errors.push({ diff --git a/yarn.lock b/yarn.lock index 8be1b93681d..75f1ea0b44b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16139,7 +16139,6 @@ __metadata: jest: "npm:29.7.0" react: "npm:18.3.1" react-dom: "npm:18.3.1" - react-router-dom: "npm:6.27.0" typescript: "npm:5.7.2" webpack: "npm:5.96.1" webpack-dev-server: "npm:5.1.0" From a5cb81b936ec4c95ca0d2f7df5e306e29448970b Mon Sep 17 00:00:00 2001 From: Lars <74791975+lassopicasso@users.noreply.github.com> Date: Sun, 8 Dec 2024 23:20:39 +0100 Subject: [PATCH 05/35] style: remove help texts in other settings (#14196) Co-authored-by: JamalAlabdullah <90609090+JamalAlabdullah@users.noreply.github.com> --- .../components/config/FormComponentConfig.tsx | 2 - .../config/editModal/EditBooleanValue.tsx | 8 ++-- .../packages/ux-editor/src/hooks/index.ts | 1 + .../useComponentPropertyHelpText.test.ts | 37 +++++++++++++++++++ .../src/hooks/useComponentPropertyHelpText.ts | 16 ++++++++ 5 files changed, 57 insertions(+), 7 deletions(-) create mode 100644 frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.test.ts create mode 100644 frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.ts diff --git a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx index 61cca8fc1c3..5ab3cf2935c 100644 --- a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx +++ b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx @@ -127,7 +127,6 @@ export const FormComponentConfig = ({ propertyKey={propertyKey} defaultValue={properties[propertyKey].default} key={propertyKey} - helpText={properties[propertyKey]?.description} /> ); })} @@ -137,7 +136,6 @@ export const FormComponentConfig = ({ <> { diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditBooleanValue.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditBooleanValue.tsx index 4cae14575a3..1a259f1edff 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditBooleanValue.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditBooleanValue.tsx @@ -1,13 +1,11 @@ import React from 'react'; import { Switch } from '@digdir/designsystemet-react'; import type { IGenericEditComponent } from '../componentConfig'; -import { useText } from '../../../hooks'; +import { useText, useComponentPropertyLabel, useComponentPropertyHelpText } from '../../../hooks'; import { FormField } from '../../FormField'; -import { useComponentPropertyLabel } from '../../../hooks/useComponentPropertyLabel'; export interface EditBooleanValueProps extends IGenericEditComponent { propertyKey: string; - helpText?: string; defaultValue?: boolean; } @@ -15,11 +13,11 @@ export const EditBooleanValue = ({ component, handleComponentChange, propertyKey, - helpText, defaultValue, }: EditBooleanValueProps) => { const t = useText(); const componentPropertyLabel = useComponentPropertyLabel(); + const componentPropertyHelpText = useComponentPropertyHelpText(); const handleChange = () => { handleComponentChange({ @@ -44,7 +42,7 @@ export const EditBooleanValue = ({ helpText={ isValueExpression(component[propertyKey]) ? t('ux_editor.component_properties.config_is_expression_message') - : helpText + : componentPropertyHelpText(propertyKey) } renderField={({ fieldProps }) => { return ( diff --git a/frontend/packages/ux-editor/src/hooks/index.ts b/frontend/packages/ux-editor/src/hooks/index.ts index 10e5eda8b0f..62a95c9457a 100644 --- a/frontend/packages/ux-editor/src/hooks/index.ts +++ b/frontend/packages/ux-editor/src/hooks/index.ts @@ -12,3 +12,4 @@ export { useValidateComponent } from './useValidateComponent'; export { useComponentPropertyLabel } from './useComponentPropertyLabel'; export { useAppContext } from './useAppContext'; export { useGetLayoutSetByName } from './useGetLayoutSetByName'; +export { useComponentPropertyHelpText } from './useComponentPropertyHelpText'; diff --git a/frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.test.ts b/frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.test.ts new file mode 100644 index 00000000000..4f1bab81cb9 --- /dev/null +++ b/frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.test.ts @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react'; +import { useComponentPropertyHelpText } from './useComponentPropertyHelpText'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; + +const somePropertyName = 'undefinedKey'; + +const customTextMockToHandleUndefined = ( + keys: string | string[], + variables?: KeyValuePairs, +) => { + const key = Array.isArray(keys) ? keys[0] : keys; + if (key === `ux_editor.component_properties_help_text.${somePropertyName}`) return key; + return variables + ? '[mockedText(' + key + ', ' + JSON.stringify(variables) + ')]' + : '[mockedText(' + key + ')]'; +}; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: customTextMockToHandleUndefined, + }), +})); + +describe('useComponentPropertyHelpText', () => { + it('Returns a function that returns the help text', () => { + const result = renderHook(() => useComponentPropertyHelpText()).result.current; + const propertyHelpText = result('test'); + expect(propertyHelpText).toEqual(textMock('ux_editor.component_properties_help_text.test')); + }); + + it('Returns a function that returns undefined if there was no text key for the help text', () => { + const result = renderHook(() => useComponentPropertyHelpText()).result.current; + const propertyHelpText = result(somePropertyName); + expect(propertyHelpText).toEqual(undefined); + }); +}); diff --git a/frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.ts b/frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.ts new file mode 100644 index 00000000000..519771883dd --- /dev/null +++ b/frontend/packages/ux-editor/src/hooks/useComponentPropertyHelpText.ts @@ -0,0 +1,16 @@ +import { useTranslation } from 'react-i18next'; +import { useCallback } from 'react'; + +export const useComponentPropertyHelpText = () => { + const { t } = useTranslation(); + + return useCallback( + (propertyKey: string): string | undefined => { + const translationKey: string = `ux_editor.component_properties_help_text.${propertyKey}`; + const translation = t(translationKey); + + return translation !== translationKey ? translation : undefined; + }, + [t], + ); +}; From 9aa4f75900c44da1263fd32a758a16f90e31ac55 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:44:05 +0100 Subject: [PATCH 06/35] chore(deps): update dependency eslint-plugin-testing-library to v7 (#14246) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 152 ++++++++++++++++++++++----------------------------- 2 files changed, 65 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index 053724c3368..77a046e4d66 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.2", "eslint-plugin-react-hooks": "4.6.2", - "eslint-plugin-testing-library": "6.5.0", + "eslint-plugin-testing-library": "7.1.0", "glob": "11.0.0", "husky": "9.1.7", "jest": "29.7.0", diff --git a/yarn.lock b/yarn.lock index 75f1ea0b44b..34568b94fde 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5272,13 +5272,6 @@ __metadata: languageName: node linkType: hard -"@types/semver@npm:^7.3.12": - version: 7.3.13 - resolution: "@types/semver@npm:7.3.13" - checksum: 10/0064efd7a0515a539062b71630c72ca2b058501b957326c285cdff82f42c1716d9f9f831332ccf719d5ee8cc3ef24f9ff62122d7a7140c73959a240b49b0f62d - languageName: node - linkType: hard - "@types/semver@npm:^7.3.4": version: 7.5.8 resolution: "@types/semver@npm:7.5.8" @@ -5452,16 +5445,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/scope-manager@npm:5.62.0" - dependencies: - "@typescript-eslint/types": "npm:5.62.0" - "@typescript-eslint/visitor-keys": "npm:5.62.0" - checksum: 10/e827770baa202223bc0387e2fd24f630690809e460435b7dc9af336c77322290a770d62bd5284260fa881c86074d6a9fd6c97b07382520b115f6786b8ed499da - languageName: node - linkType: hard - "@typescript-eslint/scope-manager@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/scope-manager@npm:7.18.0" @@ -5482,6 +5465,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/scope-manager@npm:8.17.0, @typescript-eslint/scope-manager@npm:^8.15.0": + version: 8.17.0 + resolution: "@typescript-eslint/scope-manager@npm:8.17.0" + dependencies: + "@typescript-eslint/types": "npm:8.17.0" + "@typescript-eslint/visitor-keys": "npm:8.17.0" + checksum: 10/fa934d9fd88070833c57a3e79c0f933d0b68884c00293a1d571889b882e5c9680ccfdc5c77a7160d5a4b8b46657f93db2468a4726a517fce4d3bc764b66f1995 + languageName: node + linkType: hard + "@typescript-eslint/type-utils@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/type-utils@npm:7.18.0" @@ -5499,13 +5492,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/types@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/types@npm:5.62.0" - checksum: 10/24e8443177be84823242d6729d56af2c4b47bfc664dd411a1d730506abf2150d6c31bdefbbc6d97c8f91043e3a50e0c698239dcb145b79bb6b0c34469aaf6c45 - languageName: node - linkType: hard - "@typescript-eslint/types@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/types@npm:7.18.0" @@ -5520,21 +5506,10 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/typescript-estree@npm:5.62.0" - dependencies: - "@typescript-eslint/types": "npm:5.62.0" - "@typescript-eslint/visitor-keys": "npm:5.62.0" - debug: "npm:^4.3.4" - globby: "npm:^11.1.0" - is-glob: "npm:^4.0.3" - semver: "npm:^7.3.7" - tsutils: "npm:^3.21.0" - peerDependenciesMeta: - typescript: - optional: true - checksum: 10/06c975eb5f44b43bd19fadc2e1023c50cf87038fe4c0dd989d4331c67b3ff509b17fa60a3251896668ab4d7322bdc56162a9926971218d2e1a1874d2bef9a52e +"@typescript-eslint/types@npm:8.17.0": + version: 8.17.0 + resolution: "@typescript-eslint/types@npm:8.17.0" + checksum: 10/46baf69ab30dd814a390590b94ca64c407ac725cb0143590ddcaf72fa43c940cec180539752ce4af26ac7e0ae2f5f921cfd0d07b088ca680f8a28800d4d33a5f languageName: node linkType: hard @@ -5576,6 +5551,25 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/typescript-estree@npm:8.17.0": + version: 8.17.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.17.0" + dependencies: + "@typescript-eslint/types": "npm:8.17.0" + "@typescript-eslint/visitor-keys": "npm:8.17.0" + debug: "npm:^4.3.4" + fast-glob: "npm:^3.3.2" + is-glob: "npm:^4.0.3" + minimatch: "npm:^9.0.4" + semver: "npm:^7.6.0" + ts-api-utils: "npm:^1.3.0" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/8a1f8be767b82e75d41eedda7fdb5135787ceaab480671b6d9891b5f92ee3a13f19ad6f48d5abf5e4f2afc4dd3317c621c1935505ef098f22b67be2f9d01ab7b + languageName: node + linkType: hard + "@typescript-eslint/utils@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/utils@npm:7.18.0" @@ -5590,21 +5584,20 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/utils@npm:^5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/utils@npm:5.62.0" +"@typescript-eslint/utils@npm:^8.15.0": + version: 8.17.0 + resolution: "@typescript-eslint/utils@npm:8.17.0" dependencies: - "@eslint-community/eslint-utils": "npm:^4.2.0" - "@types/json-schema": "npm:^7.0.9" - "@types/semver": "npm:^7.3.12" - "@typescript-eslint/scope-manager": "npm:5.62.0" - "@typescript-eslint/types": "npm:5.62.0" - "@typescript-eslint/typescript-estree": "npm:5.62.0" - eslint-scope: "npm:^5.1.1" - semver: "npm:^7.3.7" + "@eslint-community/eslint-utils": "npm:^4.4.0" + "@typescript-eslint/scope-manager": "npm:8.17.0" + "@typescript-eslint/types": "npm:8.17.0" + "@typescript-eslint/typescript-estree": "npm:8.17.0" peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 10/15ef13e43998a082b15f85db979f8d3ceb1f9ce4467b8016c267b1738d5e7cdb12aa90faf4b4e6dd6486c236cf9d33c463200465cf25ff997dbc0f12358550a1 + eslint: ^8.57.0 || ^9.0.0 + peerDependenciesMeta: + typescript: + optional: true + checksum: 10/e82934468bece55ccf633be9f3fe6cae26791fa6488b5af08ea22566f6b32e1296917e46cb1fe39bba7717ebdf0dca49935112760c4439a11af36b3b7925917a languageName: node linkType: hard @@ -5625,16 +5618,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:5.62.0": - version: 5.62.0 - resolution: "@typescript-eslint/visitor-keys@npm:5.62.0" - dependencies: - "@typescript-eslint/types": "npm:5.62.0" - eslint-visitor-keys: "npm:^3.3.0" - checksum: 10/dc613ab7569df9bbe0b2ca677635eb91839dfb2ca2c6fa47870a5da4f160db0b436f7ec0764362e756d4164e9445d49d5eb1ff0b87f4c058946ae9d8c92eb388 - languageName: node - linkType: hard - "@typescript-eslint/visitor-keys@npm:7.18.0": version: 7.18.0 resolution: "@typescript-eslint/visitor-keys@npm:7.18.0" @@ -5655,6 +5638,16 @@ __metadata: languageName: node linkType: hard +"@typescript-eslint/visitor-keys@npm:8.17.0": + version: 8.17.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.17.0" + dependencies: + "@typescript-eslint/types": "npm:8.17.0" + eslint-visitor-keys: "npm:^4.2.0" + checksum: 10/e7a3c3b9430ecefb8e720f735f8a94f87901f055c75dc8eec60052dfdf90cc28dd33f03c11cd8244551dc988bf98d1db9bd09ef8fd3c51236912cab3680b9c6b + languageName: node + linkType: hard + "@ungap/structured-clone@npm:^1.2.0": version: 1.2.0 resolution: "@ungap/structured-clone@npm:1.2.0" @@ -6195,7 +6188,7 @@ __metadata: eslint-plugin-jsx-a11y: "npm:6.10.2" eslint-plugin-react: "npm:7.37.2" eslint-plugin-react-hooks: "npm:4.6.2" - eslint-plugin-testing-library: "npm:6.5.0" + eslint-plugin-testing-library: "npm:7.1.0" glob: "npm:11.0.0" husky: "npm:9.1.7" i18next: "npm:23.16.8" @@ -9723,18 +9716,19 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-testing-library@npm:6.5.0": - version: 6.5.0 - resolution: "eslint-plugin-testing-library@npm:6.5.0" +"eslint-plugin-testing-library@npm:7.1.0": + version: 7.1.0 + resolution: "eslint-plugin-testing-library@npm:7.1.0" dependencies: - "@typescript-eslint/utils": "npm:^5.62.0" + "@typescript-eslint/scope-manager": "npm:^8.15.0" + "@typescript-eslint/utils": "npm:^8.15.0" peerDependencies: - eslint: ^7.5.0 || ^8.0.0 || ^9.0.0 - checksum: 10/5ce5f71aed5dc39315f7fa2e987c7e1ffc78f7d35164c2d8769ad29000558828dc13c04bfef289c28faf57749ce7720e5ab17869780b743bc1d8cd59a5052a43 + eslint: ^8.57.0 || ^9.0.0 + checksum: 10/c250daad343514e107efcd7011cbb41e0e4ccd8c16cef1a1ba3612ad28970d431f81109bb38b6c2c3d6cb4217587aeb6c60f89d0faae6931065ef858dd2ba641 languageName: node linkType: hard -"eslint-scope@npm:5.1.1, eslint-scope@npm:^5.1.1": +"eslint-scope@npm:5.1.1": version: 5.1.1 resolution: "eslint-scope@npm:5.1.1" dependencies: @@ -17835,13 +17829,6 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^1.8.1": - version: 1.14.1 - resolution: "tslib@npm:1.14.1" - checksum: 10/7dbf34e6f55c6492637adb81b555af5e3b4f9cc6b998fb440dac82d3b42bdc91560a35a5fb75e20e24a076c651438234da6743d139e4feabf0783f3cdfe1dddb - languageName: node - linkType: hard - "tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.0": version: 2.5.0 resolution: "tslib@npm:2.5.0" @@ -17856,17 +17843,6 @@ __metadata: languageName: node linkType: hard -"tsutils@npm:^3.21.0": - version: 3.21.0 - resolution: "tsutils@npm:3.21.0" - dependencies: - tslib: "npm:^1.8.1" - peerDependencies: - typescript: ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - checksum: 10/ea036bec1dd024e309939ffd49fda7a351c0e87a1b8eb049570dd119d447250e2c56e0e6c00554e8205760e7417793fdebff752a46e573fbe07d4f375502a5b2 - languageName: node - linkType: hard - "tunnel-agent@npm:^0.6.0": version: 0.6.0 resolution: "tunnel-agent@npm:0.6.0" From 586089ab5885e5d526dbeb55796a0288fffa91b5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:48:30 +0100 Subject: [PATCH 07/35] chore(deps): update alpine docker tag to v3.21.0 (#14243) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- backend/Migrations.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/Migrations.Dockerfile b/backend/Migrations.Dockerfile index 09569daacc0..03dff76c05b 100644 --- a/backend/Migrations.Dockerfile +++ b/backend/Migrations.Dockerfile @@ -13,7 +13,7 @@ ENV OidcLoginSettings__ClientSecret=dummyRequired RUN dotnet ef migrations script --project src/Designer/Designer.csproj -o /app/migrations.sql -FROM alpine:3.20.3 AS final +FROM alpine:3.21.0 AS final COPY --from=build /app/migrations.sql migrations.sql RUN apk --no-cache add postgresql-client From 5565df8930296f9118e944a5d8dfd2fb38e14da7 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 08:51:13 +0100 Subject: [PATCH 08/35] chore(deps): update dependency @types/node to v22 (#14244) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../testing/playwright/package.json | 2 +- frontend/testing/playwright/package.json | 2 +- yarn.lock | 20 ++----------------- 3 files changed, 4 insertions(+), 20 deletions(-) diff --git a/frontend/resourceadm/testing/playwright/package.json b/frontend/resourceadm/testing/playwright/package.json index e8e73299949..a5ca9ad4215 100644 --- a/frontend/resourceadm/testing/playwright/package.json +++ b/frontend/resourceadm/testing/playwright/package.json @@ -9,7 +9,7 @@ "devDependencies": { "@playwright/test": "^1.41.1", "@types/dotenv": "^8.2.0", - "@types/node": "^20.10.5", + "@types/node": "^22.0.0", "ts-node": "^10.9.2" }, "scripts": { diff --git a/frontend/testing/playwright/package.json b/frontend/testing/playwright/package.json index 27627af7599..595ec32cb9d 100644 --- a/frontend/testing/playwright/package.json +++ b/frontend/testing/playwright/package.json @@ -9,7 +9,7 @@ "devDependencies": { "@playwright/test": "^1.41.1", "@types/dotenv": "^8.2.0", - "@types/node": "^20.10.5", + "@types/node": "^22.0.0", "ts-node": "^10.9.2" }, "scripts": { diff --git a/yarn.lock b/yarn.lock index 34568b94fde..795f1dc4025 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5166,15 +5166,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^20.10.5": - version: 20.17.9 - resolution: "@types/node@npm:20.17.9" - dependencies: - undici-types: "npm:~6.19.2" - checksum: 10/11604a47adf383892394a59a339136b2746a71addf4a3b13f661d23b6e81e8a4f3b35e69dbcffc94698368e5ab5ec056a43a86c87eff00b1b8ea8cfcbfe641df - languageName: node - linkType: hard - "@types/parse-json@npm:^4.0.0": version: 4.0.0 resolution: "@types/parse-json@npm:4.0.0" @@ -14657,7 +14648,7 @@ __metadata: dependencies: "@playwright/test": "npm:^1.41.1" "@types/dotenv": "npm:^8.2.0" - "@types/node": "npm:^20.10.5" + "@types/node": "npm:^22.0.0" dotenv: "npm:^16.3.1" ts-node: "npm:^10.9.2" languageName: unknown @@ -14669,7 +14660,7 @@ __metadata: dependencies: "@playwright/test": "npm:^1.41.1" "@types/dotenv": "npm:^8.2.0" - "@types/node": "npm:^20.10.5" + "@types/node": "npm:^22.0.0" dotenv: "npm:^16.3.1" ts-node: "npm:^10.9.2" languageName: unknown @@ -18100,13 +18091,6 @@ __metadata: languageName: node linkType: hard -"undici-types@npm:~6.19.2": - version: 6.19.8 - resolution: "undici-types@npm:6.19.8" - checksum: 10/cf0b48ed4fc99baf56584afa91aaffa5010c268b8842f62e02f752df209e3dea138b372a60a963b3b2576ed932f32329ce7ddb9cb5f27a6c83040d8cd74b7a70 - languageName: node - linkType: hard - "undici-types@npm:~6.20.0": version: 6.20.0 resolution: "undici-types@npm:6.20.0" From f93a1ab479bb669c7bad462e5b066d5b258d12fe Mon Sep 17 00:00:00 2001 From: Martin Gunnerud Date: Mon, 9 Dec 2024 09:01:05 +0100 Subject: [PATCH 09/35] feat(resource-adm): add better error messages for migrate delegations (#14167) --- .../Controllers/ResourceAdminController.cs | 7 +++ .../packages/shared/src/enums/ServerCodes.ts | 1 + .../MigrationPanel/MigrationPanel.test.tsx | 49 ++++++++++++++++++- .../MigrationPanel/MigrationPanel.tsx | 39 +++++++-------- frontend/resourceadm/language/src/nb.json | 3 ++ .../resourceadm/utils/resourceUtils/index.ts | 1 + .../resourceUtils/resourceUtils.test.tsx | 46 ++++++++++++++++- .../utils/resourceUtils/resourceUtils.ts | 45 +++++++++++++++++ 8 files changed, 166 insertions(+), 25 deletions(-) diff --git a/backend/src/Designer/Controllers/ResourceAdminController.cs b/backend/src/Designer/Controllers/ResourceAdminController.cs index 29cca0e6408..4d86405ae04 100644 --- a/backend/src/Designer/Controllers/ResourceAdminController.cs +++ b/backend/src/Designer/Controllers/ResourceAdminController.cs @@ -336,6 +336,13 @@ public async Task ImportResource(string org, string serviceCode, i [Route("designer/api/{org}/resources/altinn2/delegationcount/{serviceCode}/{serviceEdition}/{env}")] public async Task GetDelegationCount(string org, string serviceCode, int serviceEdition, string env) { + List allResources = await _resourceRegistry.GetResourceList(env.ToLower(), true); + bool serviceExists = allResources.Any(x => x.Identifier.Equals($"se_{serviceCode}_{serviceEdition}")); + if (!serviceExists) + { + return new NotFoundResult(); + } + ServiceResource resource = await _resourceRegistry.GetServiceResourceFromService(serviceCode, serviceEdition, env.ToLower()); if (!IsServiceOwner(resource, org)) { diff --git a/frontend/packages/shared/src/enums/ServerCodes.ts b/frontend/packages/shared/src/enums/ServerCodes.ts index 5356e5f29ca..297f1986a42 100644 --- a/frontend/packages/shared/src/enums/ServerCodes.ts +++ b/frontend/packages/shared/src/enums/ServerCodes.ts @@ -6,4 +6,5 @@ export enum ServerCodes { NotFound = 404, PreconditionFailed = 412, TooLargeContent = 413, + InternalServerError = 500, } diff --git a/frontend/resourceadm/components/MigrationPanel/MigrationPanel.test.tsx b/frontend/resourceadm/components/MigrationPanel/MigrationPanel.test.tsx index 168cc8b8988..6381dcd46b1 100644 --- a/frontend/resourceadm/components/MigrationPanel/MigrationPanel.test.tsx +++ b/frontend/resourceadm/components/MigrationPanel/MigrationPanel.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { MemoryRouter } from 'react-router-dom'; @@ -47,7 +47,52 @@ describe('MigrationPanel', () => { }), }, ); - await waitFor(() => screen.findByText(textMock('resourceadm.migration_not_needed'))); + expect( + await screen.findByText(textMock('resourceadm.migration_not_needed')), + ).toBeInTheDocument(); + }); + + it('should show message if link service does not exist in given environment', async () => { + renderMigrationPanel( + {}, + { + getAltinn2DelegationsCount: jest.fn().mockImplementation(() => { + return Promise.reject({ response: { status: ServerCodes.NotFound } }); + }), + }, + ); + expect( + await screen.findByText(textMock('resourceadm.migration_service_not_found')), + ).toBeInTheDocument(); + }); + + it('should show message if link service cannot be migrated in given environment', async () => { + renderMigrationPanel( + {}, + { + getAltinn2DelegationsCount: jest.fn().mockImplementation(() => { + return Promise.reject({ response: { status: ServerCodes.Forbidden } }); + }), + }, + ); + expect( + await screen.findByText(textMock('resourceadm.migration_cannot_migrate_in_env')), + ).toBeInTheDocument(); + }); + + it('should show message if get delegation count fails in given environment', async () => { + renderMigrationPanel( + {}, + { + getAltinn2DelegationsCount: jest.fn().mockImplementation(() => { + return Promise.reject({ response: { status: ServerCodes.InternalServerError } }); + }), + }, + ); + + expect( + await screen.findByText(textMock('resourceadm.migration_technical_error')), + ).toBeInTheDocument(); }); it('should show error when user starts migrate delegations if user has no permission to migrate', async () => { diff --git a/frontend/resourceadm/components/MigrationPanel/MigrationPanel.tsx b/frontend/resourceadm/components/MigrationPanel/MigrationPanel.tsx index d4074ee54e8..82f66e2fb6c 100644 --- a/frontend/resourceadm/components/MigrationPanel/MigrationPanel.tsx +++ b/frontend/resourceadm/components/MigrationPanel/MigrationPanel.tsx @@ -4,12 +4,10 @@ import { Alert, Checkbox, Heading } from '@digdir/designsystemet-react'; import { useTranslation } from 'react-i18next'; import { StudioButton, StudioModal } from '@studio/components'; import classes from './MigrationPanel.module.css'; -import type { Environment } from '../../utils/resourceUtils'; +import { getMigrationErrorMessage, type Environment } from '../../utils/resourceUtils'; import { useGetAltinn2DelegationsCount } from '../../hooks/queries/useGetAltinn2DelegationCount'; import { useMigrateDelegationsMutation } from '../../hooks/mutations/useMigrateDelegationsMutation'; import { useUrlParams } from '../../hooks/useUrlParams'; -import type { ResourceError } from 'app-shared/types/ResourceAdm'; -import { ServerCodes } from 'app-shared/enums/ServerCodes'; export interface MigrationPanelProps { serviceCode: string; @@ -35,12 +33,11 @@ export const MigrationPanel = ({ const { mutate: migrateDelegations, isPending: isSettingMigrateDelegations } = useMigrateDelegationsMutation(org, env.id); - const { data: numberOfA2Delegations, isFetching: isLoadingDelegationCount } = - useGetAltinn2DelegationsCount(org, serviceCode, serviceEdition, env.id); - - const isErrorForbidden = (error: Error) => { - return (error as ResourceError)?.response?.status === ServerCodes.Forbidden; - }; + const { + data: numberOfA2Delegations, + isFetching: isLoadingDelegationCount, + error: loadDelegationCountError, + } = useGetAltinn2DelegationsCount(org, serviceCode, serviceEdition, env.id); const postMigrateDelegations = (): void => { setMigrateDelegationsError(null); @@ -67,6 +64,13 @@ export const MigrationPanel = ({ setServiceExpiredWarningModalRef.current?.close(); }; + const errorMessage = getMigrationErrorMessage( + loadDelegationCountError, + migrateDelegationsError, + isPublishedInEnv, + ); + const isMigrateButtonDisabled = !!errorMessage || isSettingMigrateDelegations; + return ( <> {t('resourceadm.migration_altinn3_delegations')} N/A
- {!isPublishedInEnv && ( - - {t('resourceadm.migration_not_published')} - - )} {isPublishedInEnv && numberOfA2Delegations?.numberOfDelegations === 0 && ( {t('resourceadm.migration_not_needed')} )} - {migrateDelegationsError && ( - - {isErrorForbidden(migrateDelegationsError) - ? t('resourceadm.migration_no_migration_access') - : t('resourceadm.migration_post_migration_failed')} - + {errorMessage && ( + {t(errorMessage.errorMessage)} )}
{ - if (isPublishedInEnv && !isSettingMigrateDelegations) { + if (!isMigrateButtonDisabled) { setServiceExpiredWarningModalRef.current?.showModal(); } }} diff --git a/frontend/resourceadm/language/src/nb.json b/frontend/resourceadm/language/src/nb.json index acbe64bb4e4..9530d899f9e 100644 --- a/frontend/resourceadm/language/src/nb.json +++ b/frontend/resourceadm/language/src/nb.json @@ -237,6 +237,9 @@ "resourceadm.migration_altinn2_delegations": "Delegeringer i Altinn 2:", "resourceadm.migration_altinn3_delegations": "Delegeringer i Altinn 3:", "resourceadm.migration_not_published": "Ressursen må publiseres før migrering kan startes", + "resourceadm.migration_service_not_found": "Tjenesten finnes ikke i dette miljøet, eller er allerede migrert", + "resourceadm.migration_cannot_migrate_in_env": "Du kan ikke migrere tjenesten i dette miljøet", + "resourceadm.migration_technical_error": "Teknisk feil, kunne ikke hente antall delegeringer", "resourceadm.migration_not_needed": "Migrering er ikke nødvendig i dette miljøet", "resourceadm.migration_migrate_environment": "Start migrering i {{env}}", "resourceadm.migration_confirm_migration": "Jeg har testet integrasjonen i Altinn 3 og er klar til å starte migrering", diff --git a/frontend/resourceadm/utils/resourceUtils/index.ts b/frontend/resourceadm/utils/resourceUtils/index.ts index 5ae0eac1dad..7c03247d9f7 100644 --- a/frontend/resourceadm/utils/resourceUtils/index.ts +++ b/frontend/resourceadm/utils/resourceUtils/index.ts @@ -12,5 +12,6 @@ export { resourceTypeMap, validateResource, getAltinn2Reference, + getMigrationErrorMessage, } from './resourceUtils'; export type { EnvId, Environment } from './resourceUtils'; diff --git a/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx b/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx index 266b8fa6d05..939514e7eab 100644 --- a/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx +++ b/frontend/resourceadm/utils/resourceUtils/resourceUtils.test.tsx @@ -5,9 +5,11 @@ import { getEnvLabel, mapKeywordStringToKeywordTypeArray, validateResource, + getMigrationErrorMessage, } from './'; import type { EnvId } from './resourceUtils'; -import type { Resource, SupportedLanguage } from 'app-shared/types/ResourceAdm'; +import type { Resource, ResourceError, SupportedLanguage } from 'app-shared/types/ResourceAdm'; +import { ServerCodes } from 'app-shared/enums/ServerCodes'; describe('mapKeywordStringToKeywordTypeArray', () => { it('should split keywords correctly', () => { @@ -219,3 +221,45 @@ describe('deepCompare', () => { }); }); }); + +describe('getMigrationErrorMessage', () => { + it('returns no error', () => { + const error = getMigrationErrorMessage(null, null, true); + expect(error).toBeNull(); + }); + + it('returns error when start migration status is forbidden', () => { + const migrateError = { response: { status: ServerCodes.Forbidden } }; + const error = getMigrationErrorMessage(null, migrateError as ResourceError, true); + expect(error.errorMessage).toEqual('resourceadm.migration_no_migration_access'); + }); + + it('returns error when start migration failed', () => { + const migrateError = { response: { status: ServerCodes.InternalServerError } }; + const error = getMigrationErrorMessage(null, migrateError as ResourceError, true); + expect(error.errorMessage).toEqual('resourceadm.migration_post_migration_failed'); + }); + + it('returns error when service is not found', () => { + const loadDelegationCountError = { response: { status: ServerCodes.NotFound } }; + const error = getMigrationErrorMessage(loadDelegationCountError as ResourceError, null, true); + expect(error.errorMessage).toEqual('resourceadm.migration_service_not_found'); + }); + + it('returns error when service cannot be migrated in environment', () => { + const loadDelegationCountError = { response: { status: ServerCodes.Forbidden } }; + const error = getMigrationErrorMessage(loadDelegationCountError as ResourceError, null, true); + expect(error.errorMessage).toEqual('resourceadm.migration_cannot_migrate_in_env'); + }); + + it('returns error when unknown error occurs', () => { + const loadDelegationCountError = { response: { status: ServerCodes.InternalServerError } }; + const error = getMigrationErrorMessage(loadDelegationCountError as ResourceError, null, true); + expect(error.errorMessage).toEqual('resourceadm.migration_technical_error'); + }); + + it('returns error when resource is not published', () => { + const error = getMigrationErrorMessage(null, null, false); + expect(error.errorMessage).toEqual('resourceadm.migration_not_published'); + }); +}); diff --git a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts index 467f56cefd9..b6d04538588 100644 --- a/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts +++ b/frontend/resourceadm/utils/resourceUtils/resourceUtils.ts @@ -8,8 +8,10 @@ import type { SupportedLanguage, Resource, ResourceFormError, + ResourceError, } from 'app-shared/types/ResourceAdm'; import { isAppPrefix, isSePrefix } from '../stringUtils'; +import { ServerCodes } from 'app-shared/enums/ServerCodes'; /** * The map of resource type @@ -396,3 +398,46 @@ export const getAltinn2Reference = ( )?.reference; return serviceCode && serviceEdition ? [serviceCode, serviceEdition] : null; }; + +export const getMigrationErrorMessage = ( + loadDelegationCountError: Error | null, + migrateDelegationsError: Error | null, + isPublishedInEnv: boolean, +): { + errorMessage: string; + severity: 'success' | 'warning' | 'danger'; +} | null => { + const loadErrorStatus = (loadDelegationCountError as ResourceError)?.response.status; + const isErrorForbidden = + (migrateDelegationsError as ResourceError)?.response?.status === ServerCodes.Forbidden; + + if (migrateDelegationsError) { + return { + errorMessage: isErrorForbidden + ? 'resourceadm.migration_no_migration_access' + : 'resourceadm.migration_post_migration_failed', + severity: 'danger', + }; + } else if (loadErrorStatus === ServerCodes.NotFound) { + return { + errorMessage: 'resourceadm.migration_service_not_found', + severity: 'success', + }; + } else if (loadErrorStatus === ServerCodes.Forbidden) { + return { + errorMessage: 'resourceadm.migration_cannot_migrate_in_env', + severity: 'danger', + }; + } else if (loadErrorStatus === ServerCodes.InternalServerError) { + return { + errorMessage: 'resourceadm.migration_technical_error', + severity: 'danger', + }; + } else if (!isPublishedInEnv) { + return { + errorMessage: 'resourceadm.migration_not_published', + severity: 'warning', + }; + } + return null; +}; From 34c022aaecd4f625000a7fc06582fa2ca6cd2d90 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:15:34 +0100 Subject: [PATCH 10/35] chore(deps): update dependency system.text.json to 8.0.5 [security] (#14226) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .../Altinn.EFormidlingClient.Tests.csproj | 2 +- .../Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj index c09d2e97678..796eb4cc32c 100644 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj +++ b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj @@ -20,7 +20,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj index f31f6a8ba2a..22956d0c2be 100644 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj +++ b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj @@ -25,7 +25,7 @@ - + From 2da9920800f01a8b508b2a223df2a2e127bdd89b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:16:35 +0100 Subject: [PATCH 11/35] chore(deps): update dependency eslint-plugin-react-hooks to v5 (#14245) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 77a046e4d66..236e40ef520 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "eslint-plugin-import": "2.31.0", "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.2", - "eslint-plugin-react-hooks": "4.6.2", + "eslint-plugin-react-hooks": "5.1.0", "eslint-plugin-testing-library": "7.1.0", "glob": "11.0.0", "husky": "9.1.7", diff --git a/yarn.lock b/yarn.lock index 795f1dc4025..607e9de7193 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6178,7 +6178,7 @@ __metadata: eslint-plugin-import: "npm:2.31.0" eslint-plugin-jsx-a11y: "npm:6.10.2" eslint-plugin-react: "npm:7.37.2" - eslint-plugin-react-hooks: "npm:4.6.2" + eslint-plugin-react-hooks: "npm:5.1.0" eslint-plugin-testing-library: "npm:7.1.0" glob: "npm:11.0.0" husky: "npm:9.1.7" @@ -9657,12 +9657,12 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:4.6.2": - version: 4.6.2 - resolution: "eslint-plugin-react-hooks@npm:4.6.2" +"eslint-plugin-react-hooks@npm:5.1.0": + version: 5.1.0 + resolution: "eslint-plugin-react-hooks@npm:5.1.0" peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - checksum: 10/5a0680941f34e70cf505bcb6082df31a3e445d193ee95a88ff3483041eb944f4cefdaf7e81b0eb1feb4eeceee8c7c6ddb8a2a6e8c4c0388514a42e16ac7b7a69 + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + checksum: 10/b6778fd9e1940b06868921309e8b269426e17eda555816d4b71def4dcf0572de1199fdb627ac09ce42160b9569a93cd9b0fd81b740ab4df98205461c53997a43 languageName: node linkType: hard From 59ca243a2aa9e5c7420284464c0b48aa95c60f6b Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 9 Dec 2024 09:34:36 +0100 Subject: [PATCH 12/35] feat: also resolve app version from experimental package (#14239) --- .../Designer/Helpers/PackageVersionHelper.cs | 29 ++++++++++--------- .../Implementation/AppDevelopmentService.cs | 4 ++- .../Helpers/PackageVersionHelperTests.cs | 16 ++++++---- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/backend/src/Designer/Helpers/PackageVersionHelper.cs b/backend/src/Designer/Helpers/PackageVersionHelper.cs index ae294de1429..23d19d5609b 100644 --- a/backend/src/Designer/Helpers/PackageVersionHelper.cs +++ b/backend/src/Designer/Helpers/PackageVersionHelper.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.Collections.Generic; +using System.Linq; using System.Xml.Linq; using System.Xml.XPath; using NuGet.Versioning; @@ -7,27 +8,29 @@ namespace Altinn.Studio.Designer.Helpers { public static class PackageVersionHelper { - public static bool TryGetPackageVersionFromCsprojFile(string csprojFilePath, string packageName, out SemanticVersion version) + public static bool TryGetPackageVersionFromCsprojFile(string csprojFilePath, IReadOnlyList packageNames, out SemanticVersion version) { version = null; var doc = XDocument.Load(csprojFilePath); var packageReferences = doc.XPathSelectElements("//PackageReference") - .Where(element => element.Attribute("Include")?.Value == packageName).ToList(); + .Where(element => packageNames.Contains(element.Attribute("Include")?.Value)); - if (packageReferences.Count != 1) + foreach (var packageReference in packageReferences) { - return false; - } - - var packageReference = packageReferences.First(); + string versionString = packageReference.Attribute("Version")?.Value; + if (string.IsNullOrEmpty(versionString)) + { + continue; + } - string versionString = packageReference.Attribute("Version")?.Value; - if (string.IsNullOrEmpty(versionString)) - { - return false; + if (SemanticVersion.TryParse(versionString, out version)) + { + return true; + } } - return SemanticVersion.TryParse(versionString, out version); + version = default; + return false; } } } diff --git a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs index 569333c0633..cc04b5523ec 100644 --- a/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs +++ b/backend/src/Designer/Services/Implementation/AppDevelopmentService.cs @@ -538,9 +538,11 @@ public SemanticVersion GetAppLibVersion(AltinnRepoEditingContext altinnRepoEditi var csprojFiles = altinnAppGitRepository.FindFiles(new[] { "*.csproj" }); + string[] packageNames = ["Altinn.App.Api", "Altinn.App.Api.Experimental"]; + foreach (string csprojFile in csprojFiles) { - if (PackageVersionHelper.TryGetPackageVersionFromCsprojFile(csprojFile, "Altinn.App.Api", + if (PackageVersionHelper.TryGetPackageVersionFromCsprojFile(csprojFile, packageNames, out SemanticVersion version)) { return version; diff --git a/backend/tests/Designer.Tests/Helpers/PackageVersionHelperTests.cs b/backend/tests/Designer.Tests/Helpers/PackageVersionHelperTests.cs index 38fb6260bed..b49faf47824 100644 --- a/backend/tests/Designer.Tests/Helpers/PackageVersionHelperTests.cs +++ b/backend/tests/Designer.Tests/Helpers/PackageVersionHelperTests.cs @@ -1,4 +1,5 @@ using System.IO; +using System.Linq; using Altinn.Studio.Designer.Helpers; using DotNet.Testcontainers.Builders; using FluentAssertions; @@ -15,13 +16,18 @@ public void TryGetPackageVersionFromCsprojFile_GivenValidCsprojFile_ReturnsTrue( { string testTemplateCsProjPath = Path.Combine(CommonDirectoryPath.GetSolutionDirectory().DirectoryPath, "..", "testdata", "AppTemplates", "AspNet", "App", "App.csproj"); - bool result = PackageVersionHelper.TryGetPackageVersionFromCsprojFile(testTemplateCsProjPath, packageName, out var version); + string[] packages = [packageName, $"{packageName}.Experimental"]; + string[][] inputs = [packages, packages.Reverse().ToArray()]; + foreach (var input in inputs) + { + bool result = PackageVersionHelper.TryGetPackageVersionFromCsprojFile(testTemplateCsProjPath, input, out var version); - result.Should().Be(expectedResult); + result.Should().Be(expectedResult); - if (result) - { - version.ToString().Should().Be(expectedVersion); + if (result) + { + version.ToString().Should().Be(expectedVersion); + } } } } From 732cc5f1000815932b864c2d925f95f7c526beae Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:47:25 +0100 Subject: [PATCH 13/35] chore(deps): update dependency libgit2sharp to 0.31.0 (#14241) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Erling Hauan <148075168+ErlingHauan@users.noreply.github.com> --- backend/packagegroups/NuGet.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/packagegroups/NuGet.props b/backend/packagegroups/NuGet.props index f32cb55f724..8a594aa99bb 100644 --- a/backend/packagegroups/NuGet.props +++ b/backend/packagegroups/NuGet.props @@ -13,7 +13,7 @@ - + From 950a3317fc422eb6a56584739691e1947434b957 Mon Sep 17 00:00:00 2001 From: Erling Hauan <148075168+ErlingHauan@users.noreply.github.com> Date: Mon, 9 Dec 2024 10:27:39 +0100 Subject: [PATCH 14/35] refactor: rename sub-components of StudioCodeListEditor (#14237) Co-authored-by: Tomas Engebretsen --- .../StudioCodelistEditor/StudioCodeListEditor.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx index 7f2709c1e61..18e26f708dc 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx @@ -110,13 +110,13 @@ function EmptyCodeListTable(): ReactElement { function CodeListTableWithContent(props: InternalCodeListEditorWithErrorsProps): ReactElement { return ( - - + + ); } -function Headings(): ReactElement { +function TableHeadings(): ReactElement { const { texts } = useStudioCodeListEditorContext(); return ( @@ -132,7 +132,7 @@ function Headings(): ReactElement { ); } -function CodeLists({ +function TableBody({ codeList, onChange, errorMap, From 9f43969f068db88865648f54949cb728ff92f647 Mon Sep 17 00:00:00 2001 From: Konrad-Simso Date: Mon, 9 Dec 2024 11:45:53 +0100 Subject: [PATCH 15/35] feat: merge select and edit tabs in config (#14137) --- frontend/language/src/nb.json | 7 + .../StudioCodeListEditor.test.tsx | 15 +- .../StudioCodeListEditor.tsx | 4 +- .../StudioCodeListEditorRow.tsx | 40 +-- .../CodeList/CodeLists/CodeLists.test.tsx | 4 +- .../src/components/Properties/Text.tsx | 3 - .../EditOptions/EditOptions.test.tsx | 235 +++--------------- .../editModal/EditOptions/EditOptions.tsx | 14 +- .../OptionTabs/EditManualOptions/index.ts | 2 - .../EditManualOptionsWithEditor.test.tsx | 176 ------------- .../EditManualOptionsWithEditor.tsx | 51 ---- .../EditManualOptionsWithEditor/index.ts | 1 - .../EditOptionList/OptionListEditor.test.tsx | 148 ----------- .../EditOptionList/OptionListEditor.tsx | 97 -------- .../OptionTabs/EditOptionList/index.ts | 2 - .../AddManualOptionsModal.module.css} | 5 + .../AddManualOptionsModal.test.tsx | 118 +++++++++ .../AddManualOptionsModal.tsx | 64 +++++ .../EditTab/AddManualOptionsModal/index.ts | 1 + .../OptionTabs/EditTab/EditTab.module.css | 25 ++ .../OptionTabs/EditTab/EditTab.test.tsx | 76 ++++++ .../OptionTabs/EditTab/EditTab.tsx | 112 +++++++++ .../OptionListEditor.module.css | 7 +- .../OptionListEditor.test.tsx | 230 +++++++++++++++++ .../OptionListEditor/OptionListEditor.tsx | 184 ++++++++++++++ .../EditTab/OptionListEditor/index.ts | 1 + .../OptionListSelector.module.css | 4 + .../OptionListSelector.test.tsx | 112 +++++++++ .../OptionListSelector/OptionListSelector.tsx | 95 +++++++ .../EditTab/OptionListSelector/index.ts | 1 + .../OptionListUploader.test.tsx | 95 +++++++ .../OptionListUploader/OptionListUploader.tsx | 85 +++++++ .../EditTab/OptionListUploader/index.ts | 1 + .../utils/findFileNameError.test.ts | 21 ++ .../utils/findFileNameError.ts | 29 +++ .../EditOptions/OptionTabs/EditTab/index.ts | 1 + .../EditOption/EditOption.module.css | 0 .../EditOption/EditOption.test.tsx | 0 .../EditOption/EditOption.tsx | 0 .../OptionValue/OptionValue.module.css | 0 .../EditOption/OptionValue/OptionValue.tsx | 0 .../EditOption/OptionValue/index.ts | 0 .../EditOption/index.ts | 0 .../EditOption/utils.test.ts | 0 .../EditOption/utils.ts | 0 .../OptionTabs/ManualTab/ManualTab.module.css | 3 + .../ManualTab.test.tsx} | 78 +++--- .../ManualTab.tsx} | 66 ++--- .../EditOptions/OptionTabs/ManualTab/index.ts | 1 + .../OptionTabs/OptionTabs.module.css | 15 +- .../OptionTabs/OptionTabs.test.tsx | 180 ++++++++++++++ .../EditOptions/OptionTabs/OptionTabs.tsx | 158 ++++++------ .../ReferenceTab/ReferenceTab.module.css | 3 + .../ReferenceTab.test.tsx} | 64 +++-- .../ReferenceTab.tsx} | 8 +- .../OptionTabs/ReferenceTab/index.ts | 1 + .../SelectTab.module.css} | 7 + .../SelectTab.test.tsx} | 74 +++--- .../SelectTab.tsx} | 19 +- .../EditOptions/OptionTabs/SelectTab/index.ts | 1 + .../EditOptions/OptionTabs/hooks/index.ts | 1 - .../hooks/useOptionListButtonValue.ts | 14 -- .../OptionTabs}/utils/optionsUtils.test.ts | 75 +++--- .../OptionTabs/utils/optionsUtils.ts | 48 ++++ .../ux-editor/src/utils/optionsUtils.ts | 61 ----- 65 files changed, 1863 insertions(+), 1080 deletions(-) delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.test.tsx delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.tsx delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/index.ts rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptionsWithEditor/EditManualOptionsWithEditor.module.css => EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css} (81%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditOptionList => EditTab/OptionListEditor}/OptionListEditor.module.css (61%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.test.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/index.ts rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/EditOption.module.css (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/EditOption.test.tsx (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/EditOption.tsx (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/OptionValue/OptionValue.module.css (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/OptionValue/OptionValue.tsx (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/OptionValue/index.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/index.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/utils.test.ts (100%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions => ManualTab}/EditOption/utils.ts (100%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.module.css rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions/EditManualOptions.test.tsx => ManualTab/ManualTab.test.tsx} (74%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditManualOptions/EditManualOptions.tsx => ManualTab/ManualTab.tsx} (50%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/index.ts create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.module.css rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditOptionList/EditOptionListReference.test.tsx => ReferenceTab/ReferenceTab.test.tsx} (76%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditOptionList/EditOptionListReference.tsx => ReferenceTab/ReferenceTab.tsx} (89%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/index.ts rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditOptionList/EditOptionList.module.css => SelectTab/SelectTab.module.css} (60%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditOptionList/EditOptionList.test.tsx => SelectTab/SelectTab.test.tsx} (76%) rename frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/{EditOptionList/EditOptionList.tsx => SelectTab/SelectTab.tsx} (90%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/index.ts delete mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useOptionListButtonValue.ts rename frontend/packages/ux-editor/src/{ => components/config/editModal/EditOptions/OptionTabs}/utils/optionsUtils.test.ts (58%) create mode 100644 frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts delete mode 100644 frontend/packages/ux-editor/src/utils/optionsUtils.ts diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 22696bdf67e..9c19cc6a6fa 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1556,6 +1556,10 @@ "ux_editor.modal_header_type_helper": "Velg titteltype", "ux_editor.modal_new_option": "Legg til alternativ", "ux_editor.modal_properties_add_radio_button_options": "Hvordan vil du legge til radioknapper?", + "ux_editor.modal_properties_code_list": "Velg fra biblioteket", + "ux_editor.modal_properties_code_list_alert_title": "Du redigerer nå en kodeliste i biblioteket.", + "ux_editor.modal_properties_code_list_button_title_library": "Kodeliste fra biblioteket", + "ux_editor.modal_properties_code_list_button_title_manual": "Kodeliste på komponenten", "ux_editor.modal_properties_code_list_custom_list": "Egendefinert kodeliste", "ux_editor.modal_properties_code_list_filename_error": "Filnavnet er ugyldig. Du kan bruke tall, understrek, punktum, bindestrek, og store/små bokstaver fra det norske alfabetet. Filnavnet må starte med en engelsk bokstav.", "ux_editor.modal_properties_code_list_helper": "Velg kodeliste", @@ -1712,11 +1716,14 @@ "ux_editor.options.code_list_referenceId.description": "Her kan du legge til en referanse-ID til en dynamisk kodeliste som er satt opp i koden.", "ux_editor.options.code_list_referenceId.description_details": "Du bruker dynamiske kodelister for å tilpasse alternativer for brukerne. Det kan for eksempel være tilpasninger ut fra geografisk plassering, eller valg brukeren gjør tidligere i skjemaet.", "ux_editor.options.multiple": "{{value}} alternativer", + "ux_editor.options.option_edit_text": "Rediger kodeliste", + "ux_editor.options.option_remove_text": "Fjern valgt kodeliste", "ux_editor.options.section_heading": "Valg for kodelister", "ux_editor.options.single": "{{value}} alternativ", "ux_editor.options.tab_code_list": "Velg kodeliste", "ux_editor.options.tab_manual": "Sett opp egne alternativer", "ux_editor.options.tab_referenceId": "Angi referanse-ID", + "ux_editor.options.upload_title": "Last opp egen kodeliste", "ux_editor.page": "Side", "ux_editor.page_config_pdf_abort_converting_page_to_pdf": "Avbryt å gjøre om siden til PDF", "ux_editor.page_config_pdf_card_heading": "Siden skal være en PDF", diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx index 267f27e8a70..b78acd0001b 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx @@ -119,7 +119,8 @@ describe('StudioCodeListEditor', () => { const labelInput = screen.getByRole('textbox', { name: texts.itemLabel(1) }); const newValue = 'new text'; await user.type(labelInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + await user.tab(); + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], label: newValue }, codeList[1], @@ -133,7 +134,8 @@ describe('StudioCodeListEditor', () => { const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) }); const newValue = 'new text'; await user.type(valueInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + await user.tab(); + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], value: newValue }, codeList[1], @@ -147,7 +149,8 @@ describe('StudioCodeListEditor', () => { const descriptionInput = screen.getByRole('textbox', { name: texts.itemDescription(1) }); const newValue = 'new text'; await user.type(descriptionInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + await user.tab(); + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], description: newValue }, codeList[1], @@ -161,7 +164,8 @@ describe('StudioCodeListEditor', () => { const helpTextInput = screen.getByRole('textbox', { name: texts.itemHelpText(1) }); const newValue = 'new text'; await user.type(helpTextInput, newValue); - expect(onChange).toHaveBeenCalledTimes(newValue.length); + await user.tab(); + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], helpText: newValue }, codeList[1], @@ -263,7 +267,8 @@ describe('StudioCodeListEditor', () => { const validValueInput = screen.getByRole('textbox', { name: texts.itemValue(3) }); const newValue = 'new value'; await user.type(validValueInput, newValue); - expect(onInvalid).toHaveBeenCalledTimes(newValue.length); + await user.tab(); + expect(onInvalid).toHaveBeenCalledTimes(1); }); it('Does not trigger onInvalid if an invalid code list is changed to a valid state', async () => { diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx index 18e26f708dc..a4cff5e76da 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx @@ -145,7 +145,7 @@ function TableBody({ [codeList, onChange], ); - const handleChange = useCallback( + const handleBlur = useCallback( (index: number, newItem: CodeListItem) => { const updatedCodeList = changeCodeListItem(codeList, index, newItem); onChange(updatedCodeList); @@ -161,7 +161,7 @@ function TableBody({ item={item} key={index} number={index + 1} - onChange={(newItem) => handleChange(index, newItem)} + onBlur={(newItem) => handleBlur(index, newItem)} onDeleteButtonClick={() => handleDeleteButtonClick(index)} /> ))} diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx index 501f9f4fcac..161b6d04264 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx @@ -13,7 +13,7 @@ type StudioCodeListEditorRowProps = { error: ValueError | null; item: CodeListItem; number: number; - onChange: (newItem: CodeListItem) => void; + onBlur: (newItem: CodeListItem) => void; onDeleteButtonClick: () => void; }; @@ -21,7 +21,7 @@ export function StudioCodeListEditorRow({ error, item, number, - onChange, + onBlur, onDeleteButtonClick, }: StudioCodeListEditorRowProps) { const { texts } = useStudioCodeListEditorContext(); @@ -29,33 +29,33 @@ export function StudioCodeListEditorRow({ const handleLabelChange = useCallback( (label: string) => { const updatedItem = changeLabel(item, label); - onChange(updatedItem); + onBlur(updatedItem); }, - [item, onChange], + [item, onBlur], ); const handleDescriptionChange = useCallback( (description: string) => { const updatedItem = changeDescription(item, description); - onChange(updatedItem); + onBlur(updatedItem); }, - [item, onChange], + [item, onBlur], ); const handleValueChange = useCallback( (value: string) => { const updatedItem = changeValue(item, value); - onChange(updatedItem); + onBlur(updatedItem); }, - [item, onChange], + [item, onBlur], ); const handleHelpTextChange = useCallback( (helpText: string) => { const updatedItem = changeHelpText(item, helpText); - onChange(updatedItem); + onBlur(updatedItem); }, - [item, onChange], + [item, onBlur], ); return ( @@ -64,22 +64,22 @@ export function StudioCodeListEditorRow({ autoComplete='off' error={error && texts.valueErrors[error]} label={texts.itemValue(number)} - onChange={handleValueChange} + onBlur={handleValueChange} value={item.value} /> @@ -90,23 +90,23 @@ export function StudioCodeListEditorRow({ type TextfieldCellProps = { error?: string; label: string; - onChange: (newString: string) => void; + onBlur: (newString: string) => void; value: CodeListItemValue; autoComplete?: HTMLInputAutoCompleteAttribute; }; -function TextfieldCell({ error, label, value, onChange, autoComplete }: TextfieldCellProps) { +function TextfieldCell({ error, label, value, onBlur, autoComplete }: TextfieldCellProps) { const ref = useRef(null); useEffect((): void => { ref.current?.setCustomValidity(error || ''); }, [error]); - const handleChange = useCallback( + const handleBlur = useCallback( (event: React.ChangeEvent): void => { - onChange(event.target.value); + onBlur(event.target.value); }, - [onChange], + [onBlur], ); const handleFocus = useCallback((event: FocusEvent): void => { @@ -118,7 +118,7 @@ function TextfieldCell({ error, label, value, onChange, autoComplete }: Textfiel aria-label={label} autoComplete={autoComplete} className={classes.textfieldCell} - onChange={handleChange} + onBlur={handleBlur} onFocus={handleFocus} ref={ref} value={(value as string) ?? ''} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.test.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.test.tsx index 13c10ab63e7..e73937a2a17 100644 --- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.test.tsx +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.test.tsx @@ -40,7 +40,9 @@ describe('CodeLists', () => { textMock('code_list_editor.value_item', { number: 1 }), ); await user.type(codeListFirstItemValue, codeListValueText); - expect(onUpdateCodeListMock).toHaveBeenCalledTimes(codeListValueText.length); + await user.tab(); + + expect(onUpdateCodeListMock).toHaveBeenCalledTimes(1); expect(onUpdateCodeListMock).toHaveBeenLastCalledWith({ codeList: [expect.objectContaining({ value: codeListValueText })], title: codeListName, diff --git a/frontend/packages/ux-editor/src/components/Properties/Text.tsx b/frontend/packages/ux-editor/src/components/Properties/Text.tsx index c85a3ab03d6..60daa4fd0ce 100644 --- a/frontend/packages/ux-editor/src/components/Properties/Text.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/Text.tsx @@ -64,9 +64,6 @@ export const Text = () => { } handleComponentChange={handleComponentChange} layoutName={selectedFormLayoutName} - renderOptions={{ - areLayoutOptionsSupported: schema.properties.optionsId! && schema.properties.options, - }} /> )} {form.type === ComponentType.Image && ( diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx index 40ed26ba90d..762ca8a1ddd 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.test.tsx @@ -1,225 +1,42 @@ import React from 'react'; -import { screen, waitFor } from '@testing-library/react'; +import { screen } from '@testing-library/react'; import { EditOptions } from './EditOptions'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { ComponentType } from 'app-shared/types/ComponentType'; -import type { FormComponent } from '../../../../types/FormComponent'; import type { FormItem } from '../../../../types/FormItem'; import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; -import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../../../../testing/mocks'; +import { componentMocks } from '../../../../testing/componentMocks'; -const mockComponent: FormComponent = { - id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96', - type: ComponentType.RadioButtons, - textResourceBindings: { - title: 'ServiceName', - }, - maxLength: 10, - itemType: 'COMPONENT', - dataModelBindings: { simpleBinding: '' }, -}; - +// Test data: +const mockComponent = componentMocks[ComponentType.RadioButtons]; const queryClientMock = createQueryClientMock(); -const renderEditOptions = async ({ - componentProps, - handleComponentChange = jest.fn(), - queries = {}, - renderOptions = {}, -}: { - componentProps?: Partial>; - handleComponentChange?: () => void; - queries?: Partial; - renderOptions?: { - areLayoutOptionsSupported?: boolean; - }; -} = {}) => { - return renderWithProviders( - , - { - queries, - queryClient: queryClientMock, - }, - ); -}; - describe('EditOptions', () => { - afterEach(() => { - queryClientMock.clear(); - }); - it('should render', async () => { - await renderEditOptions(); - expect(screen.getByText(textMock('ux_editor.options.section_heading'))).toBeInTheDocument(); - }); + afterEach(() => queryClientMock.clear()); - it('should show code list input by default when neither options nor optionId are set', async () => { - await renderEditOptions({ - componentProps: { options: undefined, optionsId: undefined }, - }); - expect( - await screen.findByRole('tab', { - name: textMock('ux_editor.options.tab_code_list'), - selected: true, - }), - ).toBeInTheDocument(); + it('should render', () => { + renderEditOptions(); + expect(screen.getByText(textMock('ux_editor.options.section_heading'))).toBeInTheDocument(); }); - it('should show manual options view when options property is set', async () => { - await renderEditOptions({ - componentProps: { - options: [ - { label: 'label1', value: 'value1' }, - { label: 'label2', value: 'value2' }, - ], - }, - }); - expect( - await screen.findByRole('tab', { - name: textMock('ux_editor.options.tab_manual'), - selected: true, - }), - ).toBeInTheDocument(); - - expect(screen.getByText('value1')).toBeInTheDocument(); - - expect(screen.getByText('value2')).toBeInTheDocument(); - - expect(screen.getByText(textMock('ux_editor.modal_new_option'))).toBeInTheDocument(); + it('should render spinner when loading data', () => { + renderEditOptions(); + expect(screen.getByText(textMock('ux_editor.modal_properties_loading'))).toBeInTheDocument(); }); - it('should show manual options view when options property is empty list', async () => { - await renderEditOptions({ - componentProps: { options: [] }, - queries: { - getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve([])), - }, - }); - expect(await screen.findByText(textMock('ux_editor.modal_new_option'))).toBeInTheDocument(); - }); - - it('should show code list tab when component has optionsId defined matching an optionId in optionsID-list', async () => { - const optionsId = 'optionsId'; - await renderEditOptions({ - componentProps: { - optionsId, - }, - queries: { - getOptionListIds: jest - .fn() - .mockImplementation(() => Promise.resolve([optionsId])), - }, + it('should render child component when loading is done', async () => { + renderEditOptions({ + componentProps: { options: undefined, optionsId: undefined }, }); - expect( await screen.findByRole('tab', { name: textMock('ux_editor.options.tab_code_list'), - selected: true, }), ).toBeInTheDocument(); }); - it('should switch to manual input clicking manual tab', async () => { - const user = userEvent.setup(); - await renderEditOptions({ - componentProps: { optionsId: '' }, - }); - - expect( - screen.queryByRole('tab', { - name: textMock('ux_editor.options.tab_manual'), - selected: true, - }), - ).not.toBeInTheDocument(); - - const manualTabElement = await screen.findByRole('tab', { - name: textMock('ux_editor.options.tab_manual'), - }); - await waitFor(() => user.click(manualTabElement)); - expect( - screen.getByRole('tab', { - name: textMock('ux_editor.options.tab_manual'), - selected: true, - }), - ).toBeInTheDocument(); - }); - - it('should switch to codelist input clicking codelist tab', async () => { - const user = userEvent.setup(); - await renderEditOptions({ - componentProps: { options: [] }, - }); - - expect( - screen.queryByRole('tab', { - name: textMock('ux_editor.options.tab_code_list'), - selected: true, - }), - ).not.toBeInTheDocument(); - - const codelistTabElement = await screen.findByRole('tab', { - name: textMock('ux_editor.options.tab_code_list'), - }); - await waitFor(() => user.click(codelistTabElement)); - expect( - screen.getByRole('tab', { - name: textMock('ux_editor.options.tab_code_list'), - selected: true, - }), - ).toBeInTheDocument(); - }); - - it('should switch to referenceId input clicking referenceId tab', async () => { - const user = userEvent.setup(); - await renderEditOptions({ - componentProps: { options: [] }, - }); - - expect( - screen.queryByRole('tab', { - name: textMock('ux_editor.options.tab_referenceId'), - selected: true, - }), - ).not.toBeInTheDocument(); - - const referenceIdElement = await screen.findByRole('tab', { - name: textMock('ux_editor.options.tab_referenceId'), - }); - await waitFor(() => user.click(referenceIdElement)); - expect( - screen.getByRole('tab', { - name: textMock('ux_editor.options.tab_referenceId'), - selected: true, - }), - ).toBeInTheDocument(); - }); - - it('should show alert message in Manual tab when prop areLayoutOptionsSupported is false', async () => { - const user = userEvent.setup(); - await renderEditOptions({ - componentProps: { optionsId: '' }, - renderOptions: { areLayoutOptionsSupported: false }, - queries: { - getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve([])), - }, - }); - - const manualTabElement = await screen.findByRole('tab', { - name: textMock('ux_editor.options.tab_manual'), - }); - - await waitFor(() => user.click(manualTabElement)); - expect(screen.getByText(textMock('ux_editor.options.code_list_only'))).toBeInTheDocument(); - }); - it('should show error message if getOptionListIds fails', async () => { renderEditOptions({ queries: { @@ -235,3 +52,27 @@ describe('EditOptions', () => { jest.clearAllMocks(); }); }); + +function renderEditOptions({ + componentProps, + handleComponentChange = jest.fn(), + queries = {}, +}: { + componentProps?: Partial>; + handleComponentChange?: () => void; + queries?: Partial; +} = {}) { + return renderWithProviders( + , + { + queries, + queryClient: queryClientMock, + }, + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx index f34699e4604..2ecf681349f 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/EditOptions.tsx @@ -1,20 +1,16 @@ import React from 'react'; -import { ErrorMessage, Heading } from '@digdir/designsystemet-react'; -import classes from './EditOptions.module.css'; import type { IGenericEditComponent } from '../../componentConfig'; +import type { SelectionComponentType } from '../../../../types/FormComponent'; import { useOptionListIdsQuery } from '../../../../hooks/queries/useOptionListIdsQuery'; +import { ErrorMessage, Heading } from '@digdir/designsystemet-react'; import { StudioSpinner } from '@studio/components'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { useTranslation } from 'react-i18next'; -import type { SelectionComponentType } from '../../../../types/FormComponent'; import { OptionTabs } from './OptionTabs'; +import classes from './EditOptions.module.css'; export interface ISelectionEditComponentProvidedProps - extends IGenericEditComponent { - renderOptions?: { - areLayoutOptionsSupported?: boolean; - }; -} + extends IGenericEditComponent {} export enum SelectedOptionsType { CodeList = 'codelist', @@ -26,7 +22,6 @@ export enum SelectedOptionsType { export function EditOptions({ component, handleComponentChange, - renderOptions, }: ISelectionEditComponentProvidedProps) { const { org, app } = useStudioEnvironmentParams(); const { data: optionListIds, isPending, isError } = useOptionListIdsQuery(org, app); @@ -50,7 +45,6 @@ export function EditOptions({ )} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts deleted file mode 100644 index 5c20c260147..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { EditManualOptions } from './EditManualOptions'; -export type { EditManualOptionsProps } from './EditManualOptions'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx deleted file mode 100644 index 28fca9f1305..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.test.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React from 'react'; -import { screen } from '@testing-library/react'; -import { EditManualOptionsWithEditor } from './EditManualOptionsWithEditor'; -import { renderWithProviders } from '../../../../../../testing/mocks'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { ComponentType } from 'app-shared/types/ComponentType'; -import type { FormItem } from '../../../../../../types/FormItem'; -import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; -import type { FormComponent } from '../../../../../../types/FormComponent'; -import userEvent from '@testing-library/user-event'; - -const mockComponent: FormComponent = { - id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96', - type: ComponentType.RadioButtons, - textResourceBindings: { - title: 'ServiceName', - }, - maxLength: 10, - itemType: 'COMPONENT', - dataModelBindings: { simpleBinding: '' }, -}; - -const renderEditManualOptionsWithEditor = < - T extends ComponentType.Checkboxes | ComponentType.RadioButtons, ->({ - componentProps, - handleComponentChange = jest.fn(), -}: { - componentProps?: Partial>; - handleComponentChange?: () => void; - queries?: Partial; -} = {}) => { - const component = { - ...mockComponent, - ...componentProps, - }; - renderWithProviders( - , - ); -}; - -describe('EditManualOptionsWithEditor', () => { - it('should display a button when no code list is defined in the layout', () => { - renderEditManualOptionsWithEditor(); - - const modalButton = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_custom_list'), - }); - - expect(modalButton).toBeInTheDocument(); - }); - - it('should display a button when a code list is defined in the layout', () => { - renderEditManualOptionsWithEditor({ - componentProps: { - options: [{ label: 'option1', value: 'option1' }], - }, - }); - - const modalButton = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_custom_list'), - }); - - expect(modalButton).toBeInTheDocument(); - }); - - it('should not display how many options have been defined, when no options are defined', () => { - renderEditManualOptionsWithEditor(); - - const optionText = screen.queryByText(textMock('ux_editor.options.single', { value: 1 })); - const optionsText = screen.queryByText(textMock('ux_editor.options.multiple', { value: 2 })); - - expect(optionText).not.toBeInTheDocument(); - expect(optionsText).not.toBeInTheDocument(); - }); - - it('should display how many options have been defined, when a single option is defined', () => { - renderEditManualOptionsWithEditor({ - componentProps: { - options: [{ label: 'option1', value: 'option1' }], - }, - }); - - const optionText = screen.getByText(textMock('ux_editor.options.single', { value: 1 })); - const optionsText = screen.queryByText(textMock('ux_editor.options.multiple', { value: 2 })); - - expect(optionText).toBeInTheDocument(); - expect(optionsText).not.toBeInTheDocument(); - }); - - it('should display how many options have been defined, when multiple options are defined', () => { - renderEditManualOptionsWithEditor({ - componentProps: { - options: [ - { label: 'option1', value: 'option1' }, - { label: 'option2', value: 'option2' }, - ], - }, - }); - - const optionText = screen.queryByText(textMock('ux_editor.options.single', { value: 1 })); - const optionsText = screen.getByText(textMock('ux_editor.options.multiple', { value: 2 })); - - expect(optionText).not.toBeInTheDocument(); - expect(optionsText).toBeInTheDocument(); - }); - - it('should open a modal when the trigger button is clicked', async () => { - const user = userEvent.setup(); - renderEditManualOptionsWithEditor(); - - const modalButton = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_custom_list'), - }); - - await user.click(modalButton); - - const modalDialog = screen.getByRole('dialog'); - - expect(modalDialog).toBeInTheDocument(); - }); - - it('should call handleComponentChange when there has been a change in the editor', async () => { - const mockHandleComponentChange = jest.fn(); - const user = userEvent.setup(); - renderEditManualOptionsWithEditor({ handleComponentChange: mockHandleComponentChange }); - - const modalButton = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_custom_list'), - }); - - await user.click(modalButton); - - const addNewButton = screen.getByRole('button', { - name: textMock('code_list_editor.add_option'), - }); - - await user.click(addNewButton); - - expect(mockHandleComponentChange).toHaveBeenCalledWith({ - ...mockComponent, - options: [{ label: '', value: '' }], - }); - }); - - it('should delete optionsId from the layout when using the manual editor', async () => { - const user = userEvent.setup(); - const mockHandleComponentChange = jest.fn(); - renderEditManualOptionsWithEditor({ - componentProps: { - optionsId: 'somePredefinedOptionsList', - }, - handleComponentChange: mockHandleComponentChange, - }); - - const modalButton = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_custom_list'), - }); - - await user.click(modalButton); - - const addNewButton = screen.getByRole('button', { - name: textMock('code_list_editor.add_option'), - }); - - await user.click(addNewButton); - - expect(mockHandleComponentChange).toHaveBeenCalledWith({ - ...mockComponent, // does not contain optionsId - options: [{ label: '', value: '' }], - }); - }); -}); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx deleted file mode 100644 index dbb8c0d896f..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import React, { useRef } from 'react'; -import classes from './EditManualOptionsWithEditor.module.css'; -import { StudioCodeListEditor, StudioModal, StudioProperty } from '@studio/components'; -import type { Option } from 'app-shared/types/Option'; -import { useTranslation } from 'react-i18next'; -import { useOptionListButtonValue, useOptionListEditorTexts } from '../hooks'; -import type { EditManualOptionsProps } from '../EditManualOptions'; - -export function EditManualOptionsWithEditor({ - component, - handleComponentChange, -}: EditManualOptionsProps) { - const { t } = useTranslation(); - const manualOptionsModalRef = useRef(null); - const buttonValue = useOptionListButtonValue(component.options); - const editorTexts = useOptionListEditorTexts(); - - const handleOptionsChange = (options: Option[]) => { - if (component.optionsId) { - delete component.optionsId; - } - - handleComponentChange({ - ...component, - options, - }); - }; - - return ( - <> - manualOptionsModalRef.current.showModal()} - property={t('ux_editor.modal_properties_code_list_custom_list')} - value={buttonValue} - /> - - handleOptionsChange(optionList)} - texts={editorTexts} - /> - - - ); -} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts deleted file mode 100644 index 118cc12b2bc..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EditManualOptionsWithEditor } from './EditManualOptionsWithEditor'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.test.tsx deleted file mode 100644 index a356aff9ac2..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.test.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React from 'react'; -import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; -import type { OptionsLists } from 'app-shared/types/api/OptionsLists'; -import type { Option } from 'app-shared/types/Option'; -import { OptionListEditor } from './OptionListEditor'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { renderWithProviders } from '../../../../../../testing/mocks'; -import userEvent, { type UserEvent } from '@testing-library/user-event'; -import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; -import { app, org } from '@studio/testing/testids'; - -// Test data: -const mockComponentOptionsId = 'options'; -const getOptionLists = jest.fn().mockImplementation(() => Promise.resolve(apiResult)); -const updateOptionList = jest - .fn() - .mockImplementation(() => Promise.resolve([{ value: '', label: '' }])); - -const apiResult: OptionsLists = { - options: [ - { value: 'test', label: 'label text', description: 'description', helpText: 'help text' }, - { value: 2, label: 'label number', description: null, helpText: null }, - { value: true, label: 'label boolean', description: null, helpText: null }, - ], -}; - -describe('OptionListEditor', () => { - afterEach(jest.clearAllMocks); - - it('should render a spinner when there is no data', () => { - renderOptionListEditor({ - queries: { - getOptionLists: jest.fn().mockImplementation(() => Promise.resolve({})), - }, - }); - - expect( - screen.getByText(textMock('ux_editor.modal_properties_code_list_spinner_title')), - ).toBeInTheDocument(); - }); - - it('should render an error message when getOptionLists throws an error', async () => { - await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ - queries: { - getOptionLists: jest.fn().mockRejectedValueOnce(new Error('Error')), - }, - }); - - expect( - screen.getByText(textMock('ux_editor.modal_properties_fetch_option_list_error_message')), - ).toBeInTheDocument(); - }); - - it('should render the open Dialog button', async () => { - await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); - - const btnOpen = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_open_editor'), - }); - - expect(btnOpen).toBeInTheDocument(); - }); - - it('should open Dialog', async () => { - const user = userEvent.setup(); - await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); - - await openModal(user); - - expect(screen.getByRole('dialog')).toBeInTheDocument(); - }); - - it('should close Dialog', async () => { - const user = userEvent.setup(); - await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); - - await openModal(user); - await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when https://github.com/digdir/designsystemet/issues/2195 is fixed - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - }); - - it('should call doReloadPreview when editing', async () => { - const user = userEvent.setup(); - const doReloadPreview = jest.fn(); - await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ - previewContextProps: { doReloadPreview }, - }); - - await openModal(user); - const textBox = screen.getByRole('textbox', { - name: textMock('code_list_editor.description_item', { number: 2 }), - }); - await user.type(textBox, 'test'); - - await waitFor(() => expect(doReloadPreview).toHaveBeenCalledTimes(1)); - }); - - it('should call updateOptionList with correct parameters when closing Dialog', async () => { - const user = userEvent.setup(); - await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); - const expectedResultAfterEdit: Option[] = [ - { value: 'test', label: 'label text', description: 'description', helpText: 'help text' }, - { value: 2, label: 'label number', description: 'test', helpText: null }, - { value: true, label: 'label boolean', description: null, helpText: null }, - ]; - - await openModal(user); - const textBox = screen.getByRole('textbox', { - name: textMock('code_list_editor.description_item', { number: 2 }), - }); - await user.type(textBox, 'test'); - - await waitFor(() => expect(updateOptionList).toHaveBeenCalledTimes(1)); - expect(updateOptionList).toHaveBeenCalledWith( - org, - app, - mockComponentOptionsId, - expectedResultAfterEdit, - ); - }); -}); - -const openModal = async (user: UserEvent) => { - const btnOpen = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_open_editor'), - }); - await user.click(btnOpen); -}; - -const renderOptionListEditor = ({ previewContextProps = {}, queries = {} } = {}) => { - return renderWithProviders(, { - queries: { getOptionLists, updateOptionList, ...queries }, - queryClient: createQueryClientMock(), - previewContextProps, - }); -}; - -const renderOptionListEditorAndWaitForSpinnerToBeRemoved = async ({ - previewContextProps = {}, - queries = {}, -} = {}) => { - const view = renderOptionListEditor({ previewContextProps, queries }); - await waitForElementToBeRemoved(() => { - return screen.queryByText(textMock('ux_editor.modal_properties_code_list_spinner_title')); - }); - return view; -}; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.tsx deleted file mode 100644 index 3279321c080..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { createRef } from 'react'; -import type { Option } from 'app-shared/types/Option'; -import { useTranslation } from 'react-i18next'; -import { - StudioCodeListEditor, - StudioModal, - StudioSpinner, - StudioErrorMessage, - type CodeListEditorTexts, -} from '@studio/components'; -import { TableIcon } from '@studio/icons'; -import { useDebounce } from '@studio/hooks'; -import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; -import { useUpdateOptionListMutation } from 'app-shared/hooks/mutations/useUpdateOptionListMutation'; -import { useOptionListsQuery } from 'app-shared/hooks/queries/useOptionListsQuery'; -import { useOptionListEditorTexts } from '../hooks/useOptionListEditorTexts'; -import { usePreviewContext } from 'app-development/contexts/PreviewContext'; -import classes from './OptionListEditor.module.css'; -import { AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS } from 'app-shared/constants'; - -type OptionListEditorProps = { - optionsId: string; -}; - -export function OptionListEditor({ optionsId }: OptionListEditorProps): React.ReactNode { - const { t } = useTranslation(); - const { org, app } = useStudioEnvironmentParams(); - const { data: optionsLists, status } = useOptionListsQuery(org, app); - - switch (status) { - case 'pending': - return ( - - ); - case 'error': - return ( - - {t('ux_editor.modal_properties_fetch_option_list_error_message')} - - ); - case 'success': { - return ; - } - } -} - -type OptionListEditorModalProps = { - optionsList: Option[]; - optionsId: string; -}; - -function OptionListEditorModal({ - optionsList, - optionsId, -}: OptionListEditorModalProps): React.ReactNode { - const { t } = useTranslation(); - const { org, app } = useStudioEnvironmentParams(); - const { doReloadPreview } = usePreviewContext(); - const { mutate: updateOptionList } = useUpdateOptionListMutation(org, app); - const { debounce } = useDebounce({ debounceTimeInMs: AUTOSAVE_DEBOUNCE_INTERVAL_MILLISECONDS }); - const editorTexts: CodeListEditorTexts = useOptionListEditorTexts(); - const modalRef = createRef(); - - const handleOptionsChange = (options: Option[]) => { - debounce(() => { - updateOptionList({ optionListId: optionsId, optionsList: options }); - doReloadPreview(); - }); - }; - - const handleClose = () => { - modalRef.current?.close(); - }; - - return ( - - }> - {t('ux_editor.modal_properties_code_list_open_editor')} - - - - - - ); -} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/index.ts deleted file mode 100644 index 2066bcc3cdc..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { EditOptionList } from './EditOptionList'; -export { EditOptionListReference } from './EditOptionListReference'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css similarity index 81% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.module.css rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css index 1380eafccd8..e366a282152 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptionsWithEditor/EditManualOptionsWithEditor.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.module.css @@ -6,6 +6,11 @@ height: var(--code-list-modal-height); } +.modalTrigger { + width: 50%; + min-width: 12rem; +} + .content { height: 100%; } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx new file mode 100644 index 00000000000..92033d4e1c2 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.test.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { AddManualOptionsModal } from './AddManualOptionsModal'; +import { renderWithProviders } from '../../../../../../../testing/mocks'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import type { FormItem } from '../../../../../../../types/FormItem'; +import userEvent from '@testing-library/user-event'; +import { componentMocks } from '../../../../../../../testing/componentMocks'; + +// Test data: +const mockComponent = componentMocks[ComponentType.Dropdown]; +mockComponent.optionsId = undefined; + +const handleComponentChange = jest.fn(); + +describe('AddManualOptionsModal', () => { + it('should display a button when no code list is defined in the layout', () => { + renderEditManualOptionsWithEditor(); + + expect( + screen.getByRole('button', { + name: textMock('general.create_new'), + }), + ).toBeInTheDocument(); + }); + + it('should open a modal when the trigger button is clicked', async () => { + const user = userEvent.setup(); + renderEditManualOptionsWithEditor(); + + await user.click(getOpenModalButton()); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('should call handleComponentChange when there has been a change in the editor', async () => { + const user = userEvent.setup(); + renderEditManualOptionsWithEditor(); + + await user.click(getOpenModalButton()); + await user.click(getAddNewOptionButton()); + + expect(handleComponentChange).toHaveBeenCalledTimes(1); + expect(handleComponentChange).toHaveBeenCalledWith({ + ...mockComponent, + options: [{ label: '', value: '' }], + }); + }); + + it('should delete optionsId from the layout when using the manual editor', async () => { + const user = userEvent.setup(); + renderEditManualOptionsWithEditor({ + componentProps: { + optionsId: 'somePredefinedOptionsList', + }, + }); + + await user.click(getOpenModalButton()); + await user.click(getAddNewOptionButton()); + + expect(handleComponentChange).toHaveBeenCalledWith({ + ...mockComponent, + options: [{ label: '', value: '' }], + }); + }); + + it('should call setChosenOption when closing modal', async () => { + const user = userEvent.setup(); + const mockSetComponentHasOptionList = jest.fn(); + const componentOptions = []; + renderEditManualOptionsWithEditor({ + setComponentHasOptionList: mockSetComponentHasOptionList, + componentProps: { options: componentOptions }, + }); + + await user.click(getOpenModalButton()); + + const closeButton = screen.getByRole('button', { + name: 'close modal', // Todo: Replace 'close modal' with textMock('settings_modal.close_button_label') when we upgrade to Designsystemet v1 + }); + await user.click(closeButton); + + expect(mockSetComponentHasOptionList).toHaveBeenCalledTimes(1); + expect(mockSetComponentHasOptionList).toHaveBeenCalledWith(true); + }); +}); + +function getOpenModalButton() { + return screen.getByRole('button', { + name: textMock('general.create_new'), + }); +} + +function getAddNewOptionButton() { + return screen.getByRole('button', { name: textMock('code_list_editor.add_option') }); +} + +type renderProps = { + componentProps?: Partial>; + setComponentHasOptionList?: () => void; +}; + +function renderEditManualOptionsWithEditor< + T extends ComponentType.Checkboxes | ComponentType.RadioButtons, +>({ componentProps = {}, setComponentHasOptionList = jest.fn() }: renderProps = {}) { + const component = { + ...mockComponent, + ...componentProps, + }; + renderWithProviders( + , + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx new file mode 100644 index 00000000000..cc88f91efe6 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/AddManualOptionsModal.tsx @@ -0,0 +1,64 @@ +import React, { useRef } from 'react'; +import { StudioCodeListEditor, StudioModal } from '@studio/components'; +import type { Option } from 'app-shared/types/Option'; +import { useTranslation } from 'react-i18next'; +import { useOptionListEditorTexts } from '../../hooks'; +import type { IGenericEditComponent } from '@altinn/ux-editor/components/config/componentConfig'; +import type { SelectionComponentType } from '@altinn/ux-editor/types/FormComponent'; +import classes from './AddManualOptionsModal.module.css'; + +export type EditManualOptionsWithEditorProps = { + setComponentHasOptionList: (value: boolean) => void; +} & Pick, 'component' | 'handleComponentChange'>; + +export function AddManualOptionsModal({ + setComponentHasOptionList, + component, + handleComponentChange, +}: EditManualOptionsWithEditorProps) { + const { t } = useTranslation(); + const manualOptionsModalRef = useRef(null); + const editorTexts = useOptionListEditorTexts(); + + const handleOptionsChange = (options: Option[]) => { + if (component.optionsId) { + delete component.optionsId; + } + + handleComponentChange({ + ...component, + options, + }); + }; + + const handleClose = () => { + if (component.options !== undefined) { + setComponentHasOptionList(true); + } + + manualOptionsModalRef.current?.close(); + }; + + return ( + + + {t('general.create_new')} + + + + + + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts new file mode 100644 index 00000000000..19896f313e3 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/AddManualOptionsModal/index.ts @@ -0,0 +1 @@ +export { AddManualOptionsModal } from './AddManualOptionsModal'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css new file mode 100644 index 00000000000..176985c67ee --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.module.css @@ -0,0 +1,25 @@ +.chosenOptionContainer { + display: flex; + overflow: hidden; +} + +.deleteButtonContainer { + display: flex; + width: min-content; + margin-left: auto; +} + +.deleteButton { + border-radius: 0; +} + +.optionButtons { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-2); + margin-left: var(--fds-spacing-5); +} + +.errorMessage { + margin: var(--fds-spacing-5) var(--fds-spacing-5) 0; +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx new file mode 100644 index 00000000000..ddfe00e2236 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { EditTab } from './EditTab'; +import { renderWithProviders } from '@altinn/ux-editor/testing/mocks'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import type { FormItem } from '@altinn/ux-editor/types/FormItem'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { componentMocks } from '@altinn/ux-editor/testing/componentMocks'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; + +// Test data: +const mockComponent = componentMocks[ComponentType.RadioButtons]; + +describe('EditTab', () => { + afterEach(() => jest.clearAllMocks()); + + it('should render DisplayChosenOption', async () => { + renderEditTab(); + expect( + screen.getByRole('button', { name: textMock('ux_editor.options.option_remove_text') }), + ).toBeInTheDocument(); + }); + + it('should render EditOptionList', async () => { + renderEditTab({ + componentProps: { + options: undefined, + }, + }); + + expect( + screen.getByRole('button', { name: textMock('ux_editor.options.upload_title') }), + ).toBeInTheDocument(); + }); + + it('should set optionsId to blank when removing choice', async () => { + const user = userEvent.setup(); + const handleOptionsIdChange = jest.fn(); + renderEditTab({ handleComponentChange: handleOptionsIdChange }); + const expectedArgs = mockComponent; + expectedArgs.optionsId = ''; + delete expectedArgs.options; + + const button = await screen.findByRole('button', { + name: textMock('ux_editor.options.option_remove_text'), + }); + await user.click(button); + + expect(handleOptionsIdChange).toHaveBeenCalledTimes(1); + expect(handleOptionsIdChange).toHaveBeenCalledWith(expectedArgs); + }); +}); + +type renderProps = { + componentProps?: Partial>; + handleComponentChange?: () => void; +}; + +function renderEditTab({ + componentProps = {}, + handleComponentChange = jest.fn(), +}: renderProps = {}) { + return renderWithProviders( + , + { + queryClient: createQueryClientMock(), + }, + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx new file mode 100644 index 00000000000..f6d935408a8 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/EditTab.tsx @@ -0,0 +1,112 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioDeleteButton, StudioErrorMessage } from '@studio/components'; +import { AddManualOptionsModal } from './AddManualOptionsModal'; +import { OptionListSelector } from './OptionListSelector'; +import { OptionListUploader } from './OptionListUploader'; +import { OptionListEditor } from './/OptionListEditor'; +import { useComponentErrorMessage } from '../../../../../../hooks'; +import type { IGenericEditComponent } from '../../../../componentConfig'; +import type { SelectionComponentType } from '../../../../../../types/FormComponent'; +import classes from './EditTab.module.css'; + +type EditOptionChoiceProps = Pick< + IGenericEditComponent, + 'component' | 'handleComponentChange' +>; + +export function EditTab({ + component, + handleComponentChange, +}: EditOptionChoiceProps): React.ReactElement { + const initialComponentHasOptionList: boolean = !!component.optionsId || !!component.options; + const [componentHasOptionList, setComponentHasOptionList] = useState( + initialComponentHasOptionList, + ); + const errorMessage = useComponentErrorMessage(component); + + return ( + <> + {componentHasOptionList ? ( + + ) : ( +
+ + + +
+ )} + {errorMessage && ( + + {errorMessage} + + )} + + ); +} + +type SelectedOptionListProps = { + setComponentHasOptionList: (value: boolean) => void; +} & Pick, 'component' | 'handleComponentChange'>; + +function SelectedOptionList({ + setComponentHasOptionList, + component, + handleComponentChange, +}: SelectedOptionListProps) { + const { t } = useTranslation(); + + const handleDelete = () => { + if (component.options) { + delete component.options; + } + + const emptyOptionsId = ''; + handleComponentChange({ + ...component, + optionsId: emptyOptionsId, + }); + + setComponentHasOptionList(false); + }; + + const label = + component.optionsId !== '' && component.optionsId !== undefined + ? component.optionsId + : t('ux_editor.modal_properties_code_list_custom_list'); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.module.css similarity index 61% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.module.css rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.module.css index bc34057c9db..ab917c70498 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/OptionListEditor.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.module.css @@ -1,16 +1,11 @@ .editOptionTabModal[open] { --code-list-modal-min-width: min(80rem, 100%); - --code-list-modal-height: min(40rem, 100%); + --code-list-modal-height: min(45rem, 100%); min-width: var(--code-list-modal-min-width); height: var(--code-list-modal-height); } -.modalTrigger { - margin-top: var(--fds-spacing-2); - width: fit-content; -} - .content { height: 100%; } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx new file mode 100644 index 00000000000..829ff80aa22 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.test.tsx @@ -0,0 +1,230 @@ +import React from 'react'; +import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import type { OptionsLists } from 'app-shared/types/api/OptionsLists'; +import type { Option } from 'app-shared/types/Option'; +import { OptionListEditor } from './OptionListEditor'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { renderWithProviders } from '../../../../../../../testing/mocks'; +import userEvent from '@testing-library/user-event'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { app, org } from '@studio/testing/testids'; +import { componentMocks } from '../../../../../../../testing/componentMocks'; +import { ComponentType } from 'app-shared/types/ComponentType'; + +// Test data: +const mockComponent = componentMocks[ComponentType.RadioButtons]; + +const apiResult: OptionsLists = { + options: [ + { value: 'test', label: 'label text', description: 'description', helpText: 'help text' }, + { value: 2, label: 'label number', description: null, helpText: null }, + { value: true, label: 'label boolean', description: null, helpText: null }, + ], +}; + +describe('OptionListEditor', () => { + afterEach(() => jest.clearAllMocks()); + + describe('ManualOptionListEditorModal', () => { + it('should render the open Dialog button', async () => { + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + expect(getManualModalButton()).toBeInTheDocument(); + }); + + it('should open Dialog', async () => { + const user = userEvent.setup(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + + await user.click(getManualModalButton()); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('should close Dialog', async () => { + const user = userEvent.setup(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + + await user.click(getManualModalButton()); + await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when we upgrade to Designsystemet v1 + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should call doReloadPreview when editing', async () => { + const user = userEvent.setup(); + const componentWithOptionsId = mockComponent; + componentWithOptionsId.optionsId = 'optionsID'; + const handleComponentChange = jest.fn(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ + handleComponentChange, + component: componentWithOptionsId, + }); + const text = 'test'; + + await user.click(getManualModalButton()); + const textBox = screen.getByRole('textbox', { + name: textMock('code_list_editor.description_item', { number: 2 }), + }); + await user.type(textBox, text); + await user.tab(); + + expect(handleComponentChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('LibraryOptionListEditorModal', () => { + beforeEach(() => { + mockComponent.optionsId = 'options'; + mockComponent.options = undefined; + }); + + it('should render a spinner when there is no data', () => { + renderOptionListEditor({ + queries: { + getOptionLists: jest.fn().mockImplementation(() => Promise.resolve({})), + }, + }); + + expect( + screen.getByText(textMock('ux_editor.modal_properties_code_list_spinner_title')), + ).toBeInTheDocument(); + }); + + it('should render an error message when getOptionLists throws an error', async () => { + await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ + queries: { + getOptionLists: jest.fn().mockRejectedValueOnce(new Error('Error')), + }, + }); + + expect( + screen.getByText(textMock('ux_editor.modal_properties_fetch_option_list_error_message')), + ).toBeInTheDocument(); + }); + + it('should render the open Dialog button', async () => { + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + expect(getOptionModalButton()).toBeInTheDocument(); + }); + + it('should open Dialog', async () => { + const user = userEvent.setup(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + + await user.click(getOptionModalButton()); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('should close Dialog', async () => { + const user = userEvent.setup(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + + await user.click(getOptionModalButton()); + await user.click(screen.getByRole('button', { name: 'close modal' })); // Todo: Replace "close modal" with defaultDialogProps.closeButtonTitle when we upgrade to Designsystemet v1 + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('should call doReloadPreview when editing', async () => { + const user = userEvent.setup(); + const doReloadPreview = jest.fn(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved({ + previewContextProps: { doReloadPreview }, + }); + + await user.click(getOptionModalButton()); + const textBox = screen.getByRole('textbox', { + name: textMock('code_list_editor.description_item', { number: 2 }), + }); + await user.type(textBox, 'test'); + await user.tab(); + + await waitFor(() => expect(doReloadPreview).toHaveBeenCalledTimes(1)); + }); + + it('should call updateOptionList with correct parameters when closing Dialog', async () => { + const user = userEvent.setup(); + await renderOptionListEditorAndWaitForSpinnerToBeRemoved(); + const expectedResultAfterEdit: Option[] = [ + { value: 'test', label: 'label text', description: 'description', helpText: 'help text' }, + { value: 2, label: 'label number', description: 'test', helpText: null }, + { value: true, label: 'label boolean', description: null, helpText: null }, + ]; + + await user.click(getOptionModalButton()); + const textBox = screen.getByRole('textbox', { + name: textMock('code_list_editor.description_item', { number: 2 }), + }); + await user.type(textBox, 'test'); + await user.tab(); + + await waitFor(() => expect(queriesMock.updateOptionList).toHaveBeenCalledTimes(1)); + expect(queriesMock.updateOptionList).toHaveBeenCalledWith( + org, + app, + mockComponent.optionsId, + expectedResultAfterEdit, + ); + }); + }); +}); + +function getOptionModalButton() { + return screen.getByRole('button', { + name: textMock('ux_editor.modal_properties_code_list_button_title_library'), + }); +} + +function getManualModalButton() { + return screen.getByRole('button', { + name: textMock('ux_editor.modal_properties_code_list_button_title_manual'), + }); +} + +const renderOptionListEditor = ({ + previewContextProps = {}, + queries = {}, + component = {}, + handleComponentChange = jest.fn(), +} = {}) => { + return renderWithProviders( + , + { + queries: { + getOptionLists: jest + .fn() + .mockImplementation(() => Promise.resolve(apiResult)), + ...queries, + }, + queryClient: createQueryClientMock(), + previewContextProps, + }, + ); +}; + +const renderOptionListEditorAndWaitForSpinnerToBeRemoved = async ({ + previewContextProps = {}, + queries = { + getOptionLists: jest.fn().mockImplementation(() => Promise.resolve(apiResult)), + }, + component = {}, + handleComponentChange = jest.fn(), +} = {}) => { + const view = renderOptionListEditor({ + previewContextProps, + queries, + component, + handleComponentChange, + }); + await waitForElementToBeRemoved(() => { + return screen.queryByText(textMock('ux_editor.modal_properties_code_list_spinner_title')); + }); + return view; +}; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx new file mode 100644 index 00000000000..2f581ec6407 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx @@ -0,0 +1,184 @@ +import React, { createRef, useRef, useState } from 'react'; +import type { Option } from 'app-shared/types/Option'; +import { useTranslation } from 'react-i18next'; +import { + StudioCodeListEditor, + StudioModal, + StudioSpinner, + StudioErrorMessage, + StudioAlert, + StudioProperty, +} from '@studio/components'; +import type { CodeListEditorTexts } from '@studio/components'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { useUpdateOptionListMutation } from 'app-shared/hooks/mutations/useUpdateOptionListMutation'; +import { useOptionListsQuery } from 'app-shared/hooks/queries/useOptionListsQuery'; +import { useOptionListEditorTexts } from '../../hooks/useOptionListEditorTexts'; +import { usePreviewContext } from 'app-development/contexts/PreviewContext'; +import type { IGenericEditComponent } from '../../../../../componentConfig'; +import type { SelectionComponentType } from '../../../../../../../types/FormComponent'; +import classes from './OptionListEditor.module.css'; + +type OptionListEditorProps = { + optionsId: string; + label: string; +} & Pick, 'component' | 'handleComponentChange'>; + +export function OptionListEditor({ + optionsId, + component, + handleComponentChange, + label, +}: OptionListEditorProps): React.ReactNode { + const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); + const { data: optionsLists, status } = useOptionListsQuery(org, app); + + switch (status) { + case 'pending': + return ( + + ); + case 'error': + return ( + + {t('ux_editor.modal_properties_fetch_option_list_error_message')} + + ); + case 'success': { + if (optionsLists[optionsId] !== undefined) { + return ( + + ); + } + if (component.options !== undefined) { + return ( + + ); + } + } + } +} + +type EditLibraryOptionListEditorModalProps = { + label: string; + optionsId: string; + optionsList: Option[]; +}; + +function EditLibraryOptionListEditorModal({ + label, + optionsId, + optionsList, +}: EditLibraryOptionListEditorModalProps): React.ReactNode { + const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); + const { doReloadPreview } = usePreviewContext(); + const { mutate: updateOptionList } = useUpdateOptionListMutation(org, app); + const [localOptionList, setLocalOptionList] = useState(optionsList); + const editorTexts: CodeListEditorTexts = useOptionListEditorTexts(); + const modalRef = createRef(); + + const optionListHasChanged = (options: Option[]): boolean => + JSON.stringify(options) !== JSON.stringify(localOptionList); + + const handleOptionsChange = (options: Option[]) => { + if (optionListHasChanged(options)) { + updateOptionList({ optionListId: optionsId, optionsList: options }); + setLocalOptionList(options); + doReloadPreview(); + } + }; + + const handleClose = () => { + modalRef.current?.close(); + }; + + return ( + <> + modalRef.current.showModal()} + /> + + {t('ux_editor.modal_properties_code_list_alert_title')} + + } + > + + + + ); +} + +type EditManualOptionListEditorModalProps = { + label: string; +} & Pick, 'component' | 'handleComponentChange'>; + +function EditManualOptionListEditorModal({ + label, + component, + handleComponentChange, +}: EditManualOptionListEditorModalProps): React.ReactNode { + const { t } = useTranslation(); + const modalRef = useRef(null); + const editorTexts = useOptionListEditorTexts(); + + const handleOptionsChange = (options: Option[]) => { + if (component.optionsId) { + delete component.optionsId; + } + + handleComponentChange({ + ...component, + options, + }); + }; + + return ( + <> + modalRef.current.showModal()} + /> + + + + + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/index.ts new file mode 100644 index 00000000000..4033b99c295 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/index.ts @@ -0,0 +1 @@ +export { OptionListEditor } from './OptionListEditor'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css new file mode 100644 index 00000000000..a46035a926a --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.module.css @@ -0,0 +1,4 @@ +.modalTrigger { + width: 50%; + min-width: 12rem; +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx new file mode 100644 index 00000000000..12efb70d51f --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.test.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { OptionListSelector } from './OptionListSelector'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import userEvent from '@testing-library/user-event'; +import { componentMocks } from '../../../../../../../testing/componentMocks'; +import { renderWithProviders, optionListIdsMock } from '../../../../../../../testing/mocks'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import type { FormComponent } from '../../../../../../../types/FormComponent'; + +// Test data: +const mockComponent: FormComponent = componentMocks[ComponentType.Dropdown]; +const optionsIdMock = optionListIdsMock[0]; +mockComponent.optionsId = optionsIdMock; + +const handleComponentChangeMock = jest.fn(); +const getOptionListIds = jest + .fn() + .mockImplementation(() => Promise.resolve(optionListIdsMock)); + +describe('OptionListSelector', () => { + it('should render the component', async () => { + renderOptionListSelector(); + await waitForElementToBeRemoved( + screen.queryByText(textMock('ux_editor.modal_properties_loading')), + ); + + expect(screen.getByText(textMock('ux_editor.modal_properties_code_list'))).toBeInTheDocument(); + }); + + it('should call onChange when option list changes', async () => { + const user = userEvent.setup(); + renderOptionListSelector(); + await waitForElementToBeRemoved( + screen.queryByText(textMock('ux_editor.modal_properties_loading')), + ); + + await user.click(getDropdownButton()); + await user.click(getDropdownOption()); + + expect(handleComponentChangeMock).toHaveBeenCalledTimes(1); + }); + + it('should remove options property (if it exists) when optionsId property changes', async () => { + const user = userEvent.setup(); + renderOptionListSelector({ + componentProps: { + options: [{ label: 'option1', value: 'option1' }], + }, + }); + await waitForElementToBeRemoved( + screen.queryByText(textMock('ux_editor.modal_properties_loading')), + ); + + await user.click(getDropdownButton()); + await user.click(getDropdownOption()); + + expect(handleComponentChangeMock).toHaveBeenCalledWith({ + ...mockComponent, + options: undefined, + optionsId: 'test-1', + }); + }); + + it('should render returned error message if option list endpoint returns an error', async () => { + renderOptionListSelector({ + queries: { + getOptionListIds: jest.fn().mockImplementation(() => Promise.reject(new Error('Error'))), + }, + }); + + expect(await screen.findByText('Error')).toBeInTheDocument(); + }); + + it('should render standard error message if option list endpoint throws an error without specified error message', async () => { + renderOptionListSelector({ + queries: { + getOptionListIds: jest.fn().mockImplementation(() => Promise.reject()), + }, + }); + + expect( + await screen.findByText(textMock('ux_editor.modal_properties_error_message')), + ).toBeInTheDocument(); + }); +}); + +function getDropdownButton(): HTMLElement { + return screen.getByRole('button', { name: textMock('ux_editor.modal_properties_code_list') }); +} + +function getDropdownOption(): HTMLElement { + return screen.getByText(optionListIdsMock[0]); +} + +function renderOptionListSelector({ queries = {}, componentProps = {} } = {}) { + return renderWithProviders( + , + { + queries: { getOptionListIds, ...queries }, + queryClient: createQueryClientMock(), + }, + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx new file mode 100644 index 00000000000..dcc1657473d --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/OptionListSelector.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { ErrorMessage } from '@digdir/designsystemet-react'; +import type { IGenericEditComponent } from '../../../../../componentConfig'; +import type { SelectionComponentType } from '../../../../../../../types/FormComponent'; +import { useOptionListIdsQuery } from '../../../../../../../hooks/queries/useOptionListIdsQuery'; +import { useTranslation } from 'react-i18next'; +import { StudioDropdownMenu, StudioSpinner } from '@studio/components'; +import { BookIcon } from '@studio/icons'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import classes from './OptionListSelector.module.css'; + +type OptionListSelectorProps = { + setComponentHasOptionList: (value: boolean) => void; +} & Pick, 'component' | 'handleComponentChange'>; + +export function OptionListSelector({ + setComponentHasOptionList, + component, + handleComponentChange, +}: OptionListSelectorProps): React.ReactNode { + const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); + const { data: optionListIds, status, error } = useOptionListIdsQuery(org, app); + + const handleOptionsIdChange = (optionsId: string) => { + if (component.options) { + delete component.options; + } + + handleComponentChange({ + ...component, + optionsId, + }); + + setComponentHasOptionList(true); + }; + + switch (status) { + case 'pending': + return ( + + ); + case 'error': + return ( + + {error instanceof Error ? error.message : t('ux_editor.modal_properties_error_message')} + + ); + case 'success': + return ( + + ); + } +} + +type OptionListSelectorWithDataProps = { + optionListIds: string[]; + handleOptionsIdChange: (optionsId: string) => void; +}; + +function OptionListSelectorWithData({ + optionListIds, + handleOptionsIdChange, +}: OptionListSelectorWithDataProps): React.ReactNode { + const { t } = useTranslation(); + + if (!optionListIds.length) return null; + return ( + + {optionListIds.map((optionListId: string) => ( + } + onClick={() => handleOptionsIdChange(optionListId)} + > + {optionListId} + + ))} + + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/index.ts new file mode 100644 index 00000000000..c57489cb9f6 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListSelector/index.ts @@ -0,0 +1 @@ +export { OptionListSelector } from './OptionListSelector'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx new file mode 100644 index 00000000000..a20e423ba6c --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { OptionListUploader } from './OptionListUploader'; +import { screen } from '@testing-library/react'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import userEvent from '@testing-library/user-event'; +import { componentMocks } from '../../../../../../../testing/componentMocks'; +import { renderWithProviders, optionListIdsMock } from '../../../../../../../testing/mocks'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; + +// Test data: +const mockComponent = componentMocks[ComponentType.RadioButtons]; +const optionsIdMock = optionListIdsMock[0]; +mockComponent.optionsId = optionsIdMock; + +const handleComponentChangeMock = jest.fn(); +const getOptionListIds = jest + .fn() + .mockImplementation(() => Promise.resolve(optionListIdsMock)); + +describe('OptionListUploader', () => { + it('should render the component', () => { + renderEditOptionList(); + expect(screen.getByText(textMock('ux_editor.options.upload_title'))).toBeInTheDocument(); + }); + + it('should render success toast if file upload is successful', async () => { + const user = userEvent.setup(); + const file = new File(['hello'], 'hello.json', { type: 'text/json' }); + renderEditOptionList(); + + await user.click(getUploadButton()); + await user.upload(getFileInput(), file); + + expect(await screen.findByRole('alert')).toHaveTextContent( + textMock('ux_editor.modal_properties_code_list_upload_success'), + ); + }); + + it('should render error toast if file already exists', async () => { + const user = userEvent.setup(); + const file = new File([optionListIdsMock[0]], optionListIdsMock[0] + '.json', { + type: 'text/json', + }); + renderEditOptionList(); + + await user.click(getUploadButton()); + await user.upload(getFileInput(), file); + + expect(await screen.findByRole('alert')).toHaveTextContent( + textMock('validation_errors.upload_file_name_occupied'), + ); + }); + + it('should render alert on invalid file name', async () => { + const user = userEvent.setup(); + const invalidFileName = '_InvalidFileName.json'; + const file = new File([optionListIdsMock[0]], invalidFileName, { + type: 'text/json', + }); + renderEditOptionList(); + + await user.click(getUploadButton()); + await user.upload(getFileInput(), file); + + expect(await screen.findByRole('alert')).toHaveTextContent( + textMock('validation_errors.file_name_occupied'), + ); + }); +}); + +function getUploadButton() { + return screen.getByRole('button', { name: textMock('ux_editor.options.upload_title') }); +} + +function getFileInput() { + return screen.getByLabelText(textMock('ux_editor.options.upload_title')); +} + +function renderEditOptionList({ queries = {}, componentProps = {} } = {}) { + return renderWithProviders( + , + { + queries: { getOptionListIds, ...queries }, + queryClient: createQueryClientMock(), + }, + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.tsx new file mode 100644 index 00000000000..0130b29e8e7 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/OptionListUploader.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import type { IGenericEditComponent } from '../../../../../componentConfig'; +import type { SelectionComponentType } from '../../../../../../../types/FormComponent'; +import { useOptionListIdsQuery } from '../../../../../../../hooks/queries/useOptionListIdsQuery'; +import { useAddOptionListMutation } from 'app-shared/hooks/mutations'; +import { useTranslation } from 'react-i18next'; +import { StudioFileUploader } from '@studio/components'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { FileNameUtils } from '@studio/pure-functions'; +import { findFileNameError } from './utils/findFileNameError'; +import type { FileNameError } from './utils/findFileNameError'; +import type { AxiosError } from 'axios'; +import type { ApiError } from 'app-shared/types/api/ApiError'; +import { toast } from 'react-toastify'; + +type EditOptionListProps = { + setComponentHasOptionList: (value: boolean) => void; +} & Pick, 'component' | 'handleComponentChange'>; + +export function OptionListUploader({ + setComponentHasOptionList, + component, + handleComponentChange, +}: EditOptionListProps) { + const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); + const { data: optionListIds } = useOptionListIdsQuery(org, app); + const { mutate: uploadOptionList } = useAddOptionListMutation(org, app, { + hideDefaultError: (error: AxiosError) => !error.response.data.errorCode, + }); + + const handleOptionsIdChange = (optionsId: string) => { + if (component.options) { + delete component.options; + } + + handleComponentChange({ + ...component, + optionsId, + }); + + setComponentHasOptionList(true); + }; + + const onSubmit = (file: File) => { + const fileNameError = findFileNameError(optionListIds, file.name); + if (fileNameError) { + handleInvalidFileName(fileNameError); + } else { + handleUpload(file); + } + }; + + const handleUpload = (file: File) => { + uploadOptionList(file, { + onSuccess: () => { + handleOptionsIdChange(FileNameUtils.removeExtension(file.name)); + toast.success(t('ux_editor.modal_properties_code_list_upload_success')); + }, + onError: (error: AxiosError) => { + if (!error.response?.data?.errorCode) { + toast.error(`${t('ux_editor.modal_properties_code_list_upload_generic_error')}`); + } + }, + }); + }; + + const handleInvalidFileName = (fileNameError: FileNameError) => { + switch (fileNameError) { + case 'invalidFileName': + return toast.error(t('validation_errors.file_name_occupied')); + case 'fileExists': + return toast.error(t('validation_errors.upload_file_name_occupied')); + } + }; + + return ( + + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/index.ts new file mode 100644 index 00000000000..3fe3633974f --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/index.ts @@ -0,0 +1 @@ +export { OptionListUploader } from './OptionListUploader'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.test.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.test.ts new file mode 100644 index 00000000000..108f40ccc63 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.test.ts @@ -0,0 +1,21 @@ +import { findFileNameError } from './findFileNameError'; + +const optionListIdOne = 'one'; +const optionLists: string[] = [optionListIdOne]; + +const validFilename = 'two.json'; +const invalidFilename = '_InvalidFileName.json'; + +describe('findFileNameError', () => { + it('should return null for valid filename', () => { + expect(findFileNameError(optionLists, validFilename)).toBe(null); + }); + + it('should return "invalidFileName" for invalid filename', () => { + expect(findFileNameError(optionLists, invalidFilename)).toBe('invalidFileName'); + }); + + it('should return "fileExists" for duplicate filename', () => { + expect(findFileNameError(optionLists, optionListIdOne + '.json')).toBe('fileExists'); + }); +}); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.ts new file mode 100644 index 00000000000..f13182f7336 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListUploader/utils/findFileNameError.ts @@ -0,0 +1,29 @@ +import { FileNameUtils } from '@studio/pure-functions'; + +export type FileNameError = 'invalidFileName' | 'fileExists'; + +export const findFileNameError = ( + optionListIds: string[], + fileName: string, +): FileNameError | null => { + const fileNameWithoutExtension = FileNameUtils.removeExtension(fileName); + + if (!isFilenameValid(fileNameWithoutExtension)) { + return 'invalidFileName'; + } else if (isFileNameDuplicate(optionListIds, fileNameWithoutExtension)) { + return 'fileExists'; + } else { + return null; + } +}; + +const isFilenameValid = (fileName: string): boolean => { + return Boolean(fileName.match(/^[a-zA-Z][a-zA-Z0-9_.\-æÆøØåÅ ]*$/)); +}; + +const isFileNameDuplicate = ( + optionListIds: string[], + fileNameWithoutExtension: string, +): boolean => { + return optionListIds.some((option) => option === fileNameWithoutExtension); +}; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/index.ts new file mode 100644 index 00000000000..535c16bae15 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/index.ts @@ -0,0 +1 @@ +export { EditTab } from './EditTab'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/EditOption.module.css similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.module.css rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/EditOption.module.css diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/EditOption.test.tsx similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/EditOption.test.tsx diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/EditOption.tsx similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/EditOption.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/EditOption.tsx diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/OptionValue/OptionValue.module.css similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.module.css rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/OptionValue/OptionValue.module.css diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/OptionValue/OptionValue.tsx similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/OptionValue.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/OptionValue/OptionValue.tsx diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/OptionValue/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/OptionValue/index.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/OptionValue/index.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/index.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/index.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/index.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.test.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/utils.test.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.test.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/utils.test.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/utils.ts similarity index 100% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditOption/utils.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/EditOption/utils.ts diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.module.css new file mode 100644 index 00000000000..c2fb6d16130 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.module.css @@ -0,0 +1,3 @@ +.errorMessage { + margin: var(--fds-spacing-5) var(--fds-spacing-5) 0; +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.test.tsx similarity index 74% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.test.tsx index 4e84a057ade..20732d697e5 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { EditManualOptions } from './EditManualOptions'; +import { ManualTab } from './ManualTab'; import { renderWithProviders } from '../../../../../../testing/mocks'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { ComponentType } from 'app-shared/types/ComponentType'; @@ -8,38 +8,19 @@ import type { FormItem } from '../../../../../../types/FormItem'; import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import type { FormComponent } from '../../../../../../types/FormComponent'; import userEvent from '@testing-library/user-event'; +import { componentMocks } from '@altinn/ux-editor/testing/componentMocks'; -const mockComponent: FormComponent = { - id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96', - type: ComponentType.RadioButtons, - textResourceBindings: { - title: 'ServiceName', - }, - maxLength: 10, - itemType: 'COMPONENT', - dataModelBindings: { simpleBinding: '' }, -}; - -const renderEditManualOptions = async < - T extends ComponentType.Checkboxes | ComponentType.RadioButtons, ->({ - componentProps, - handleComponentChange = jest.fn(), -}: { - componentProps?: Partial>; - handleComponentChange?: () => void; - queries?: Partial; -} = {}) => { - const component = { - ...mockComponent, - ...componentProps, - }; - renderWithProviders( - , - ); -}; +// Test data: +const mockComponent: FormComponent = + componentMocks[ComponentType.RadioButtons]; +mockComponent.optionsId = undefined; +mockComponent.options = undefined; + +const handleComponentChange = jest.fn(); + +describe('ManualTab', () => { + afterEach(() => jest.clearAllMocks()); -describe('EditManualOptions', () => { it('should show manual input when component has options defined', async () => { renderEditManualOptions({ componentProps: { @@ -60,9 +41,7 @@ describe('EditManualOptions', () => { }); it('should call handleComponentUpdate when adding a new option', async () => { - const handleComponentChangeMock = jest.fn(); renderEditManualOptions({ - handleComponentChange: handleComponentChangeMock, componentProps: { options: [{ label: 'oldOption', value: 'oldOption' }], }, @@ -72,7 +51,7 @@ describe('EditManualOptions', () => { name: textMock('ux_editor.modal_new_option'), }); addOptionButton.click(); - expect(handleComponentChangeMock).toHaveBeenCalledWith({ + expect(handleComponentChange).toHaveBeenCalledWith({ ...mockComponent, options: [ { label: 'oldOption', value: 'oldOption' }, @@ -83,9 +62,7 @@ describe('EditManualOptions', () => { it('should call handleComponentUpdate when removing an option', async () => { const user = userEvent.setup(); - const handleComponentChangeMock = jest.fn(); renderEditManualOptions({ - handleComponentChange: handleComponentChangeMock, componentProps: { options: [ { label: 'option1', value: 'option1' }, @@ -102,16 +79,14 @@ describe('EditManualOptions', () => { name: textMock('general.delete'), }); await user.click(removeOptionButton); - expect(handleComponentChangeMock).toHaveBeenCalledWith({ + expect(handleComponentChange).toHaveBeenCalledWith({ ...mockComponent, options: [{ label: 'option1', value: 'option1' }], }); }); it('should handle adding new option even if options property has not been set', async () => { - const handleComponentChangeMock = jest.fn(); renderEditManualOptions({ - handleComponentChange: handleComponentChangeMock, componentProps: { options: undefined, }, @@ -121,16 +96,14 @@ describe('EditManualOptions', () => { name: textMock('ux_editor.modal_new_option'), }); addOptionButton.click(); - expect(handleComponentChangeMock).toHaveBeenCalledWith({ + expect(handleComponentChange).toHaveBeenCalledWith({ ...mockComponent, options: [{ label: expect.any(String), value: expect.any(String) }], }); }); it('should delete optionsId property if it exists when adding a new option', async () => { - const handleComponentChangeMock = jest.fn(); renderEditManualOptions({ - handleComponentChange: handleComponentChangeMock, componentProps: { optionsId: 'testId', }, @@ -140,7 +113,7 @@ describe('EditManualOptions', () => { name: textMock('ux_editor.modal_new_option'), }); addOptionButton.click(); - expect(handleComponentChangeMock).toHaveBeenCalledWith({ + expect(handleComponentChange).toHaveBeenCalledWith({ ...mockComponent, options: [{ label: expect.any(String), value: expect.any(String) }], }); @@ -148,9 +121,7 @@ describe('EditManualOptions', () => { it('should call handleComponentUpdate when changing an option', async () => { const user = userEvent.setup(); - const handleComponentChangeMock = jest.fn(); renderEditManualOptions({ - handleComponentChange: handleComponentChangeMock, componentProps: { options: [{ label: 'option1', value: 'option1' }], }, @@ -164,9 +135,24 @@ describe('EditManualOptions', () => { name: textMock('general.value'), }); await user.type(textField, 'a'); - expect(handleComponentChangeMock).toHaveBeenCalledWith({ + expect(handleComponentChange).toHaveBeenCalledWith({ ...mockComponent, options: [{ label: 'option1', value: 'option1a' }], }); }); }); + +function renderEditManualOptions({ + componentProps, +}: { + componentProps?: Partial>; + queries?: Partial; +} = {}) { + const component = { + ...mockComponent, + ...componentProps, + }; + renderWithProviders( + , + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.tsx similarity index 50% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.tsx index cc9d1222172..d170844df21 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditManualOptions/EditManualOptions.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/ManualTab.tsx @@ -1,20 +1,23 @@ import React, { useMemo } from 'react'; import type { IGenericEditComponent } from '../../../../componentConfig'; import { addOptionToComponent, generateRandomOption } from '../../../../../../utils/component'; -import { StudioProperty } from '@studio/components'; +import { StudioErrorMessage, StudioProperty } from '@studio/components'; import type { SelectionComponentType } from '../../../../../../types/FormComponent'; import { EditOption } from './EditOption'; import { ArrayUtils } from '@studio/pure-functions'; import type { Option } from 'app-shared/types/Option'; import { useTranslation } from 'react-i18next'; +import { useComponentErrorMessage } from '../../../../../../hooks'; +import classes from './ManualTab.module.css'; -export type EditManualOptionsProps = Pick< +export type ManualTabProps = Pick< IGenericEditComponent, 'component' | 'handleComponentChange' >; -export function EditManualOptions({ component, handleComponentChange }: EditManualOptionsProps) { +export function ManualTab({ component, handleComponentChange }: ManualTabProps) { const { t } = useTranslation(); + const errorMessage = useComponentErrorMessage(component); const mappedOptionIds = useMemo( () => component.options?.map((_, index) => `option_${index}`), @@ -48,30 +51,37 @@ export function EditManualOptions({ component, handleComponentChange }: EditManu }; return ( - - {component.options?.map((option, index) => { - const removeItem = () => handleRemoveOption(index); - const key = mappedOptionIds[index]; - const optionNumber = index + 1; - const legend = - component.type === 'RadioButtons' - ? t('ux_editor.radios_option', { optionNumber }) - : t('ux_editor.checkboxes_option', { optionNumber }); - return ( - - ); - })} - !label)} - onClick={handleAddOption} - property={t('ux_editor.modal_new_option')} - /> - + <> + + {component.options?.map((option, index) => { + const removeItem = () => handleRemoveOption(index); + const key = mappedOptionIds[index]; + const optionNumber = index + 1; + const legend = + component.type === 'RadioButtons' + ? t('ux_editor.radios_option', { optionNumber }) + : t('ux_editor.checkboxes_option', { optionNumber }); + return ( + + ); + })} + !label)} + onClick={handleAddOption} + property={t('ux_editor.modal_new_option')} + /> + + {errorMessage && ( + + {errorMessage} + + )} + ); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/index.ts new file mode 100644 index 00000000000..56cee7e6403 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ManualTab/index.ts @@ -0,0 +1 @@ +export { ManualTab } from './ManualTab'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.module.css index e73616a403e..358e83d652e 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.module.css @@ -1,19 +1,10 @@ -.errorMessage { - margin: var(--fds-spacing-5) var(--fds-spacing-5) 0; -} - -.codelistTabContent { - padding: var(--fds-spacing-5); +.tabContent { + padding: var(--fds-spacing-5) 0; display: flex; flex-direction: column; gap: var(--fds-spacing-2); } -.manualTabContent { - padding-block: var(--fds-spacing-5); - padding-inline: 0; -} - -.manualTabAlert { +.tabAlert { margin-inline: var(--fds-spacing-5); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx new file mode 100644 index 00000000000..82a461ac212 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { ComponentType } from 'app-shared/types/ComponentType'; +import type { FormItem } from '../../../../../types/FormItem'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../../../../testing/mocks'; +import { OptionTabs } from './OptionTabs'; +import { componentMocks } from '../../../../../testing/componentMocks'; +import { addFeatureFlagToLocalStorage } from 'app-shared/utils/featureToggleUtils'; + +// Test data: +const mockComponent = componentMocks[ComponentType.RadioButtons]; + +describe('EditOptions', () => { + afterEach(() => jest.clearAllMocks()); + + it('should render component', () => { + renderEditOptions(); + expect(screen.getByText(textMock('ux_editor.options.tab_code_list'))).toBeInTheDocument(); + }); + + it('should show code list input by default when neither options nor optionId are set', () => { + renderEditOptions({ + componentProps: { options: undefined, optionsId: undefined }, + }); + expect( + screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_code_list'), + }), + ).toBeInTheDocument(); + }); + + it('should show code list tab when component has optionsId defined matching an optionId in optionsID-list', () => { + const optionsId = 'optionsId'; + renderEditOptions({ + componentProps: { + optionsId, + options: undefined, + }, + optionListIds: [optionsId], + }); + + expect( + screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_code_list'), + }), + ).toBeInTheDocument(); + }); + + it('should show referenceId tab when component has optionsId defined not matching an optionId in optionsId-list', () => { + const optionsId = 'optionsId'; + renderEditOptions({ + componentProps: { + optionsId, + options: undefined, + }, + optionListIds: [], + }); + + expect( + screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_referenceId'), + }), + ).toBeInTheDocument(); + }); + + it('should switch to code list tab when clicking code list tab', async () => { + const user = userEvent.setup(); + const optionsId = 'optionsId'; + renderEditOptions({ + componentProps: { + optionsId, + }, + optionListIds: [], + }); + + expect( + screen.queryByRole('tab', { + name: textMock('ux_editor.options.tab_code_list'), + selected: true, + }), + ).not.toBeInTheDocument(); + + const codeListTabElement = screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_code_list'), + }); + await user.click(codeListTabElement); + + expect( + screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_code_list'), + }), + ).toBeInTheDocument(); + }); + + it('should switch to referenceId input clicking referenceId tab', async () => { + const user = userEvent.setup(); + renderEditOptions({ + componentProps: { options: [] }, + }); + + const referenceIdElement = screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_referenceId'), + }); + await user.click(referenceIdElement); + + expect( + screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_referenceId'), + }), + ).toBeInTheDocument(); + }); + + it('should render EditOptionChoice when featureFlag is enabled', async () => { + addFeatureFlagToLocalStorage('optionListEditor'); + const optionsId = 'optionsId'; + renderEditOptions({ + componentProps: { + optionsId, + options: undefined, + }, + optionListIds: [optionsId], + }); + + expect( + await screen.findByRole('button', { name: textMock('ux_editor.options.option_remove_text') }), + ).toBeInTheDocument(); + }); + + it('should switch to referenceId input clicking referenceId tab', async () => { + addFeatureFlagToLocalStorage('optionListEditor'); + const user = userEvent.setup(); + renderEditOptions({ + componentProps: { options: [] }, + }); + + const referenceIdElement = screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_referenceId'), + }); + await user.click(referenceIdElement); + + expect( + screen.getByRole('tab', { + name: textMock('ux_editor.options.tab_referenceId'), + }), + ).toBeInTheDocument(); + }); +}); + +type renderEditOptionsProps = { + componentProps?: Partial>; + handleComponentChange?: () => void; + queries?: Partial; + optionListIds?: string[]; +}; + +function renderEditOptions({ + componentProps = {}, + handleComponentChange = jest.fn(), + queries = {}, + optionListIds = [], +}: renderEditOptionsProps = {}) { + return renderWithProviders( + , + { + queries, + queryClient: createQueryClientMock(), + }, + ); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx index fd4bdf17960..3c1dafe76c8 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx @@ -1,31 +1,50 @@ -import { getSelectedOptionsType } from '@altinn/ux-editor/utils/optionsUtils'; import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import classes from './OptionTabs.module.css'; -import { EditOptionList, EditOptionListReference } from './EditOptionList'; -import { SelectedOptionsType } from '@altinn/ux-editor/components/config/editModal/EditOptions/EditOptions'; +import { StudioTabs } from '@studio/components'; +import { ReferenceTab } from './ReferenceTab/ReferenceTab'; import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; -import { EditManualOptionsWithEditor } from './EditManualOptionsWithEditor'; -import { EditManualOptions } from './EditManualOptions'; -import { StudioTabs, StudioAlert, StudioErrorMessage } from '@studio/components'; -import { useComponentErrorMessage } from '@altinn/ux-editor/hooks'; -import type { IGenericEditComponent } from '@altinn/ux-editor/components/config/componentConfig'; -import type { SelectionComponentType } from '@altinn/ux-editor/types/FormComponent'; +import { ManualTab } from './ManualTab'; +import { EditTab } from './EditTab'; +import { SelectedOptionsType } from '../EditOptions'; +import type { IGenericEditComponent } from '../../../componentConfig'; +import type { SelectionComponentType } from '../../../../../types/FormComponent'; +import classes from './OptionTabs.module.css'; +import { SelectTab } from './SelectTab'; +import { + getSelectedOptionsTypeWithManualSupport, + getSelectedOptionsType, +} from './utils/optionsUtils'; type OptionTabsProps = { optionListIds: string[]; - renderOptions?: { - areLayoutOptionsSupported?: boolean; - }; } & Pick, 'component' | 'handleComponentChange'>; -export const OptionTabs = ({ +export function OptionTabs({ component, handleComponentChange, optionListIds }: OptionTabsProps) { + return ( + <> + {shouldDisplayFeature('optionListEditor') ? ( + + ) : ( + + )} + + ); +} + +function OptionTabsMergedTabs({ component, handleComponentChange, optionListIds, - renderOptions, -}: OptionTabsProps) => { - const initialSelectedOptionsType = getSelectedOptionsType( +}: OptionTabsProps) { + const initialSelectedOptionsType = getSelectedOptionsTypeWithManualSupport( component.optionsId, component.options, optionListIds || [], @@ -54,74 +73,67 @@ export const OptionTabs = ({ {t('ux_editor.options.tab_code_list')} - - {t('ux_editor.options.tab_manual')} - {t('ux_editor.options.tab_referenceId')} - - + + - - - - - + + ); -}; - -type RenderManualOptionsProps = { - areLayoutOptionsSupported: boolean; -} & Pick, 'component' | 'handleComponentChange'>; +} -const RenderManualOptions = ({ - component, - handleComponentChange, - areLayoutOptionsSupported, -}: RenderManualOptionsProps) => { - const errorMessage = useComponentErrorMessage(component); +// Todo: Remove once featureFlag "optionListEditor" is removed. +function OptionTabsSplitTabs({ component, handleComponentChange, optionListIds }: OptionTabsProps) { + const initialSelectedOptionsType = getSelectedOptionsTypeWithManualSupport( + component.optionsId, + component.options, + optionListIds || [], + ); + const [selectedOptionsType, setSelectedOptionsType] = useState(initialSelectedOptionsType); const { t } = useTranslation(); - if (areLayoutOptionsSupported === false) { - return ( - - {t('ux_editor.options.code_list_only')} - + useEffect(() => { + const updatedSelectedOptionsType = getSelectedOptionsTypeWithManualSupport( + component.optionsId, + component.options, + optionListIds, ); - } + setSelectedOptionsType(updatedSelectedOptionsType); + }, [optionListIds, component.optionsId, component.options, setSelectedOptionsType]); return ( - <> - {shouldDisplayFeature('optionListEditor') ? ( - - ) : ( - - )} - {errorMessage && ( - - {errorMessage} - - )} - + { + setSelectedOptionsType(value as SelectedOptionsType); + }} + > + + + {t('ux_editor.options.tab_code_list')} + + + {t('ux_editor.options.tab_manual')} + + + {t('ux_editor.options.tab_referenceId')} + + + + + + + + + + + + ); -}; +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.module.css new file mode 100644 index 00000000000..1395b55cc9f --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.module.css @@ -0,0 +1,3 @@ +.container { + padding-inline: var(--fds-spacing-5); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionListReference.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.test.tsx similarity index 76% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionListReference.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.test.tsx index 229f11e9401..9bbd928027d 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionListReference.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.test.tsx @@ -1,53 +1,25 @@ import React from 'react'; import { screen } from '@testing-library/react'; -import { EditOptionListReference } from './EditOptionListReference'; +import { ReferenceTab } from './ReferenceTab'; import { renderWithProviders } from '../../../../../../testing/mocks'; import { ComponentType } from 'app-shared/types/ComponentType'; import type { FormComponent } from '../../../../../../types/FormComponent'; import { textMock } from '@studio/testing/mocks/i18nMock'; import userEvent from '@testing-library/user-event'; +import { componentMocks } from '../../../../../../testing/componentMocks'; -const mockComponent: FormComponent = { - id: 'c24d0812-0c34-4582-8f31-ff4ce9795e96', - type: ComponentType.RadioButtons, - textResourceBindings: { - title: 'ServiceName', - }, - maxLength: 10, - itemType: 'COMPONENT', - dataModelBindings: { simpleBinding: '' }, -}; - -const renderEditOptionListReference = ({ - handleComponentChange = jest.fn(), - componentProps = {}, -}: { - handleComponentChange?: () => void; - componentProps?: Partial< - FormComponent - >; -} = {}) => { - renderWithProviders( - , - ); -}; +const mockComponent = componentMocks[ComponentType.Dropdown]; -describe('EditOptionListReference', () => { +describe('ReferenceTab', () => { it('should render', () => { - renderEditOptionListReference(); + renderReferenceTab(); expect( screen.getByText(textMock('ux_editor.options.code_list_referenceId.description')), ).toBeInTheDocument(); }); it('should render value when optionsId is set', () => { - renderEditOptionListReference({ + renderReferenceTab({ componentProps: { optionsId: 'some-id', }, @@ -57,7 +29,7 @@ describe('EditOptionListReference', () => { it('should call handleComponentChange when input value changes', async () => { const handleComponentChange = jest.fn(); - renderEditOptionListReference({ handleComponentChange }); + renderReferenceTab({ handleComponentChange }); const user = userEvent.setup(); const inputElement = screen.getByRole('textbox'); await user.type(inputElement, 'new-id'); @@ -69,7 +41,7 @@ describe('EditOptionListReference', () => { it('should call remove options property (if it exists) when input value changes', async () => { const handleComponentChange = jest.fn(); - renderEditOptionListReference({ + renderReferenceTab({ handleComponentChange, componentProps: { options: [ @@ -89,3 +61,23 @@ describe('EditOptionListReference', () => { }); }); }); + +const renderReferenceTab = ({ + handleComponentChange = jest.fn(), + componentProps = {}, +}: { + handleComponentChange?: () => void; + componentProps?: Partial< + FormComponent + >; +} = {}) => { + renderWithProviders( + , + ); +}; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionListReference.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.tsx similarity index 89% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionListReference.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.tsx index 30b2db4b75d..113c7fd8271 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionListReference.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/ReferenceTab.tsx @@ -1,15 +1,15 @@ import React from 'react'; import type { IGenericEditComponent } from '../../../../componentConfig'; import { useTranslation, Trans } from 'react-i18next'; - import { altinnDocsUrl } from 'app-shared/ext-urls'; import { StudioParagraph, StudioTextfield } from '@studio/components'; import type { SelectionComponentType } from '../../../../../../types/FormComponent'; +import classes from './ReferenceTab.module.css'; -export function EditOptionListReference({ +export function ReferenceTab({ component, handleComponentChange, -}: IGenericEditComponent) { +}: IGenericEditComponent) { const { t } = useTranslation(); const handleOptionsIdChange = (optionsId: string) => { @@ -23,7 +23,7 @@ export function EditOptionListReference({ }; return ( -
+
{t('ux_editor.options.code_list_referenceId.description')} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/index.ts new file mode 100644 index 00000000000..af890b8bc00 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/ReferenceTab/index.ts @@ -0,0 +1 @@ +export { ReferenceTab } from './ReferenceTab'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionList.module.css b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/SelectTab.module.css similarity index 60% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionList.module.css rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/SelectTab.module.css index 309c7dd3efd..90637ff3c23 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionList.module.css +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/SelectTab.module.css @@ -7,3 +7,10 @@ margin-bottom: 0; padding-top: var(--fds-spacing-2); } + +.container { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-2); + margin: 0 var(--fds-spacing-5); +} diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionList.test.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/SelectTab.test.tsx similarity index 76% rename from frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionList.test.tsx rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/SelectTab.test.tsx index e4124e02f73..0287770bef4 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditOptionList/EditOptionList.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/SelectTab.test.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import { EditOptionList } from './EditOptionList'; -import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import { SelectTab } from './SelectTab'; +import { screen, waitFor } from '@testing-library/react'; import { ComponentType } from 'app-shared/types/ComponentType'; import userEvent, { type UserEvent } from '@testing-library/user-event'; import { componentMocks } from '@altinn/ux-editor/testing/componentMocks'; -import { addFeatureFlagToLocalStorage } from 'app-shared/utils/featureToggleUtils'; -import type { OptionsLists } from 'app-shared/types/api/OptionsLists'; import { renderWithProviders, optionListIdsMock } from '../../../../../../testing/mocks'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; @@ -21,9 +19,9 @@ const getOptionListIds = jest .fn() .mockImplementation(() => Promise.resolve(optionListIdsMock)); -describe('EditOptionList', () => { +describe('SelectTab', () => { it('should render the component', async () => { - renderEditOptionList(); + renderSelectTab(); expect( await screen.findByText(textMock('ux_editor.modal_properties_code_list_helper')), ).toBeInTheDocument(); @@ -31,7 +29,7 @@ describe('EditOptionList', () => { it('should call onChange when option list changes', async () => { const user = userEvent.setup(); - renderEditOptionList(); + renderSelectTab(); await waitFor(() => screen.findByRole('combobox')); @@ -41,7 +39,7 @@ describe('EditOptionList', () => { it('should remove options property (if it exists) when optionsId property changes', async () => { const user = userEvent.setup(); - renderEditOptionList({ + renderSelectTab({ componentProps: { options: [{ label: 'option1', value: 'option1' }], }, @@ -60,7 +58,7 @@ describe('EditOptionList', () => { }); it('should render the selected option list item upon component initialization', async () => { - renderEditOptionList({ + renderSelectTab({ componentProps: { optionsId: 'test-2', }, @@ -69,8 +67,22 @@ describe('EditOptionList', () => { expect(await screen.findByRole('combobox')).toHaveValue('test-2'); }); - it('should render error message if getOptionListIds returns an unknown error', async () => { - renderEditOptionList({ + it('should render returned error message if option list endpoint returns an error', async () => { + renderSelectTab({ + queries: { + getOptionListIds: jest.fn().mockImplementation(() => Promise.reject(new Error('Error'))), + }, + }); + + expect( + await screen.findByText( + textMock('ux_editor.modal_properties_fetch_option_list_error_message'), + ), + ).toBeInTheDocument(); + }); + + it('should render standard error message if option list endpoint throws an error without specified error message', async () => { + renderSelectTab({ queries: { getOptionListIds: jest.fn().mockImplementation(() => Promise.reject()), }, @@ -87,7 +99,7 @@ describe('EditOptionList', () => { const user = userEvent.setup(); const file = new File(['hello'], 'hello.json', { type: 'text/json' }); - renderEditOptionList(); + renderSelectTab(); await userFindUploadButtonAndClick(user); await userFindFileInputAndUploadFile(user, file); @@ -102,7 +114,7 @@ describe('EditOptionList', () => { type: 'text/json', }); - renderEditOptionList(); + renderSelectTab(); await userFindUploadButtonAndClick(user); await userFindFileInputAndUploadFile(user, file); @@ -118,7 +130,7 @@ describe('EditOptionList', () => { type: 'text/json', }); - renderEditOptionList({ queries: { uploadOptionList } }); + renderSelectTab({ queries: { uploadOptionList } }); await userFindUploadButtonAndClick(user); await userFindFileInputAndUploadFile(user, file); @@ -134,7 +146,7 @@ describe('EditOptionList', () => { type: 'text/json', }); - renderEditOptionList(); + renderSelectTab(); await userFindUploadButtonAndClick(user); await userFindFileInputAndUploadFile(user, file); @@ -142,47 +154,23 @@ describe('EditOptionList', () => { textMock('validation_errors.file_name_invalid'), ); }); - - it('should render OptionListEditor when featureFlag is active', async () => { - addFeatureFlagToLocalStorage('optionListEditor'); - renderEditOptionList({ - queries: { - getOptionLists: jest.fn().mockImplementation(() => - Promise.resolve({ - optionsIdMock: [{ value: 'test', label: 'label text' }], - }), - ), - }, - }); - - await waitForElementToBeRemoved(() => - screen.queryByText(textMock('ux_editor.modal_properties_code_list_spinner_title')), - ); - - expect( - screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_open_editor'), - }), - ).toBeInTheDocument(); - }); }); const userFindUploadButtonAndClick = async (user: UserEvent) => { const btn = screen.getByRole('button', { - name: textMock('ux_editor.modal_properties_code_list_upload'), + name: textMock('ux_editor.options.upload_title'), }); await user.click(btn); }; const userFindFileInputAndUploadFile = async (user: UserEvent, file: File) => { - const fileInput = screen.getByLabelText(textMock('ux_editor.modal_properties_code_list_upload')); - + const fileInput = screen.getByLabelText(textMock('ux_editor.options.upload_title')); await user.upload(fileInput, file); }; -const renderEditOptionList = ({ queries = {}, componentProps = {} } = {}) => { +const renderSelectTab = ({ queries = {}, componentProps = {} } = {}) => { return renderWithProviders( - ({ +export function SelectTab({ component, handleComponentChange, }: IGenericEditComponent) { @@ -74,18 +72,13 @@ export function EditOptionList({ } }; - const componentHasConnectedOptionListToEdit = !!component.optionsId; - return ( - <> +
- {shouldDisplayFeature('optionListEditor') && componentHasConnectedOptionListToEdit && ( - - )} @@ -98,7 +91,7 @@ export function EditOptionList({ rel='noopener noreferrer' /> - +
); } diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/index.ts new file mode 100644 index 00000000000..790e8f6d6e7 --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/SelectTab/index.ts @@ -0,0 +1 @@ +export { SelectTab } from './SelectTab'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts index 15b99f92415..9f88938023b 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/index.ts @@ -1,2 +1 @@ -export { useOptionListButtonValue } from './useOptionListButtonValue'; export { useOptionListEditorTexts } from './useOptionListEditorTexts'; diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useOptionListButtonValue.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useOptionListButtonValue.ts deleted file mode 100644 index b7f61384deb..00000000000 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/hooks/useOptionListButtonValue.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import type { Option } from 'app-shared/types/Option'; - -export const useOptionListButtonValue = (options: Option[] | undefined): string | undefined => { - const { t } = useTranslation(); - - if (options?.length > 1) { - return t('ux_editor.options.multiple', { value: options.length }); - } else if (options?.length === 1) { - return t('ux_editor.options.single', { value: options.length }); - } else { - return undefined; - } -}; diff --git a/frontend/packages/ux-editor/src/utils/optionsUtils.test.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.test.ts similarity index 58% rename from frontend/packages/ux-editor/src/utils/optionsUtils.test.ts rename to frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.test.ts index 6c751c41df8..36dfb844792 100644 --- a/frontend/packages/ux-editor/src/utils/optionsUtils.test.ts +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.test.ts @@ -1,9 +1,9 @@ -import { SelectedOptionsType } from '../components/config/editModal/EditOptions/EditOptions'; -import type { IOption } from '../types/global'; +import { SelectedOptionsType } from '../../../../../../components/config/editModal/EditOptions/EditOptions'; +import type { IOption } from '../../../../../../types/global'; import { getSelectedOptionsType, + getSelectedOptionsTypeWithManualSupport, componentUsesDynamicCodeList, - getOptionsPropertyKey, } from './optionsUtils'; describe('getSelectedOptionsType', () => { @@ -31,11 +31,52 @@ describe('getSelectedOptionsType', () => { expect(result).toEqual(SelectedOptionsType.ReferenceId); }); - it('should return SelectedOptionsType.Manual if options is set and codeListId is not set', () => { + it('should use default value for optionListIds if it is not provided', () => { + const codeListId = ''; + const options = undefined; + const result = getSelectedOptionsType(codeListId, options); + expect(result).toEqual(SelectedOptionsType.CodeList); + }); + + it('should return SelectedOptionsType.CodeList if options is set and codeListId is not set', () => { const codeListId = undefined; const options = [{ label: 'label1', value: 'value1' }]; const optionListIds = ['anotherCodeListId']; const result = getSelectedOptionsType(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.CodeList); + }); +}); + +describe('getSelectedOptionsTypeV1', () => { + it('should return SelectedOptionsType.Unknown if both options and optionsId are set', () => { + const codeListId = 'codeListId'; + const options: IOption[] = [{ label: 'label1', value: 'value1' }]; + const optionListIds = ['codeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.Unknown); + }); + + it('should return SelectedOptionsType.CodeList if options is not set and codeListId is in optionListIds', () => { + const codeListId = 'codeListId'; + const options = undefined; + const optionListIds = ['codeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual('codelist'); + }); + + it('should return SelectedOptionsType.ReferenceId if options is not set and codeListId is not in optionListIds', () => { + const codeListId = 'codeListId'; + const options = undefined; + const optionListIds = ['anotherCodeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); + expect(result).toEqual(SelectedOptionsType.ReferenceId); + }); + + it('should return SelectedOptionsType.Manual if options is set and codeListId is not set', () => { + const codeListId = undefined; + const options = [{ label: 'label1', value: 'value1' }]; + const optionListIds = ['anotherCodeListId']; + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); expect(result).toEqual(SelectedOptionsType.Manual); }); @@ -43,14 +84,14 @@ describe('getSelectedOptionsType', () => { const codeListId = undefined; const options = []; const optionListIds = ['anotherCodeListId']; - const result = getSelectedOptionsType(codeListId, options, optionListIds); + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options, optionListIds); expect(result).toEqual(SelectedOptionsType.Manual); }); it('should use default value for optionListIds if it is not provided', () => { const codeListId = ''; const options = undefined; - const result = getSelectedOptionsType(codeListId, options); + const result = getSelectedOptionsTypeWithManualSupport(codeListId, options); expect(result).toEqual(SelectedOptionsType.CodeList); }); }); @@ -77,25 +118,3 @@ describe('componentUsesDynamicCodeList', () => { expect(result).toEqual(true); }); }); - -describe('getOptionsPropertyKey', () => { - it('should return optionsId if selected options type is Codelist', () => { - const result = getOptionsPropertyKey(SelectedOptionsType.CodeList); - expect(result).toEqual('optionsId'); - }); - - it('should return optionsId if selected options type is ReferenceId', () => { - const result = getOptionsPropertyKey(SelectedOptionsType.ReferenceId); - expect(result).toEqual('optionsId'); - }); - - it('should return options if selected options type is Manual', () => { - const result = getOptionsPropertyKey(SelectedOptionsType.Manual); - expect(result).toEqual('options'); - }); - - it('should return options if selected options type is Unknown', () => { - const result = getOptionsPropertyKey(SelectedOptionsType.Unknown); - expect(result).toEqual('options'); - }); -}); diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts new file mode 100644 index 00000000000..8f4e7e9249a --- /dev/null +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/utils/optionsUtils.ts @@ -0,0 +1,48 @@ +import { SelectedOptionsType } from '../../../../../../components/config/editModal/EditOptions/EditOptions'; +import type { IOption } from '../../../../../../types/global'; + +export const componentUsesDynamicCodeList = ( + codeListId: string, + optionListIds: string[], +): boolean => { + if (!codeListId) { + return false; + } + + return !optionListIds.includes(codeListId); +}; + +export function getSelectedOptionsType( + codeListId: string | undefined, + options: IOption[] | undefined, + optionListIds: string[] = [], +): SelectedOptionsType { + /** It is not permitted for a component to have both options and optionsId set on the same component. */ + if (options?.length && codeListId) { + return SelectedOptionsType.Unknown; + } + + return componentUsesDynamicCodeList(codeListId, optionListIds) + ? SelectedOptionsType.ReferenceId + : SelectedOptionsType.CodeList; +} + +// Todo: Remove once featureFlag "optionListEditor" is removed. +export function getSelectedOptionsTypeWithManualSupport( + codeListId: string | undefined, + options: IOption[] | undefined, + optionListIds: string[] = [], +): SelectedOptionsType { + /** It is not permitted for a component to have both options and optionsId set on the same component. */ + if (options?.length && codeListId) { + return SelectedOptionsType.Unknown; + } + + if (!!options) { + return SelectedOptionsType.Manual; + } + + return componentUsesDynamicCodeList(codeListId, optionListIds) + ? SelectedOptionsType.ReferenceId + : SelectedOptionsType.CodeList; +} diff --git a/frontend/packages/ux-editor/src/utils/optionsUtils.ts b/frontend/packages/ux-editor/src/utils/optionsUtils.ts deleted file mode 100644 index 0b30f7ca3ac..00000000000 --- a/frontend/packages/ux-editor/src/utils/optionsUtils.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { SelectedOptionsType } from '../components/config/editModal/EditOptions/EditOptions'; -import type { IOption } from '../types/global'; - -/** - * Function that determines if a component uses a dynamic code list with reference id - * @param codelistId The code list id. - * @param optionListIds The list of available static code list ids. - * @returns True if the code list id is set and is not in the list of static code list ids, false otherwise. - */ -export const componentUsesDynamicCodeList = ( - codelistId: string, - optionListIds: string[], -): boolean => { - if (!codelistId) { - return false; - } - - return !optionListIds.includes(codelistId); -}; - -/** - * Function that determines the selected options type based on the provided parameters. - * @param codeListId The code list id (if it exists). - * @param options The list of manual options (if it exists). - * @param optionListIds The list of available code list ids. - * @returns The selected options type - either Manual, CodeList, ReferenceId or Unknown. - */ -export function getSelectedOptionsType( - codeListId: string | undefined, - options: IOption[] | undefined, - optionListIds: string[] = [], -): SelectedOptionsType { - /** It is not permitted for a component to have both options and optionsId set on the same component. */ - if (options?.length && codeListId) { - return SelectedOptionsType.Unknown; - } - - if (!!options) { - return SelectedOptionsType.Manual; - } - - return componentUsesDynamicCodeList(codeListId, optionListIds) - ? SelectedOptionsType.ReferenceId - : SelectedOptionsType.CodeList; -} - -/** - * Function that returns the property key for the selected options type. - * @param selectedOptionsType The selected options type. - * @returns The property key for the selected options type. - */ -export function getOptionsPropertyKey(selectedOptionsType: SelectedOptionsType) { - switch (selectedOptionsType) { - case SelectedOptionsType.CodeList: - case SelectedOptionsType.ReferenceId: - return 'optionsId'; - case SelectedOptionsType.Manual: - default: - return 'options'; - } -} From 58509bd4ae9f963d432658d168603aa69d020a2b Mon Sep 17 00:00:00 2001 From: Erling Hauan <148075168+ErlingHauan@users.noreply.github.com> Date: Mon, 9 Dec 2024 12:23:04 +0100 Subject: [PATCH 16/35] chore: update storybook versions (#14250) --- frontend/libs/studio-components/package.json | 14 +- yarn.lock | 322 +++++++++---------- 2 files changed, 168 insertions(+), 168 deletions(-) diff --git a/frontend/libs/studio-components/package.json b/frontend/libs/studio-components/package.json index 5d87317e805..7c056bca8b9 100644 --- a/frontend/libs/studio-components/package.json +++ b/frontend/libs/studio-components/package.json @@ -21,14 +21,14 @@ }, "devDependencies": { "@chromatic-com/storybook": "3.2.2", - "@storybook/addon-essentials": "8.4.6", - "@storybook/addon-interactions": "8.4.6", - "@storybook/addon-links": "8.4.6", + "@storybook/addon-essentials": "8.4.7", + "@storybook/addon-interactions": "8.4.7", + "@storybook/addon-links": "8.4.7", "@storybook/addon-webpack5-compiler-swc": "1.0.5", - "@storybook/blocks": "8.4.6", - "@storybook/react": "8.4.6", - "@storybook/react-webpack5": "8.4.6", - "@storybook/test": "8.4.6", + "@storybook/blocks": "8.4.7", + "@storybook/react": "8.4.7", + "@storybook/react-webpack5": "8.4.7", + "@storybook/test": "8.4.7", "@testing-library/jest-dom": "6.6.3", "@testing-library/react": "16.0.1", "@types/jest": "^29.5.5", diff --git a/yarn.lock b/yarn.lock index 607e9de7193..9079a7f7115 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3633,9 +3633,9 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-actions@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-actions@npm:8.4.6" +"@storybook/addon-actions@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-actions@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" "@types/uuid": "npm:^9.0.1" @@ -3643,158 +3643,158 @@ __metadata: polished: "npm:^4.2.2" uuid: "npm:^9.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/d5ed4ffb2df7ecf256132f9bc4e235c8912ce786a78b00bf90170e72f6c058c524118c110604970de45fee684302cc14e20c4c4427a0f91313b4e5b64a84e123 + storybook: ^8.4.7 + checksum: 10/a691f172f2899bf97ee2d454948a53f94fde29038b1dfc8b1fd902cf0912f72b02f484f3ab4abd6df52237edbed2a7f430a6b7f1b6ba8ee2be1e357c586466bd languageName: node linkType: hard -"@storybook/addon-backgrounds@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-backgrounds@npm:8.4.6" +"@storybook/addon-backgrounds@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-backgrounds@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" memoizerific: "npm:^1.11.3" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/fe88c94fb0d9638d1b600082af981e6a94f8d8714e32b8319570de442fb31ccf06a676cecac8e3b094dd7867040b9f25c43d78522fbdc956cc17aec2cf01b427 + storybook: ^8.4.7 + checksum: 10/504ecd09fcdd8bd8525233469df386944a7baff7c8aaeb737532987d27d113db4ded72e394cfcb6b00262602e9fd070cce801cffbb157be6242ee56e0491577c languageName: node linkType: hard -"@storybook/addon-controls@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-controls@npm:8.4.6" +"@storybook/addon-controls@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-controls@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" dequal: "npm:^2.0.2" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/2bf9bd08d33ba567701446b43fe544d6b5ca3931d2ec5aa65e056e42ac2d28bf318a3d8efaad61b9b653b33c6278c4d24c77a8b79c42c78c14e74d6578c0b5b9 + storybook: ^8.4.7 + checksum: 10/29a0d760622cc09517416a5775d8ae7e937fe90ede9d9739a56cdec4bc52564c0d8de535040ed540df912c1c3c04c6f557bc78f792c8af07da91753972f9a512 languageName: node linkType: hard -"@storybook/addon-docs@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-docs@npm:8.4.6" +"@storybook/addon-docs@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-docs@npm:8.4.7" dependencies: "@mdx-js/react": "npm:^3.0.0" - "@storybook/blocks": "npm:8.4.6" - "@storybook/csf-plugin": "npm:8.4.6" - "@storybook/react-dom-shim": "npm:8.4.6" + "@storybook/blocks": "npm:8.4.7" + "@storybook/csf-plugin": "npm:8.4.7" + "@storybook/react-dom-shim": "npm:8.4.7" react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0" react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/6fe339fc4541f6ed8df53a3bff88c2e71fef25982da3e9fb2e854198f378c59e0cc7231b439455e602f49fcfcd37b1eac167c09a609be6f00d5da19ab8c3060c + storybook: ^8.4.7 + checksum: 10/d09fefeefb462a1b6c368e781f4abbb1dfdf0c58e6f9311bc8a2c320699e9e694153ebf3274f4fc54fb85953eb10ced6de11a848c718ffb38a0f59e1b1717220 languageName: node linkType: hard -"@storybook/addon-essentials@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-essentials@npm:8.4.6" - dependencies: - "@storybook/addon-actions": "npm:8.4.6" - "@storybook/addon-backgrounds": "npm:8.4.6" - "@storybook/addon-controls": "npm:8.4.6" - "@storybook/addon-docs": "npm:8.4.6" - "@storybook/addon-highlight": "npm:8.4.6" - "@storybook/addon-measure": "npm:8.4.6" - "@storybook/addon-outline": "npm:8.4.6" - "@storybook/addon-toolbars": "npm:8.4.6" - "@storybook/addon-viewport": "npm:8.4.6" +"@storybook/addon-essentials@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-essentials@npm:8.4.7" + dependencies: + "@storybook/addon-actions": "npm:8.4.7" + "@storybook/addon-backgrounds": "npm:8.4.7" + "@storybook/addon-controls": "npm:8.4.7" + "@storybook/addon-docs": "npm:8.4.7" + "@storybook/addon-highlight": "npm:8.4.7" + "@storybook/addon-measure": "npm:8.4.7" + "@storybook/addon-outline": "npm:8.4.7" + "@storybook/addon-toolbars": "npm:8.4.7" + "@storybook/addon-viewport": "npm:8.4.7" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/72915e83a3cbae4c302d76721f1b3446455f7dbc49f76234e8b25f673cf69f448a1c3aeae121e800bec4aee35c55031ef1b430a0827e358c6dce4ee17375704a + storybook: ^8.4.7 + checksum: 10/d8731c18935fbc130beee7236b4e80c1621c6964a4109741512b50f065cd8d322446f8ecd84b4120ad1ce2ea829d0d3b5b764cca19c1bd8b73fc77d04dc13f17 languageName: node linkType: hard -"@storybook/addon-highlight@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-highlight@npm:8.4.6" +"@storybook/addon-highlight@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-highlight@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/137c2dba6e171e885b20b2a348fdb6253e1d66c09680591507947dc582ba1a4793ecb57ec6331985d24ff4cdb6417e82f66c65198ff4bbf05b3facad39dba403 + storybook: ^8.4.7 + checksum: 10/2d77ce06eaf69445ed6d7c23a666e67576376d770f8fd33055fd35e33c248c2c78f6333461cb92aa21f45bbf06a1255f1977ec3d349fdef531416fc51da809be languageName: node linkType: hard -"@storybook/addon-interactions@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-interactions@npm:8.4.6" +"@storybook/addon-interactions@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-interactions@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" - "@storybook/instrumenter": "npm:8.4.6" - "@storybook/test": "npm:8.4.6" + "@storybook/instrumenter": "npm:8.4.7" + "@storybook/test": "npm:8.4.7" polished: "npm:^4.2.2" ts-dedent: "npm:^2.2.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/298343fedafbb87056f52a1001925747664e6dd3a277ad28cb020f2c8b3d7535b7cac1e721fa39856184e3a210bcfa542b6a06cf924f2154b7a32e5e77c4a3f5 + storybook: ^8.4.7 + checksum: 10/24d5c55eb7f320a002d54cc638a58f196d243b248df7735d68bba21e5b2b4cd0ba0369b78e7b67522ef741516b022e9e627db9a59476e0ea2da153736950d1bc languageName: node linkType: hard -"@storybook/addon-links@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-links@npm:8.4.6" +"@storybook/addon-links@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-links@npm:8.4.7" dependencies: "@storybook/csf": "npm:^0.1.11" "@storybook/global": "npm:^5.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 + storybook: ^8.4.7 peerDependenciesMeta: react: optional: true - checksum: 10/4874a60332a14048ac47d4d46eadc9207622eefdc8ad0abff58a0e6ec997a4de3ab69bf91a3b00be1fc009d58f4ce50c53feceef48f34cab8d185aa4443e95a1 + checksum: 10/3d64225348f1c72dec069551044c7781de03a4775acfefb8ebe2d0c1a6e0171692a1222e15191bccd57b76ca9a995032df14974b7a6271f7a9b283c90bff1a00 languageName: node linkType: hard -"@storybook/addon-measure@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-measure@npm:8.4.6" +"@storybook/addon-measure@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-measure@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" tiny-invariant: "npm:^1.3.1" peerDependencies: - storybook: ^8.4.6 - checksum: 10/81d2b0cc1e16eabcd9bf921bdae3e19cf01a163cb75d6a23918d34c2cc06463f69fd28f9cc1ff80d902c232d9501386a4833b1f5582bccadbcfbcfe8288011c4 + storybook: ^8.4.7 + checksum: 10/d7c39c6048add359aa43ae10a65dda738f9b893a1963a9485a5ac0337f2961495fbdcf3e3907c2f19e7fb5380089f16c57a54113ed097cbf915bfe7f8b756ede languageName: node linkType: hard -"@storybook/addon-outline@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-outline@npm:8.4.6" +"@storybook/addon-outline@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-outline@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/64d047122d9ad6dfa76b3d67275add52f1fcb0862cebc31b465c55638ba4b8b448e023fb31921cce840ead346dcd56c9d92a5a128cf0f9874669abc3325ab1cb + storybook: ^8.4.7 + checksum: 10/b213e725b3b150b3346e91206cd62bf348f537bfec999a6ca8c7c3a9f772ae69b0e67c50b29e48aaa3315753459bd66782d571a014cafe131d88e2ec3b68f060 languageName: node linkType: hard -"@storybook/addon-toolbars@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-toolbars@npm:8.4.6" +"@storybook/addon-toolbars@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-toolbars@npm:8.4.7" peerDependencies: - storybook: ^8.4.6 - checksum: 10/54a67e960a782c42813afb899de037dfbb79274dca3279057b296d1abfde1a3f3ac19e371567dfe757e531f0fea66fe6a394f9d3a93227ed2a3d1ca7a3b6159f + storybook: ^8.4.7 + checksum: 10/dff15abb4942a95e89d8d84dfa210388b3fec845e2deee473752f340638348c314b68cb5c052644f3a12b1adba2b3b82dd2dd07a6ac427f6043e26993b81722d languageName: node linkType: hard -"@storybook/addon-viewport@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/addon-viewport@npm:8.4.6" +"@storybook/addon-viewport@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-viewport@npm:8.4.7" dependencies: memoizerific: "npm:^1.11.3" peerDependencies: - storybook: ^8.4.6 - checksum: 10/06e95780da6619bbf60f84df5a4bdb04e237265cd070dc605114f1f0ffdbfa14f2d64d307ed7354459ff11f53550a8cb26db2010f5720f4634deb831d7950816 + storybook: ^8.4.7 + checksum: 10/8eaf261e43d70b6453a4ec93a3b6ace728a13db0cf49c6c2f38ca49ad987f7b9268dccf71de2b2dd15cacb8862c9de86689ce258565e2c6fa21c20690ff5761a languageName: node linkType: hard @@ -3808,9 +3808,9 @@ __metadata: languageName: node linkType: hard -"@storybook/blocks@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/blocks@npm:8.4.6" +"@storybook/blocks@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/blocks@npm:8.4.7" dependencies: "@storybook/csf": "npm:^0.1.11" "@storybook/icons": "npm:^1.2.12" @@ -3818,21 +3818,21 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 + storybook: ^8.4.7 peerDependenciesMeta: react: optional: true react-dom: optional: true - checksum: 10/65c2b215ca32e34656f491fe59646282b94217f7fe1e275f85804daea3e4d90413fa7a05623e5a95ca53b026cf66b78fc6d05d3f3534cfaccb791b388b431dda + checksum: 10/d1b92f08b7a829800b16d7a6c6b540eb9b855ca6b6dd7d87cd9c67d211590e76eb43b03d04685950839e764ac96fb6062872868f204fec91bfc1ec4624dbcd6c languageName: node linkType: hard -"@storybook/builder-webpack5@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/builder-webpack5@npm:8.4.6" +"@storybook/builder-webpack5@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/builder-webpack5@npm:8.4.7" dependencies: - "@storybook/core-webpack": "npm:8.4.6" + "@storybook/core-webpack": "npm:8.4.7" "@types/node": "npm:^22.0.0" "@types/semver": "npm:^7.3.4" browser-assert: "npm:^1.2.1" @@ -3858,32 +3858,32 @@ __metadata: webpack-hot-middleware: "npm:^2.25.1" webpack-virtual-modules: "npm:^0.6.0" peerDependencies: - storybook: ^8.4.6 + storybook: ^8.4.7 peerDependenciesMeta: typescript: optional: true - checksum: 10/5132655497346ea2a4686d98e54aef479477af8f2b829baf922bae59d6d328d826ee6a7f6e3b131850473cb3de600d9d26a33dca027b8d820c9680851a338a2f + checksum: 10/169d12e25780ec5801c051bc3abc3de12d236327f6ea035cfb6938f59db009e6bea88d4bbf1e13ceecb9fa726abd317a11fde88b3143b1e35608e62775d4761d languageName: node linkType: hard -"@storybook/components@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/components@npm:8.4.6" +"@storybook/components@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/components@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10/66b08f840017e279274a0be2d9cba9edaa50139d5d7cdd9f148ff815f693db0026531e3e15efc9706c9e32aeccb0a97717aca7b81a2119b79f5875a69f0b81a3 + checksum: 10/e39fb81e8386db4f3f76cbf4f82e50512fed2f65a581951c0b61e00c9834c20cfff7f717e936353275dadfe6a25ffaac5d47151adbe1e3be85e709f8a64f6a15 languageName: node linkType: hard -"@storybook/core-webpack@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/core-webpack@npm:8.4.6" +"@storybook/core-webpack@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/core-webpack@npm:8.4.7" dependencies: "@types/node": "npm:^22.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.6 - checksum: 10/bab0913fad34f645ababf372f66598f64dc50b6e073b641edd5cab2c78dc0b90c1ec6faceb6dfc9b1d76d870f15caab9bfbd0bd9c88ad11fb85f3d6671d9a553 + storybook: ^8.4.7 + checksum: 10/561d28962e201086d9f0d739b377aaa5bdaad9eff0dd78cbb6cc9746b70fa3ad86d223e396f414345d19720807a3084ade16c9f2c634d07ed6b8b3355b96be91 languageName: node linkType: hard @@ -3911,14 +3911,14 @@ __metadata: languageName: node linkType: hard -"@storybook/csf-plugin@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/csf-plugin@npm:8.4.6" +"@storybook/csf-plugin@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/csf-plugin@npm:8.4.7" dependencies: unplugin: "npm:^1.3.1" peerDependencies: - storybook: ^8.4.6 - checksum: 10/e09a2175bc3af950668a307626bcc68b51c88b8404e39f2a57942097ff638b5c997aafaaf694493dac611b11572863f8a9cb6246c0b117a07c0d650299fff620 + storybook: ^8.4.7 + checksum: 10/d9006d1a506796717528ee81948be89c8ca7e4a4ad463e024936d828b8e91e12940a41f054db4d5b1f1b058146113aaeb415eca87ca94142c3ef1ef501aead17 languageName: node linkType: hard @@ -3948,33 +3948,33 @@ __metadata: languageName: node linkType: hard -"@storybook/instrumenter@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/instrumenter@npm:8.4.6" +"@storybook/instrumenter@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/instrumenter@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" "@vitest/utils": "npm:^2.1.1" peerDependencies: - storybook: ^8.4.6 - checksum: 10/8153d4b80665ab03f2018d80cc99d0211b3edc2b19d190050227cd7151cbec6bb8b8424ba44545938bcb66774435ba1ab2106d263544c6f65625d5a2903b5238 + storybook: ^8.4.7 + checksum: 10/8142789e7dd32f881cf9de551078fb3574cc54b47bb8fd2c8b66ea1fb100f14af702f4cbd4bc11a8d1dd4c89f5d0ce7574d2e232b197c43bbebd0a30c06c7e75 languageName: node linkType: hard -"@storybook/manager-api@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/manager-api@npm:8.4.6" +"@storybook/manager-api@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/manager-api@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10/f6deb13cc36852681a54a0a7ec4fed17dab7ae496f07b667e5550950186fd5569b53a21ea0a1416997cda202d47710684bc2da251cb3ea495f59160199e52076 + checksum: 10/2b826ec55de7ea0b5b5151dfa896f3e7eddfd36ede61f8a7ad14a37733d5d5645565f863dbde7e2272f1e9b5717f26de7802ae60e297a2647ee2c4c072ed3069 languageName: node linkType: hard -"@storybook/preset-react-webpack@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/preset-react-webpack@npm:8.4.6" +"@storybook/preset-react-webpack@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/preset-react-webpack@npm:8.4.7" dependencies: - "@storybook/core-webpack": "npm:8.4.6" - "@storybook/react": "npm:8.4.6" + "@storybook/core-webpack": "npm:8.4.7" + "@storybook/react": "npm:8.4.7" "@storybook/react-docgen-typescript-plugin": "npm:1.0.6--canary.9.0c3f3b7.0" "@types/node": "npm:^22.0.0" "@types/semver": "npm:^7.3.4" @@ -3988,20 +3988,20 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 + storybook: ^8.4.7 peerDependenciesMeta: typescript: optional: true - checksum: 10/11abe3c6d785c92bb9f91d39467c53c5fe0bdbdf009aafdf9bac6159c586d33aaef9d0220a90b7bbe42e79273a4e6fcd24164a60bdaca72aeb5477ba03e02386 + checksum: 10/d338fa45547126ee35ec0433a9811d9c816cebf27ec7598539b62bb08b5a9c39634986670e1cbcf11778a13691ee0695fc71e4dea68c393e5feb6ae478d047f5 languageName: node linkType: hard -"@storybook/preview-api@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/preview-api@npm:8.4.6" +"@storybook/preview-api@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/preview-api@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10/9771ea6d3e3a6d48ca926293d6521caeec370f7c51527400ec9d184c9b41c580567f00661a568d66eb5d34b9a7e8703e3c2cc9b1cf91be6acb324d134c7c9f02 + checksum: 10/1c467bb2c16c5998b9bc4c2c013e6786936d5f6a373ad8d8ab1beb626616c3187329fdfc3a709663b4af963c7e5789a1401166c6e2a3a66a12f66e858aa94e91 languageName: node linkType: hard @@ -4023,86 +4023,86 @@ __metadata: languageName: node linkType: hard -"@storybook/react-dom-shim@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/react-dom-shim@npm:8.4.6" +"@storybook/react-dom-shim@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/react-dom-shim@npm:8.4.7" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 - checksum: 10/0f36d3e21590eb51b537dad88b08a5262d942d4a4e22f202986697c159b285b8276711cc19c337ac910521d0d33ed2216bd2921ea9874fdcd373d901eda23e3b + storybook: ^8.4.7 + checksum: 10/c45af3e1320f131231aad794c8f0d565677313ba0edbac31e3551bab371927f31ec780151fbc451c57205bd0b73a157b95901d2c4d06c6a63ce868866948f328 languageName: node linkType: hard -"@storybook/react-webpack5@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/react-webpack5@npm:8.4.6" +"@storybook/react-webpack5@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/react-webpack5@npm:8.4.7" dependencies: - "@storybook/builder-webpack5": "npm:8.4.6" - "@storybook/preset-react-webpack": "npm:8.4.6" - "@storybook/react": "npm:8.4.6" + "@storybook/builder-webpack5": "npm:8.4.7" + "@storybook/preset-react-webpack": "npm:8.4.7" + "@storybook/react": "npm:8.4.7" "@types/node": "npm:^22.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 + storybook: ^8.4.7 typescript: ">= 4.2.x" peerDependenciesMeta: typescript: optional: true - checksum: 10/be39bdffca43b786e26540d0ac970fd7d0c11eb3a53696e23af9d359501ea8d1f90aa72389ed3f9e9aff61a70117fc3bc3a8d9e0e4cb69ecf2e0e696df45b1bc + checksum: 10/368565a6f8173025dbaa621d161562d0076f87e7ffd72e4bcd5a145501aa6376bc6a6fadad52a9876b586cc1ff82ffd0774faa9fc4833db41447121a8d6bae86 languageName: node linkType: hard -"@storybook/react@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/react@npm:8.4.6" +"@storybook/react@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/react@npm:8.4.7" dependencies: - "@storybook/components": "npm:8.4.6" + "@storybook/components": "npm:8.4.7" "@storybook/global": "npm:^5.0.0" - "@storybook/manager-api": "npm:8.4.6" - "@storybook/preview-api": "npm:8.4.6" - "@storybook/react-dom-shim": "npm:8.4.6" - "@storybook/theming": "npm:8.4.6" + "@storybook/manager-api": "npm:8.4.7" + "@storybook/preview-api": "npm:8.4.7" + "@storybook/react-dom-shim": "npm:8.4.7" + "@storybook/theming": "npm:8.4.7" peerDependencies: - "@storybook/test": 8.4.6 + "@storybook/test": 8.4.7 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.6 + storybook: ^8.4.7 typescript: ">= 4.2.x" peerDependenciesMeta: "@storybook/test": optional: true typescript: optional: true - checksum: 10/3d58409032d57cd6597cc318c1723e00f463cd1fb816e840909211ea336fa55ecf4f557f6f8e6d1ed2b9b3ae63eb11f31c61b8c988bca3273630548fa203db50 + checksum: 10/4138b11118a313dca2551de307b994f84121c306f2d3a66c29ef9fb07352451a899ce91fd8736149182f8806a7c03dbbe7a4a7d463b0ab3eddbd195057c4cbf8 languageName: node linkType: hard -"@storybook/test@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/test@npm:8.4.6" +"@storybook/test@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/test@npm:8.4.7" dependencies: "@storybook/csf": "npm:^0.1.11" "@storybook/global": "npm:^5.0.0" - "@storybook/instrumenter": "npm:8.4.6" + "@storybook/instrumenter": "npm:8.4.7" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:6.5.0" "@testing-library/user-event": "npm:14.5.2" "@vitest/expect": "npm:2.0.5" "@vitest/spy": "npm:2.0.5" peerDependencies: - storybook: ^8.4.6 - checksum: 10/3b56f956e06a77e72d5be13c2c45c927a334f923ab6b4c3c532e5717974c3cd5f01a4e2146985d2159b3b13a6601b60482e3d7b930fa45bf9851d27627828b06 + storybook: ^8.4.7 + checksum: 10/e6e8c2b5b63337e297362716a9de81818f8d94107cc1eea6c1aef75d0ad93d417d277fa90068ee1960acba98ea2658660514148d106a547419c9088c20905f02 languageName: node linkType: hard -"@storybook/theming@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/theming@npm:8.4.6" +"@storybook/theming@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/theming@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10/364c7c8d66f523d5dec020157ae5dd86ac976b988f1c61cff61ae141ed906d4c8693f2361eb3b1709409c62f246537e68a3ad6c8d7c7d5c39d7df5bd1c39a815 + checksum: 10/47d29993c33bb29994d227af30e099579b7cf760652ed743020f5d7e5a5974f59a6ebeb1cc8995e6158da9cf768a8d2f559d1d819cc082d0bcdb056d85fdcb29 languageName: node linkType: hard @@ -4111,14 +4111,14 @@ __metadata: resolution: "@studio/components@workspace:frontend/libs/studio-components" dependencies: "@chromatic-com/storybook": "npm:3.2.2" - "@storybook/addon-essentials": "npm:8.4.6" - "@storybook/addon-interactions": "npm:8.4.6" - "@storybook/addon-links": "npm:8.4.6" + "@storybook/addon-essentials": "npm:8.4.7" + "@storybook/addon-interactions": "npm:8.4.7" + "@storybook/addon-links": "npm:8.4.7" "@storybook/addon-webpack5-compiler-swc": "npm:1.0.5" - "@storybook/blocks": "npm:8.4.6" - "@storybook/react": "npm:8.4.6" - "@storybook/react-webpack5": "npm:8.4.6" - "@storybook/test": "npm:8.4.6" + "@storybook/blocks": "npm:8.4.7" + "@storybook/react": "npm:8.4.7" + "@storybook/react-webpack5": "npm:8.4.7" + "@storybook/test": "npm:8.4.7" "@studio/icons": "workspace:^" "@studio/pure-functions": "workspace:^" "@testing-library/jest-dom": "npm:6.6.3" From df4179210bc774e6953fb3dbdd6ba20a4ed91178 Mon Sep 17 00:00:00 2001 From: Martin Othamar Date: Mon, 9 Dec 2024 12:26:52 +0100 Subject: [PATCH 17/35] chore: delete EFormidlingClient code, in preparation of migration (#14240) --- .../Altinn.EFormidlingClient.Tests.csproj | 67 --- .../TestData/arkivmelding.xml | 78 ---- .../TestData/arkivmelding2.xml | 80 ---- .../TestData/arkivmeldingInvalid.xml | 43 -- .../TestData/sbd.json | 47 --- .../TestData/sbdInvalid.json | 47 --- .../TestData/test.pdf | Bin 77149 -> 0 bytes .../UnitTest/EFormidlingClientUnitTest.cs | 301 ------------- .../appsettings.json | 5 - .../Altinn.EFormidlingClient.sln | 31 -- .../Altinn.EFormidlingClient.csproj | 54 --- .../EFormidlingClientSettings.cs | 13 - .../EFormidlingClient.cs | 383 ----------------- .../Extensions/HttpClientExtension.cs | 98 ----- .../IEFormidlingClient.cs | 115 ----- .../Models/Arkivmelding.cs | 399 ------------------ .../Models/Capabilities.cs | 57 --- .../Models/Conversation.cs | 120 ------ .../Models/CreateSubscription.cs | 46 -- .../Models/StandardBusinessDocument.cs | 248 ----------- .../Models/Statuses.cs | 194 --------- .../Altinn.EFormidlingClient/README.md | 108 ----- .../Altinn.EFormidlingClient/Altinn3.ruleset | 177 -------- .../Settings.StyleCop | 237 ----------- .../Altinn.EFormidlingClient/stylecop.json | 69 --- 25 files changed, 3017 deletions(-) delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding.xml delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding2.xml delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmeldingInvalid.xml delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbd.json delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbdInvalid.json delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/test.pdf delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/UnitTest/EFormidlingClientUnitTest.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/appsettings.json delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.sln delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Configuration/EFormidlingClientSettings.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/EFormidlingClient.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Extensions/HttpClientExtension.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/IEFormidlingClient.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Arkivmelding.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Capabilities.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Conversation.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/CreateSubscription.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/StandardBusinessDocument.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Statuses.cs delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/README.md delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Altinn3.ruleset delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/Settings.StyleCop delete mode 100644 src/Altinn.Common/Altinn.EFormidlingClient/stylecop.json diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj deleted file mode 100644 index 796eb4cc32c..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/Altinn.EFormidlingClient.Tests.csproj +++ /dev/null @@ -1,67 +0,0 @@ - - - - netcoreapp3.1 - false - - - - - Always - - - - - - - - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - stylecop.json - - - - - ..\Altinn3.ruleset - - - - true - $(NoWarn);1591 - - - - 1701;1702;1591 - - - - - - - - - Always - - - diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding.xml b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding.xml deleted file mode 100644 index 2e5aa494241..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding.xml +++ /dev/null @@ -1,78 +0,0 @@ - - - LandLord - 3380ed76-5d4c-43e7-aa70-8ed8d97e4835 - 2017-05-23T12:46:00 - 1 - - - 43fbe161-7aac-4c9f-a888-d8167aab4144 - Nye lysrør Hauketo Skole - 2017-06-01T10:10:12.000+01:00 - - - Funksjoner - vedlikehold av skole - vedlikehold av skole - 2017-05-23T21:56:12.000+01:00 - Knut Hansen - - - Objekter - 20500 - Hauketo Skole - 2017-05-23T21:56:12.000+01:00 - Knut Hansen - - - 430a6710-a3d4-4863-8bd0-5eb1021bee45 - 2012-02-17T21:56:12.000+01:00 - LandLord - 2012-02-17T21:56:12.000+01:00 - LandLord - 43fbe161-7aac-4c9f-a888-d8167aab4144 - - 3e518e5b-a361-42c7-8668-bcbb9eecf18d - Bestilling - Dokumentet er ferdigstilt - Bestilling - nye lysrør - 2012-02-17T21:56:12.000+01:00 - Landlord - Hoveddokument - 1 - 2012-02-17T21:56:12.000+01:00 - Landlord - - 1 - Produksjonsformat - 2012-02-17T21:56:12.000+01:00 - Landlord - test.pdf - - - Nye lysrør - Nye lysrør - - - 20050 - Hauketo Skole - 200501 - 2005001 - Materiell, elektro - K-123123-elektriker - - - Utgående dokument - Journalført - 2017-05-23 - - Mottaker - elektrikeren AS, Veien 100, Oslo - - - 2017-06-01 - Blah - KNUTKÅRE - Avsluttet - - diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding2.xml b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding2.xml deleted file mode 100644 index 0183136a6ca..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmelding2.xml +++ /dev/null @@ -1,80 +0,0 @@ - - - SaMock - 3380ed76-5d4c-43e7-aa70-8ed8d97e4835 - 2017-05-23T12:46:00 - 1 - - - 43fbe161-7aac-4c9f-a888-d8167aab4144 - En tittel - 2017-06-01T10:10:12.000+01:00 - - - Funksjoner - KlasseId - En tittel - 2017-05-23T21:56:12.000+01:00 - SaMock - - - Objekter - 20500 - En tittel - 2017-05-23T21:56:12.000+01:00 - SaMock - - - 430a6710-a3d4-4863-8bd0-5eb1021bee45 - 2012-02-17T21:56:12.000+01:00 - SaMock - 2012-02-17T21:56:12.000+01:00 - SaMock - 43fbe161-7aac-4c9f-a888-d8167aab4144 - - 3e518e5b-a361-42c7-8668-bcbb9eecf18d - Bestilling - Dokumentet er ferdigstilt - Eksempeldokument - 2012-02-17T21:56:12.000+01:00 - SaMock - Hoveddokument - 1 - 2012-02-17T21:56:12.000+01:00 - SaMock - - 1 - Produksjonsformat - 2012-02-17T21:56:12.000+01:00 - Landlord - test.pdf - - - En tittel - En offentlig tittel - - - 20050 - Objektnavn - 200501 - 2005001 - Materiell, elektro - K-123123-asd - - - Utgående dokument - Journalført - 2017-05-23 - - Mottaker - Mottakers navn - - - 2017-06-01 - Admenhet - Saksansvarlig - Avsluttet - - diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmeldingInvalid.xml b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmeldingInvalid.xml deleted file mode 100644 index f9b0dc14742..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/arkivmeldingInvalid.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - LandLord - 3380ed76-5d4c-43e7-aa70-8ed8d97e4835 - 2017-05-23T12:46:00 - 1 - - - 43fbe161-7aac-4c9f-a888-d8167aab4144 - Nye lysrør Hauketo Skole - 2017-06-01T10:10:12.000+01:00 - - - - 430a6710-a3d4-4863-8bd0-5eb1021bee45 - 2012-02-17T21:56:12.000+01:00 - LandLord - 2012-02-17T21:56:12.000+01:00 - LandLord - 43fbe161-7aac-4c9f-a888-d8167aab4144 - - 3e518e5b-a361-42c7-8668-bcbb9eecf18d - Bestilling - Dokumentet er ferdigstilt - Bestilling - nye lysrør - 2012-02-17T21:56:12.000+01:00 - Landlord - Hoveddokument - 1 - 2012-02-17T21:56:12.000+01:00 - Landlord - - - Nye lysrør - Nye lysrør - Utgående dokument - Journalført - 2017-05-23 - - - - - diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbd.json b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbd.json deleted file mode 100644 index 4c1c3f6a93a..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbd.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "standardBusinessDocumentHeader": { - "headerVersion": "1.0", - "sender": [ - { - "identifier": { - "value": "0192:910075918", - "authority": "iso6523-actorid-upis" - }, - "contactInformation": [] - } - ], - "receiver": [ - { - "identifier": { - "value": "0192:910075918", - "authority": "iso6523-actorid-upis" - }, - "contactInformation": [] - } - ], - "documentIdentification": { - "instanceIdentifier": "dddf6910-6bde-11eb-83f7-e5be6c2ac43bo", - "standard": "urn:no:difi:arkivmelding:xsd::arkivmelding", - "typeVersion": "2.0", - "type": "arkivmelding", - "creationDateAndTime": "2020-01-09T15:45:51+01:00" - }, - "businessScope": { - "scope": [ - { - "type": "ConversationId", - "instanceIdentifier": "dddfb730-6bde-11eb-83f7-e5be6c2ac43o", - "identifier": "urn:no:difi:profile:arkivmelding:administrasjon:ver1.0", - "scopeInformation": [ - { - "expectedResponseDateTime": "2021-02-27T23:59:00+01:00" - } - ] - } - ] - } - }, - "arkivmelding": { - "sikkerhetsnivaa": 3 - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbdInvalid.json b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbdInvalid.json deleted file mode 100644 index ac9ccf30218..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/sbdInvalid.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "standardBusinessDocumentHeaderTest": { - "headerVersion": "1.0", - "sender": [ - { - "identifier": { - "value": "0192:910075918", - "authority": "iso6523-actorid-upis" - }, - "contactInformation": [] - } - ], - "receiver": [ - { - "identifier": { - "value": "0192:910075918", - "authority": "iso6523-actorid-upis" - }, - "contactInformation": [] - } - ], - "documentIdentification": { - "instanceIdentifier": "dddf6910-6bde-11eb-83f7-e5be6c2ac43bo", - - "typeVersion": "2.0", - "type": "arkivmelding", - "creationDateAndTime": "2020-01-09T15:45:51+01:00" - }, - "businessScope": { - "scope": [ - { - "type": "ConversationId", - "instanceIdentifier": "dddfb730-6bde-11eb-83f7-e5be6c2ac43o", - "identifier": "urn:no:difi:profile:arkivmelding:administrasjon:ver1.0", - "scopeInformation": [ - { - "expectedResponseDateTime": "2021-02-27T23:59:00+01:00" - } - ] - } - ] - } - }, - "arkivmeldingTest": { - "sikkerhetsnivaa": 3 - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/test.pdf b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/TestData/test.pdf deleted file mode 100644 index 7d349b8f759dd65bc09a6522b3f8eaa895c194a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 77149 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 - /// Represents a collection of unit test, testing the class. - /// - public class EFormidlingClientUnitTest : IClassFixture - { - private readonly ServiceProvider _serviceProvider; - - /// - /// Initializes a new instance of the class. - /// - /// Fixture for setup - public EFormidlingClientUnitTest(FixtureUnit fixture) - { - _serviceProvider = fixture.ServiceProvider; - } - - /// - /// Test valid sbd json from file - /// Expected: Not null after deserialization from valid json file - /// - [Fact] - public void Is_Valid_Json() - { - var jsonString = File.ReadAllText(@"TestData\sbd.json"); - StandardBusinessDocument sbd = JsonSerializer.Deserialize(jsonString); - - Assert.NotNull(sbd); - } - - /// - /// Test invalid sbd json from file where Arkivmelding and StandardBusinessDocumentHeader are incorrect - /// Expected: StandardBusinessDocumentHeader and Arkivmelding null - /// - [Fact] - public void Is_Not_Valid_Json() - { - var jsonString = File.ReadAllText(@"TestData\sbdInvalid.json"); - StandardBusinessDocument sbd = JsonSerializer.Deserialize(jsonString); - - Assert.Null(sbd.Arkivmelding); - Assert.Null(sbd.StandardBusinessDocumentHeader); - } - - /// - /// Test valid xml arkivmelding from file - /// Expected: Serialized arkivmelding is type of Arkivmelding dto - /// - [Fact] - public void Is_Valid_Xml() - { - using FileStream fs = File.OpenRead(@"TestData\arkivmelding.xml"); - XmlSerializer serializer = new XmlSerializer(typeof(Arkivmelding)); - Arkivmelding arkivmelding = (Arkivmelding)serializer.Deserialize(fs); - Assert.NotNull(arkivmelding); - Assert.Equal(typeof(Arkivmelding), arkivmelding.GetType()); - } - - /// - /// Tests not empty file arkivmelding - /// Expected: FileStream content is not empty after reading arkivmelding testdata - /// - [Fact] - public void Read_Not_Empty_XML_Test_Data() - { - using FileStream fs = File.OpenRead(@"TestData\arkivmelding.xml"); - Assert.True(fs.Length > 0); - } - - /// - /// Tests valid config in appsettings - /// Expected: BaseUrl from config is set to localhost - /// - [Fact] - public void Check_Valid_AppConfig() - { - var config = FixtureUnit.InitConfiguration(); - var baseUrlSetting = config["EFormidlingClientSettings:BaseUrl"]; - var baseUrlLocal = "http://localhost:9093/api/"; - - Assert.Equal(baseUrlLocal, baseUrlSetting); - } - - /// - /// Tests invalid input to GetCapabilities() - /// Expected: ArgumentNullException is thrown when input parameters are null - /// - [Fact] - public async void Get_Capabilities_Invalid_Input() - { - var service = _serviceProvider.GetService(); - ArgumentNullException ex = await Assert.ThrowsAsync(async () => await service.GetCapabilities(string.Empty, null)); - } - - /// - /// Tests invald input to UploadAttachment() - /// Expected: ArgumentNullException is thrown when input parameters are null - /// - [Fact] - public async void Upload_Attachment_Invalid_Input() - { - var service = _serviceProvider.GetService(); - ArgumentNullException ex = await Assert.ThrowsAsync(async () => await service.UploadAttachment(null, string.Empty, string.Empty, null)); - } - - /// - /// Tests invalid input to CreateMessage() - /// Expected: ArgumentNullException is thrown when input parameters are null - /// - [Fact] - public async void Create_Message_Invalid_Input() - { - var service = _serviceProvider.GetService(); - ArgumentNullException ex = await Assert.ThrowsAsync(async () => await service.CreateMessage(null, null)); - } - - /// - /// Tests invalid input to SendMessage() - /// Expected: ArgumentNullException is thrown when input parameters are null - /// - [Fact] - public async void Send_Message_Invalid_Input() - { - var service = _serviceProvider.GetService(); - ArgumentNullException ex = await Assert.ThrowsAsync(async () => await service.SendMessage(null, null)); - } - - /// - /// Tests invalid input to SubscribeeFormiding() - /// Expected: ArgumentNullException is thrown when input parameters are null - /// - [Fact] - public async void SubscribeeFormidling_Invalid_Input() - { - var service = _serviceProvider.GetService(); - ArgumentNullException ex = await Assert.ThrowsAsync(async () => await service.SubscribeeFormidling(null, null)); - } - - /// - /// Tests invalid input to UnSubscribeeFormiding() - /// Expected: ArgumentNullException is thrown when input parameters are 0 or negative - /// - [Fact] - public async void UnSubscribeeFormidling_Invalid_Input() - { - var service = _serviceProvider.GetService(); - ArgumentNullException ex = await Assert.ThrowsAsync(async () => await service.UnSubscribeeFormidling(0, null)); - } - - /// - /// Tests that a custom created arkivmelding is valid according to its model - /// Expected: Created arkivmelding object is instance of Arkivmelding dto - /// - [Fact] - public void Verify_Arkivmelding_Build() - { - Arkivmelding arkivmelding = new Arkivmelding - { - AntallFiler = 1, - Tidspunkt = DateTime.Now.ToString(), - MeldingId = Guid.NewGuid().ToString(), - System = "LandLord", - Mappe = new List - { - new Mappe - { - SystemID = Guid.NewGuid().ToString(), - Tittel = "Dette er en tittel", - OpprettetDato = DateTime.Now.ToString(), - Type = "saksmappe", - Basisregistrering = new Basisregistrering - { - Type = "journalpost", - SystemID = Guid.NewGuid().ToString(), - OpprettetDato = DateTime.UtcNow, - OpprettetAv = "LandLord", - ArkivertDato = DateTime.Now, - ArkivertAv = "LandLord", - Dokumentbeskrivelse = new Dokumentbeskrivelse - { - SystemID = Guid.NewGuid().ToString(), - Dokumenttype = "Bestilling", - Dokumentstatus = "Dokumentet er ferdigstilt", - Tittel = "Hei", - OpprettetDato = DateTime.UtcNow, - OpprettetAv = "LandLord", - TilknyttetRegistreringSom = "hoveddokument", - Dokumentnummer = 1, - TilknyttetDato = DateTime.Now, - TilknyttetAv = "Landlord", - Dokumentobjekt = new Dokumentobjekt - { - Versjonsnummer = 1, - Variantformat = "Produksjonsformat", - OpprettetDato = DateTime.UtcNow, - OpprettetAv = "LandLord", - ReferanseDokumentfil = "skjema.xml", - }, - }, - Tittel = "Nye lysrør", - OffentligTittel = "Nye lysrør", - Journalposttype = "Utgående dokument", - Journalstatus = "Journalført", - Journaldato = DateTime.Now, - }, - }, - }, - }; - - MemoryStream stream = new MemoryStream(); - XmlSerializer serializer = new XmlSerializer(typeof(Arkivmelding)); - serializer.Serialize(stream, arkivmelding); - - using MemoryStream ms = stream; - stream.Seek(0, SeekOrigin.Begin); - var verifiedArkivmelding = serializer.Deserialize(stream) as Arkivmelding; - - Assert.NotNull(arkivmelding); - Assert.Equal(typeof(Arkivmelding), arkivmelding.GetType()); - } - } - - /// - /// Initializes a new instance of the class - /// Mocking DI - /// - public class FixtureUnit - { - /// - /// Gets the ServiceProvider - /// - public ServiceProvider ServiceProvider { get; private set; } - - /// - /// Gets the CustomGuid - /// - public string CustomGuid { get; private set; } - - /// - /// Initializes a new instance of the class. - /// - public FixtureUnit() - { - var serviceCollection = new ServiceCollection(); - var configuration = new ConfigurationBuilder() - .SetBasePath(Directory.GetCurrentDirectory()) - .AddJsonFile("appsettings.json", optional: false) - .AddEnvironmentVariables() - .Build(); - - serviceCollection.Configure(configuration.GetSection("EFormidlingClientSettings")); - serviceCollection.AddLogging(config => - { - config.AddDebug(); - config.AddConsole(); - }) - .Configure(options => - { - options.AddFilter(null, LogLevel.Debug); - options.AddFilter(null, LogLevel.Debug); - }); - - serviceCollection.AddTransient(); - _ = serviceCollection.AddTransient(); - ServiceProvider = serviceCollection.BuildServiceProvider(); - - Guid obj = Guid.NewGuid(); - CustomGuid = obj.ToString(); - } - - /// - /// Gets the CustomGuid - /// - public static IConfiguration InitConfiguration() - { - var config = new ConfigurationBuilder() - .AddJsonFile("appsettings.json") - .Build(); - return config; - } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/appsettings.json b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/appsettings.json deleted file mode 100644 index a9aa49d2479..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.Tests/appsettings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "EFormidlingClientSettings": { - "BaseUrl": "http://localhost:9093/api/" - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.sln b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.sln deleted file mode 100644 index b524ff54400..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30907.101 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Altinn.EFormidlingClient", "Altinn.EFormidlingClient\Altinn.EFormidlingClient.csproj", "{3EE9F578-0CB0-4884-9A68-DF50D1957FE1}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Altinn.EFormidlingClient.Tests", "Altinn.EFormidlingClient.Tests\Altinn.EFormidlingClient.Tests.csproj", "{090C4A90-4E9F-4FBF-AD9D-891CD9BDFD96}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {3EE9F578-0CB0-4884-9A68-DF50D1957FE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3EE9F578-0CB0-4884-9A68-DF50D1957FE1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3EE9F578-0CB0-4884-9A68-DF50D1957FE1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3EE9F578-0CB0-4884-9A68-DF50D1957FE1}.Release|Any CPU.Build.0 = Release|Any CPU - {090C4A90-4E9F-4FBF-AD9D-891CD9BDFD96}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {090C4A90-4E9F-4FBF-AD9D-891CD9BDFD96}.Debug|Any CPU.Build.0 = Debug|Any CPU - {090C4A90-4E9F-4FBF-AD9D-891CD9BDFD96}.Release|Any CPU.ActiveCfg = Release|Any CPU - {090C4A90-4E9F-4FBF-AD9D-891CD9BDFD96}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {4E6BC023-0117-4D4B-8448-6E69F99312C2} - EndGlobalSection -EndGlobal diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj deleted file mode 100644 index 22956d0c2be..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Altinn.EFormidlingClient.csproj +++ /dev/null @@ -1,54 +0,0 @@ - - - - netstandard2.0 - Library - 1.3.3.0 - 1.3.3.0 - Altinn.Common.EFormidlingClient - Altinn;Studio;eFormidling;client - - Altinn.Common.EFormidlingClient is a package for sending messages via eFormidling to receiver. - - - Altinn Platform Contributors - git - https://github.com/Altinn/altinn-studio - true - snupkg - true - - - {790c4782-cdb0-4c06-8e16-514cb617b8e0} - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - stylecop.json - - - - - - - - - ..\Altinn3.ruleset - - - - 1701;1702;1587 - bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml - - - diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Configuration/EFormidlingClientSettings.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Configuration/EFormidlingClientSettings.cs deleted file mode 100644 index 685da73bb13..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Configuration/EFormidlingClientSettings.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Altinn.Common.EFormidlingClient.Configuration -{ - /// - /// Settings for EFormidling Client - /// - public class EFormidlingClientSettings - { - /// - /// BaseUrl for eFormidling integration point API - /// - public string BaseUrl { get; set; } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/EFormidlingClient.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/EFormidlingClient.cs deleted file mode 100644 index 041b4ffa462..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/EFormidlingClient.cs +++ /dev/null @@ -1,383 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Net; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Text; -using System.Text.Json; -using System.Threading.Tasks; -using System.Web; - -using Altinn.Common.EFormidlingClient.Configuration; -using Altinn.Common.EFormidlingClient.Models; -using Altinn.Common.EFormidlingClient.Models.SBD; -using Altinn.EFormidlingClient.Extensions; -using Altinn.EFormidlingClient.Models; - -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; - -namespace Altinn.Common.EFormidlingClient -{ - /// - /// Represents an implementation of using a HttpClient. - /// - public class EFormidlingClient : IEFormidlingClient - { - private readonly HttpClient _client; - private readonly ILogger _logger; - private readonly EFormidlingClientSettings _eformidlingSettings; - - /// - /// Initializes a new instance of the IFormidlingClient class with the given HttpClient, lSettings and Logger. - /// - /// A HttpClient provided by a HttpClientFactory. - /// The settings configured for eFormidling package - /// Logging - public EFormidlingClient( - HttpClient httpClient, - IOptions eformidlingSettings, - ILogger logger) - { - _client = httpClient ?? throw new ArgumentNullException("httpClient"); - _eformidlingSettings = eformidlingSettings?.Value ?? throw new ArgumentNullException("eformidlingSettings"); - _logger = logger ?? throw new ArgumentNullException("logger"); - - _client.DefaultRequestHeaders.Clear(); - _client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - _client.BaseAddress = new Uri(_eformidlingSettings.BaseUrl); - } - - /// - public async Task SendMessage(string id, Dictionary requestHeaders) - { - if (string.IsNullOrEmpty(id)) - { - throw new ArgumentNullException(nameof(id)); - } - - string responseBody = null; - try - { - HttpResponseMessage res = await _client.PostAsync($"messages/out/{id}", null, requestHeaders); - responseBody = await res.Content.ReadAsStringAsync(); - res.EnsureSuccessStatusCode(); - } - catch (HttpRequestException) - { - throw new WebException($"The remote server returned an unexpcted error: {responseBody}."); - } - catch (Exception ex) - { - _logger.LogError("Message :{Exception} ", ex); - throw; - } - } - - /// - public async Task FindOutGoingMessages(string serviceIdentifier, Dictionary requestHeaders) - { - string responseBody; - - AssertNotNullOrEmpty(serviceIdentifier, nameof(serviceIdentifier)); - - try - { - HttpResponseMessage response = await _client.GetAsync($"messages/out/?serviceIdentifier={serviceIdentifier}", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - _logger.LogDebug(responseBody); - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - } - - /// - public async Task GetAllMessageStatuses(Dictionary requestHeaders) - { - string responseBody; - try - { - HttpResponseMessage response = await _client.GetAsync($"statuses", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - Statuses allMessageStatuses = JsonSerializer.Deserialize(responseBody); - _logger.LogDebug(responseBody); - - return allMessageStatuses; - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - } - - /// - public async Task GetCapabilities(string orgId, Dictionary requestHeaders) - { - string responseBody; - - AssertNotNullOrEmpty(orgId, nameof(orgId)); - - try - { - HttpResponseMessage response = await _client.GetAsync($"capabilities/{orgId}", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - Capabilities capabilities = JsonSerializer.Deserialize(responseBody); - _logger.LogDebug(responseBody); - - return capabilities; - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - } - - /// - public async Task GetAllConversations(Dictionary requestHeaders) - { - string responseBody; - try - { - HttpResponseMessage response = await _client.GetAsync($"conversations", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - Conversation conversations = JsonSerializer.Deserialize(responseBody); - _logger.LogDebug(responseBody); - - return conversations; - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - } - - /// - public async Task GetConversationById(string id, Dictionary requestHeaders) - { - string responseBody; - - AssertNotNullOrEmpty(id, nameof(id)); - - try - { - HttpResponseMessage response = await _client.GetAsync($"conversations/{id}", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - Conversation conversation = JsonSerializer.Deserialize(responseBody); - _logger.LogDebug(responseBody); - - return conversation; - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - } - - /// - public async Task GetConversationByMessageId(string id, Dictionary requestHeaders) - { - string responseBody; - - AssertNotNullOrEmpty(id, nameof(id)); - - try - { - HttpResponseMessage response = await _client.GetAsync($"conversations/messageId/{id}", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - Conversation conversation = JsonSerializer.Deserialize(responseBody); - _logger.LogDebug(responseBody); - - return conversation; - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - } - - /// - public async Task GetMessageStatusById(string id, Dictionary requestHeaders) - { - string responseBody; - - AssertNotNullOrEmpty(id, nameof(id)); - - try - { - HttpResponseMessage response = await _client.GetAsync($"statuses?messageId={id}", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - Statuses status = JsonSerializer.Deserialize(responseBody); - _logger.LogDebug(responseBody); - - return status; - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - } - - /// - public async Task UploadAttachment(Stream stream, string id, string filename, Dictionary requestHeaders) - { - AssertNotNullOrEmpty(id, nameof(id)); - AssertNotNullOrEmpty(filename, nameof(filename)); - AssertNotNull(stream, nameof(stream)); - - var streamContent = new StreamContent(stream); - streamContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("attachment") - { - Name = "attachment", - FileName = filename, - FileNameStar = filename, - }; - - streamContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); - - HttpResponseMessage response = await _client.PutAsync($"messages/out/{id}?title={filename}", streamContent, requestHeaders); - response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - - if (response.Content == null) - { - response.Content = new StringContent(string.Empty); - } - - var responseBody = await response.Content.ReadAsStringAsync(); - - if (response.IsSuccessStatusCode) - { - return true; - } - else - { - _logger.LogError($"The remote server returned unexpcted status code: {response.StatusCode} - {responseBody}."); - throw new WebException($"The remote server returned unexpcted status code: {response.StatusCode} - {responseBody}."); - } - } - - /// - public async Task CreateMessage(StandardBusinessDocument sbd, Dictionary requestHeaders) - { - AssertNotNull(sbd, nameof(sbd)); - - var jsonContent = JsonSerializer.Serialize(sbd); - byte[] buffer = Encoding.UTF8.GetBytes(jsonContent); - ByteArrayContent byteContent = new ByteArrayContent(buffer); - - byteContent.Headers.Remove("Content-Type"); - byteContent.Headers.Add("Content-Type", "application/json"); - _logger.LogDebug(jsonContent); - - string responseBody = null; - try - { - HttpResponseMessage response = await _client.PostAsync("messages/out", byteContent, requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - response.EnsureSuccessStatusCode(); - StandardBusinessDocument sbdVerified = JsonSerializer.Deserialize(responseBody); - _logger.LogDebug(responseBody); - - return sbdVerified; - } - catch (HttpRequestException) - { - throw new WebException($"The remote server returned an unexpcted error: {responseBody}."); - } - catch (Exception ex) - { - _logger.LogError("Message :{Exception} ", ex.Message); - throw; - } - } - - /// - public async Task SubscribeeFormidling(CreateSubscription subscription, Dictionary requestHeaders) - { - AssertNotNull(subscription, nameof(subscription)); - - string responseBody = null; - try - { - var jsonString = JsonSerializer.Serialize(subscription); - var stringContent = new StringContent(jsonString, Encoding.UTF8, "application/json"); - - HttpResponseMessage response = await _client.PostAsync($"subscriptions", stringContent, requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - response.EnsureSuccessStatusCode(); - - if (response.StatusCode == HttpStatusCode.OK) - { - return true; - } - } - catch (HttpRequestException ex) - { - _logger.LogError("Message :{Exception} ", ex.Message); - throw new WebException($"The remote server returned an unexpected error: {responseBody}."); - } - - return false; - } - - /// - public async Task UnSubscribeeFormidling(int id, Dictionary requestHeaders) - { - AssertAboveZero(id, nameof(id)); - - string responseBody; - - try - { - HttpResponseMessage response = await _client.DeleteAsync($"subscriptions/{id}", requestHeaders); - responseBody = await response.Content.ReadAsStringAsync(); - _logger.LogDebug(responseBody); - - if (response.StatusCode == HttpStatusCode.OK) - { - return true; - } - } - catch (HttpRequestException e) - { - _logger.LogError("Message :{Exception} ", e.Message); - throw; - } - - return false; - } - - private static void AssertNotNullOrEmpty(string paramValue, string paramName) - { - if (string.IsNullOrEmpty(paramValue)) - { - throw new ArgumentNullException($"'{paramName}' cannot be null or empty.", nameof(paramName)); - } - } - - private static void AssertNotNull(object paramValue, string paramName) - { - if (paramValue == null) - { - throw new ArgumentNullException($"'{paramName}' cannot be null or empty.", nameof(paramName)); - } - } - - private static void AssertAboveZero(int paramValue, string paramName) - { - if (paramValue <= 0) - { - throw new ArgumentNullException($"'{paramName}' cannot be null or empty.", nameof(paramName)); - } - } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Extensions/HttpClientExtension.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Extensions/HttpClientExtension.cs deleted file mode 100644 index 8fbd62d77d3..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Extensions/HttpClientExtension.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; - -namespace Altinn.EFormidlingClient.Extensions -{ - /// - /// This extension is created to make it easy to add a bearer token to a HttpRequests. - /// - [ExcludeFromCodeCoverage] - public static class HttpClientExtension - { - /// - /// Extension that adds provided request headers to request - /// - /// The HttpClient - /// The request Uri - /// The http content - /// Dictionary of request headers to include - /// A HttpResponseMessage - public static Task PostAsync(this HttpClient httpClient, string requestUri, HttpContent content, Dictionary requestHeaders) - { - var request = new HttpRequestMessage(HttpMethod.Post, requestUri) - { - Content = content - }; - - SetRequestHeaders(request, requestHeaders); - - return httpClient.SendAsync(request, CancellationToken.None); - } - - /// - /// Extension that adds provided request headers to request - /// - /// The HttpClient - /// The request Uri - /// The http content - /// Dictionary of request headers to include - /// A HttpResponseMessage - public static Task PutAsync(this HttpClient httpClient, string requestUri, HttpContent content, Dictionary requestHeaders) - { - var request = new HttpRequestMessage(HttpMethod.Put, requestUri) - { - Content = content - }; - - SetRequestHeaders(request, requestHeaders); - - return httpClient.SendAsync(request, CancellationToken.None); - } - - /// - /// Extension that adds provided request headers to request - /// - /// The HttpClient - /// The request Uri - /// Dictionary of request headers to include - /// A HttpResponseMessage - public static Task GetAsync(this HttpClient httpClient, string requestUri, Dictionary requestHeaders) - { - HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, requestUri); - - SetRequestHeaders(request, requestHeaders); - - return httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, CancellationToken.None); - } - - /// - /// Extension that adds provided request headers to request - /// - /// The HttpClient - /// The request Uri - /// Dictionary of request headers to include - /// A HttpResponseMessage - public static Task DeleteAsync(this HttpClient httpClient, string requestUri, Dictionary requestHeaders) - { - HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Delete, requestUri); - - SetRequestHeaders(request, requestHeaders); - - return httpClient.SendAsync(request, HttpCompletionOption.ResponseContentRead, CancellationToken.None); - } - - private static void SetRequestHeaders(HttpRequestMessage request, Dictionary requestHeaders) - { - if (requestHeaders != null) - { - foreach (KeyValuePair header in requestHeaders) - { - request.Headers.Add(header.Key, header.Value); - } - } - } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/IEFormidlingClient.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/IEFormidlingClient.cs deleted file mode 100644 index 02e4c14a343..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/IEFormidlingClient.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; - -using Altinn.Common.EFormidlingClient.Models; -using Altinn.Common.EFormidlingClient.Models.SBD; -using Altinn.EFormidlingClient.Models; - -namespace Altinn.Common.EFormidlingClient -{ - /// - /// Interface for actions related to the eFormidling Integration Point(IP) API. - /// Ref: https://docs.digdir.no/eformidling_nm_restdocs.html - /// - public interface IEFormidlingClient - { - /// - /// Subscribes to IP API with callback URL allowing to get push notifcation for message status - /// - /// Object that is used to create subscription. - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task SubscribeeFormidling(CreateSubscription subscription, Dictionary requestHeaders); - - /// - /// Deletes subscription by id - /// - /// Id of previously created subscription - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task UnSubscribeeFormidling(int id, Dictionary requestHeaders); - - /// - /// Creates a message using the Standard Business Document Header specification. - /// The eFormidling IP uses this document to route it to the correct receiver(s). - /// After creation a conversation is created in the IP keepting track of the messages. - /// Ref: https://www.gs1.org/standards/edi/standard-business-document-header-sbdh - /// - /// Client provides a SBD DTO popluated with necessary fields - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task CreateMessage(StandardBusinessDocument sbd, Dictionary requestHeaders); - - /// - /// Posts attachments related to the message e.g. binary files, arkivmelding.xml - /// - /// Stream of file content - /// Descriptor which contains reference information which uniquely identifies this instance of the SBD between the sender and the receiver. - /// Name of file to send - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task UploadAttachment(Stream stream, string id, string filename, Dictionary requestHeaders); - - /// - /// Gets the capabilities for a receiver, i.e. the available process, serviceIdentifier, and documentTypes - /// - /// The Organization Id to retrieve capabilities for - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task GetCapabilities(string orgId, Dictionary requestHeaders); - - /// - /// Retrieves outgoing messages on the outgoing qeueue of IP - /// - /// The service identifier to use - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task FindOutGoingMessages(string serviceIdentifier, Dictionary requestHeaders); - - /// - /// Retrieves all created conversations. The response is paged with a default page size of 10. - /// - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task GetAllConversations(Dictionary requestHeaders); - - /// - /// Retrieves conversation on a given Id. - /// - /// Conversation Id - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task GetConversationById(string id, Dictionary requestHeaders); - - /// - /// Retrieves conversation by message Id - /// - /// Message Id - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task GetConversationByMessageId(string id, Dictionary requestHeaders); - - /// - /// Retrieves all message statuses - /// - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task GetAllMessageStatuses(Dictionary requestHeaders); - - /// - /// Retrieves message status by id - /// - /// Message Id - /// A dictionary of request headers to include in the request to the integration point. - /// A representing the result of the asynchronous operation. - Task GetMessageStatusById(string id, Dictionary requestHeaders); - - /// - /// This is used to send the message. Completes the transaction. - /// - /// Message Id - /// A dictionary of request headers to include in the request to the integration point. - Task SendMessage(string id, Dictionary requestHeaders); - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Arkivmelding.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Arkivmelding.cs deleted file mode 100644 index fbd38436b86..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Arkivmelding.cs +++ /dev/null @@ -1,399 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Xml.Serialization; - -namespace Altinn.Common.EFormidlingClient.Models -{ - /// - /// Initializes a new instance of the class. This class represents the actual arkivmelding. - /// This class is autogenerated. XSD definition: https://github.com/difi/felleslosninger/blob/gh-pages/resources/arkivmelding/arkivmelding.xsd - /// - [XmlRoot(ElementName = "arkivmelding", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public class Arkivmelding - { - /// - /// Gets or sets the AntallFiler - /// - [XmlElement(ElementName = "antallFiler", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public int AntallFiler { get; set; } - - /// - /// Gets or sets the Mappe - /// - [XmlElement(ElementName = "mappe")] - public List Mappe { get; set; } - - /// - /// Gets or sets the MeldingId - /// - [XmlElement(ElementName = "meldingId", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string MeldingId { get; set; } - - /// - /// Gets or sets the SchemaLocation - /// - [XmlAttribute(AttributeName = "schemaLocation", Namespace = "http://www.w3.org/2001/XMLSchema-instance")] - public string SchemaLocation { get; set; } - - /// - /// Gets or sets the System - /// - [XmlElement(ElementName = "system", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string System { get; set; } - - /// - /// Gets or sets the Tidspunkt - /// - [XmlElement(ElementName = "tidspunkt", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string Tidspunkt { get; set; } - - /// - /// Gets or sets the Xmlns - /// - [XmlAttribute(AttributeName = "xmlns", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string Xmlns { get; set; } - - /// - /// Gets or sets the Xsi - /// - [XmlAttribute(AttributeName = "xsi", Namespace = "http://www.w3.org/2000/xmlns/")] - public string Xsi { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - [XmlRoot(ElementName = "dokumentobjekt")] - public class Dokumentobjekt - { - /// - /// Gets or sets the Versjonsnummer - /// - [XmlElement(ElementName = "versjonsnummer")] - public int Versjonsnummer { get; set; } - - /// - /// Gets or sets the Variantformat - /// - [XmlElement(ElementName = "variantformat")] - public string Variantformat { get; set; } - - /// - /// Gets or sets the OpprettetDato - /// - [XmlElement(ElementName = "opprettetDato")] - public DateTime OpprettetDato { get; set; } - - /// - /// Gets or sets the OpprettetAv - /// - [XmlElement(ElementName = "opprettetAv")] - public string OpprettetAv { get; set; } - - /// - /// Gets or sets the ReferanseDokumentfil - /// - [XmlElement(ElementName = "referanseDokumentfil")] - public string ReferanseDokumentfil { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - [XmlRoot(ElementName = "dokumentbeskrivelse")] - public class Dokumentbeskrivelse - { - /// - /// Gets or sets the SystemID - /// - [XmlElement(ElementName = "systemID")] - public string SystemID { get; set; } - - /// - /// Gets or sets the Dokumenttype - /// - [XmlElement(ElementName = "dokumenttype")] - public string Dokumenttype { get; set; } - - /// - /// Gets or sets the Dokumentstatus - /// - [XmlElement(ElementName = "dokumentstatus")] - public string Dokumentstatus { get; set; } - - /// - /// Gets or sets the Tittel - /// - [XmlElement(ElementName = "tittel")] - public string Tittel { get; set; } - - /// - /// Gets or sets the OpprettetDato - /// - [XmlElement(ElementName = "opprettetDato")] - public DateTime OpprettetDato { get; set; } - - /// - /// Gets or sets the OpprettetAv - /// - [XmlElement(ElementName = "opprettetAv")] - public string OpprettetAv { get; set; } - - /// - /// Gets or sets the TilknyttetRegistreringSom - /// - [XmlElement(ElementName = "tilknyttetRegistreringSom")] - public string TilknyttetRegistreringSom { get; set; } - - /// - /// Gets or sets the Dokumentnummer - /// - [XmlElement(ElementName = "dokumentnummer")] - public int Dokumentnummer { get; set; } - - /// - /// Gets or sets the TilknyttetDato - /// - [XmlElement(ElementName = "tilknyttetDato")] - public DateTime TilknyttetDato { get; set; } - - /// - /// Gets or sets the TilknyttetAv - /// - [XmlElement(ElementName = "tilknyttetAv")] - public string TilknyttetAv { get; set; } - - /// - /// Gets or sets the Dokumentobjekt - /// - [XmlElement(ElementName = "dokumentobjekt")] - public Dokumentobjekt Dokumentobjekt { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - [XmlRoot(ElementName = "korrespondansepart")] - public class Korrespondansepart - { - /// - /// Gets or sets the Korrespondanseparttype - /// - [XmlElement(ElementName = "korrespondanseparttype")] - public string Korrespondanseparttype { get; set; } - - /// - /// Gets or sets the KorrespondansepartNavn - /// - [XmlElement(ElementName = "korrespondansepartNavn")] - public string KorrespondansepartNavn { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - [XmlTypeAttribute(TypeName = "journalpost")] - public class Basisregistrering - { - /// - /// Gets or sets the SystemID - /// - [XmlElement(ElementName = "systemID")] - public string SystemID { get; set; } - - /// - /// Gets or sets the OpprettetDato - /// - [XmlElement(ElementName = "opprettetDato")] - public DateTime OpprettetDato { get; set; } - - /// - /// Gets or sets the OpprettetAv - /// - [XmlElement(ElementName = "opprettetAv")] - public string OpprettetAv { get; set; } - - /// - /// Gets or sets the ArkivertDato - /// - [XmlElement(ElementName = "arkivertDato")] - public DateTime ArkivertDato { get; set; } - - /// - /// Gets or sets the ArkivertAv - /// - [XmlElement(ElementName = "arkivertAv")] - public string ArkivertAv { get; set; } - - /// - /// Gets or sets the ReferanseForelderMappe - /// - [XmlElement(ElementName = "referanseForelderMappe")] - public string ReferanseForelderMappe { get; set; } - - /// - /// Gets or sets the Dokumentbeskrivelse - /// - [XmlElement(ElementName = "dokumentbeskrivelse")] - public Dokumentbeskrivelse Dokumentbeskrivelse { get; set; } - - /// - /// Gets or sets the Tittel - /// - [XmlElement(ElementName = "tittel")] - public string Tittel { get; set; } - - /// - /// Gets or sets the OffentligTittel - /// - [XmlElement(ElementName = "offentligTittel")] - public string OffentligTittel { get; set; } - - /// - /// Gets or sets the Journalposttype - /// - [XmlElement(ElementName = "journalposttype")] - public string Journalposttype { get; set; } - - /// - /// Gets or sets the Journalstatus - /// - [XmlElement(ElementName = "journalstatus")] - public string Journalstatus { get; set; } - - /// - /// Gets or sets the Journaldato - /// - [XmlElement(ElementName = "journaldato")] - public DateTime Journaldato { get; set; } - - /// - /// Gets or sets the Korrespondansepart - /// - [XmlElement(ElementName = "korrespondansepart")] - public Korrespondansepart Korrespondansepart { get; set; } - - /// - /// Gets or sets the Type - /// - [XmlAttribute(AttributeName = "type", Namespace = "http://www.w3.org/2001/XMLSchema-instance")] - public string Type { get; set; } - - /// - /// Gets or sets the Text - /// - [XmlText] - public string Text { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - [XmlTypeAttribute(TypeName = "saksmappe")] - public class Mappe - { - /// - /// Gets or sets the AdministrativEnhet - /// - [XmlElement(ElementName = "administrativEnhet", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string AdministrativEnhet { get; set; } - - /// - /// Gets or sets the Basisregistrering - /// - [XmlElement(ElementName = "basisregistrering", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public Basisregistrering Basisregistrering { get; set; } - - /// - /// Gets or sets the Klassifikasjon - /// - [XmlElement(ElementName = "klassifikasjon", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public List Klassifikasjon { get; set; } - - /// - /// Gets or sets the OpprettetAv - /// - [XmlElement(ElementName = "opprettetAv", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string OpprettetAv { get; set; } - - /// - /// Gets or sets the OpprettetDato - /// - [XmlElement(ElementName = "opprettetDato", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string OpprettetDato { get; set; } - - /// - /// Gets or sets the Saksansvarlig - /// - [XmlElement(ElementName = "saksansvarlig", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string Saksansvarlig { get; set; } - - /// - /// Gets or sets the Saksdato - /// - [XmlElement(ElementName = "saksdato", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string Saksdato { get; set; } - - /// - /// Gets or sets the Saksstatus - /// - [XmlElement(ElementName = "saksstatus", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string Saksstatus { get; set; } - - /// - /// Gets or sets the SystemID - /// - [XmlElement(ElementName = "systemID", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string SystemID { get; set; } - - /// - /// Gets or sets the Tittel - /// - [XmlElement(ElementName = "tittel", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string Tittel { get; set; } - - /// - /// Gets or sets the Type - /// - [XmlAttribute(AttributeName = "type", Namespace = "http://www.w3.org/2001/XMLSchema-instance")] - public string Type { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - [XmlRoot(ElementName = "klassifikasjon", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public class Klassifikasjon - { - /// - /// Gets or sets the KlasseID - /// - [XmlElement(ElementName = "klasseID", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string KlasseID { get; set; } - - /// - /// Gets or sets the OpprettetAv - /// - [XmlElement(ElementName = "opprettetAv", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string OpprettetAv { get; set; } - - /// - /// Gets or sets the OpprettetDato - /// - [XmlElement(ElementName = "opprettetDato", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string OpprettetDato { get; set; } - - /// - /// Gets or sets the ReferanseKlassifikasjonssystem - /// - [XmlElement(ElementName = "referanseKlassifikasjonssystem", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string ReferanseKlassifikasjonssystem { get; set; } - - /// - /// Gets or sets the Tittel - /// - [XmlElement(ElementName = "tittel", Namespace = "http://www.arkivverket.no/standarder/noark5/arkivmelding")] - public string Tittel { get; set; } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Capabilities.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Capabilities.cs deleted file mode 100644 index b077e97500d..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Capabilities.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Runtime.Serialization; -using System.Text.Json.Serialization; - -namespace Altinn.Common.EFormidlingClient.Models -{ - /// - /// Initializes a new instance of the class. - /// - public class Capabilities - { - /// - /// Gets or sets the Capabilities. - /// - [JsonPropertyName("capabilities")] - public List Capability { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - [DataContract] - public class DocumentType - { - /// - /// Gets or sets the Type. Document type. This is always identical to the last part of the standard. - /// - public string Type { get; set; } - - /// - /// Gets or sets the Standard. Document standard. - /// - public string Standard { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Capability - { - /// - /// Gets or sets the Process. Type of process. - /// - public string Process { get; set; } - - /// - /// Gets or sets the ServiceIdentifier. The service identifier. Can be one of: DPO, DPV, DPI, DPF, DPFIO, DPE, UNKNOWN - /// - public string ServiceIdentifier { get; set; } - - /// - /// Gets or sets the DocumentTypes - /// - public List DocumentTypes { get; set; } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Conversation.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Conversation.cs deleted file mode 100644 index 49bb1947414..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Conversation.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; - -namespace Altinn.Common.EFormidlingClient.Models -{ - /// - /// Initializes a new instance of the class. - /// - [ExcludeFromCodeCoverage] - public class Conversation - { - /// - /// Gets or sets the ID. Integer. The numeric message status ID. - /// - public int Id { get; set; } - - /// - /// Gets or sets the ConversationId. The conversationId - typically an UUID. - /// - public string ConversationId { get; set; } - - /// - /// Gets or sets the MessageId. The messageId - typically an UUID. - /// - public string MessageId { get; set; } - - /// - /// Gets or sets the SenderIdentifier. Descriptor with information to identify the sender. Requires a 0192: prefix for all norwegian organizations. - /// - public string SenderIdentifier { get; set; } - - /// - /// Gets or sets the ReceiverIdentifier. Descriptor with information to identify the receiver. Requires a 0192: prefix for all norwegian organizations. Prefix is not required for individuals. - /// - public string ReceiverIdentifier { get; set; } - - /// - /// Gets or sets the ProcessIdentifier. The process identifier used by the message. - /// - public string ProcessIdentifier { get; set; } - - /// - /// Gets or sets the MessageReference. The message reference - /// - public string MessageReference { get; set; } - - /// - /// Gets or sets the MessageTitle. The message title - /// - public string MessageTitle { get; set; } - - /// - /// Gets or sets the ServiceCode. Altinn service code - /// - public string ServiceCode { get; set; } - - /// - /// Gets or sets the ServiceEditionCode. Altinn service edition code. - /// - public string ServiceEditionCode { get; set; } - - /// - /// Gets or sets the LastUpdate. Date and time of status. - /// - public DateTime LastUpdate { get; set; } - - /// - /// Gets or sets the Finished. f the conversation has a finished state or not. - /// - public bool Finished { get; set; } - - /// - /// Gets or sets the Expiry. Expiry timestamp - /// - public DateTime Expiry { get; set; } - - /// - /// Gets or sets the Direction. The direction. Can be one of: OUTGOING, INCOMING - /// - public string Direction { get; set; } - - /// - /// Gets or sets the ServiceIdentifier. The service identifier. Can be one of: DPO, DPV, DPI, DPF, DPFIO, DPE, UNKNOWN - /// - public string ServiceIdentifier { get; set; } - - /// - /// Gets or sets the MessageStatuses. An array of message statuses. - /// - public List MessageStatuses { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class MessageStatus - { - /// - /// Gets or sets the Id. Integer. The numeric message status ID. - /// - public int Id { get; set; } - - /// - /// Gets or sets the LastUpdate. Date and time of status. - /// - public DateTime LastUpdate { get; set; } - - /// - /// Gets or sets the Status. The message status. Can be one of: OPPRETTET, SENDT, MOTTATT, LEVERT, LEST, FEIL, ANNET, INNKOMMENDE_MOTTATT, INNKOMMENDE_LEVERT, LEVETID_UTLOPT. - /// More details can be found here: https://docs.digdir.no/eformidling_selfhelp_traffic_flow.html - /// - public string Status { get; set; } - - /// - /// Gets or sets the Description - /// - public string Description { get; set; } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/CreateSubscription.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/CreateSubscription.cs deleted file mode 100644 index d3545f7a49f..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/CreateSubscription.cs +++ /dev/null @@ -1,46 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text; -using System.Text.Json.Serialization; - -namespace Altinn.EFormidlingClient.Models -{ - /// - /// Initializes a new instance of the class. - /// - [ExcludeFromCodeCoverage] - public class CreateSubscription - { - /// - /// Gets or sets the Name. A label to remember why it was created. Use it for whatever purpose you’d like. - /// - [JsonPropertyName("name")] - public string Name { get; set; } - - /// - /// Gets or sets the PushEndpoint. URL to push the webhook messages to. - /// - [JsonPropertyName("pushEndpoint")] - public string PushEndpoint { get; set; } - - /// - /// Gets or sets the Resource. Indicates the noun being observed. - /// - [JsonPropertyName("resource")] - public string Resource { get; set; } - - /// - /// Gets or sets the Event. Further narrows the events by specifying the action that would trigger a notification to your backend. - /// - [JsonPropertyName("event")] - public string Event { get; set; } - - /// - /// Gets or sets the Event. A set of filtering criteria. Generally speaking, webhook filters will be a subset of the query parameters available when GETing a list of the target resource. - /// It is an optional property. To add multiple filters, separate them with the "&" symbol. Supported filters are: status, serviceIdentifier, direction. - /// - [JsonPropertyName("filter")] - public string Filter { get; set; } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/StandardBusinessDocument.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/StandardBusinessDocument.cs deleted file mode 100644 index 275f9d28aed..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/StandardBusinessDocument.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Runtime.Serialization; -using System.Text.Json.Serialization; - -namespace Altinn.Common.EFormidlingClient.Models.SBD -{ - /// - /// Initializes a new instance of the class. - /// - public class StandardBusinessDocument - { - /// - /// Gets or sets the StandardBusinessDocumentHeader - /// - /// T - [JsonPropertyName("standardBusinessDocumentHeader")] - public StandardBusinessDocumentHeader StandardBusinessDocumentHeader { get; set; } - - /// - /// Gets or sets the Arkivmelding. Name of the attachment that is the main document.Especially when there are more than one attachment, there is a need to know which document is the main one.Should only be specified for DPF. - /// - [JsonPropertyName("arkivmelding")] - public Arkivmelding Arkivmelding { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Identifier - { - /// - /// Gets or sets the Value. Descriptor with information to identify this party. Requires a 0192: prefix for all norwegian organizations. Prefix is not required for individuals - /// - [JsonPropertyName("value")] - public string Value { get; set; } - - /// - /// Gets or sets the Authority. Descriptor that qualifies the identifier used to identify the receiving party. - /// - [JsonPropertyName("authority")] - public string Authority { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Sender - { - /// - /// Gets or sets the Identifier - /// - [JsonPropertyName("identifier")] - public Identifier Identifier { get; set; } - - /// - /// Gets or sets the ContactInformation - /// - [JsonPropertyName("contactInformation")] - public List ContactInformation { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Receiver - { - /// - /// Gets or sets the Identifier - /// - [JsonPropertyName("identifier")] - public Identifier Identifier { get; set; } - - /// - /// Gets or sets the ContactInformation - /// - [JsonPropertyName("contactInformation")] - public List ContactInformation { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class DocumentIdentification - { - /// - /// Gets or sets the Standard. The originator of the type of the Business Data standard, e.g. SWIFT, OAG, EAN.UCC, EDIFACT, X12; - /// references which Data Dictionary is being used. Used for the task of verifying that the grammar of a message is valid - /// - [JsonPropertyName("standard")] - public string Standard { get; set; } - - /// - /// Gets or sets the TypeVersion. Descriptor which contains versioning information or number of the standard that defines the document which is specified in the ’Type’ data element, e.g. values could be ‘1.3’ or ‘D.96A’, etc. - /// This is the version of the document itself and is different than the HeaderVersion. - /// - [JsonPropertyName("typeVersion")] - public string TypeVersion { get; set; } - - /// - /// Gets or sets the InstanceIdentifier. Descriptor which contains reference information which uniquely identifies this instance of the SBD between the sender and the receiver. - /// This identifier identifies this document as distinct from others. There is only one SBD instance per Standard Header. The Instance Identifier is automatically generated as an UUID if not specified. - /// - [JsonPropertyName("instanceIdentifier")] - public string InstanceIdentifier { get; set; } - - /// - /// Gets or sets the Type. A logical indicator representing the type of Business Data being sent or the named type of business data. This attribute identifies the type of document and not the instance of that document. - /// The instance document or interchange can contain one or more business documents of a single document type or closely related types. - /// The industry standard body (as referenced in the ‘Standard’ element) is responsible for defining the Type value to be used in this field. Currently NextMove supports the following types: - /// status, arkivmelding_kvittering, arkivmelding, avtalt, digital, digital_dpv, print, einnsyn_kvittering, innsynskrav, publisering - /// - [JsonPropertyName("type")] - public string Type { get; set; } - - /// - /// Gets or sets the CreationDateAndTime. Descriptor which contains date and time of SBDH/document creation. In the SBDH the parser translator or service component assigns the SBD a Date and Time stamp. - /// The creation date and time expressed here most likely will be different from the date and time stamped in the transport envelope. - /// - [JsonPropertyName("creationDateAndTime")] - public DateTime CreationDateAndTime { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class ScopeInformation - { - /// - /// Gets or sets the ExpectedResponseDateTime. Date and time when response is expected. This element could be populated in an - /// initial message of a correlation sequence, and should be echoed back in a subsequent response. - /// - [JsonPropertyName("expectedResponseDateTime")] - public DateTime ExpectedResponseDateTime { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Scope - { - /// - /// Gets or sets the Type. Indicates the kind of scope; an attribute describing the Scope. Example entries include: ConversationId, SenderRef, ReceiverRef - /// - [JsonPropertyName("type")] - public string Type { get; set; } - - /// - /// Gets or sets the InstanceIdentifier. A unique identifier that references the instance of the scope (e.g. process execution instance, document instance). For example, the Instance Identifier could be used to identify the specific instance of a Business Process. - /// This identifier would be used to correlate all the way back to the business domain layer; it can be thought of as a session descriptor at the business domain application level. - /// - [JsonPropertyName("instanceIdentifier")] - public string InstanceIdentifier { get; set; } - - /// - /// Gets or sets the Identifier. An optional unique descriptor that identifies the "contract" or "agreement" that this instance relates to. It operates at the level of business domain, not at the transport or messaging level - /// by providing the information necessary and sufficient to configure the service at the other partner’s end. - /// - [JsonPropertyName("identifier")] - public string Identifier { get; set; } - - /// - /// Gets or sets the ScopeInformation. An optional unique descriptor that identifies the "contract" or "agreement" that this instance relates to. It operates at the level of business domain, not at the transport or messaging level - /// by providing the information necessary and sufficient to configure the service at the other partner’s end. - /// - [JsonPropertyName("scopeInformation")] - public List ScopeInformation { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class BusinessScope - { - /// - /// Gets or sets the Scope. Indicates the type of scope, the identifiers for the scope, other supporting information and the scope content itself. - /// The importance of the Scope is that it allows the SBDH to operate under auspices of an agreement; that parties agree that they only include reference agreements - /// - [JsonPropertyName("scope")] - public List Scope { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class StandardBusinessDocumentHeader - { - /// - /// Gets or sets the HeaderVersion. - /// - [JsonPropertyName("headerVersion")] - public string HeaderVersion { get; set; } - - /// - /// Gets or sets the Sender. Logical party representing the organization that has created the standard business document. - /// - [JsonPropertyName("sender")] - public List Sender { get; set; } - - /// - /// Gets or sets the Receiver. Logical party representing the organization that receives the SBD. - /// - [JsonPropertyName("receiver")] - public List Receiver { get; set; } - - /// - /// Gets or sets the DocumentIdentification. Characteristics containing identification about the document. - /// - [JsonPropertyName("documentIdentification")] - public DocumentIdentification DocumentIdentification { get; set; } - - /// - /// Gets or sets the BusinessScope. The business scope contains 1 to many [1..*] scopes. It is not mandatory to put all intermediary scopes in an SBDH. Only those scopes that the parties agree to are valid. The following examples are all valid: transaction; business process; collaboration. A Profile may be used to group well-formedness rules together. The business scope block consists of the Scope block. - /// - [JsonPropertyName("businessScope")] - public BusinessScope BusinessScope { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Arkivmelding - { - /// - /// Gets or sets the Sikkerhetsnivaa. Defines the authentication level required for the document to be opened. - /// - [JsonPropertyName("sikkerhetsnivaa")] - public int Sikkerhetsnivaa { get; set; } - - /// - /// Gets or sets the DPF. Defines the configuration related to Digital post til FIKS meldingsformidler. - /// - [JsonPropertyName("dpf")] - public DPF DPF { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class DPF - { - /// - /// Gets or sets the ForsendelsesType. Used for routing on the receiving end. - /// - [JsonPropertyName("forsendelseType")] - public string ForsendelsesType { get; set; } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Statuses.cs b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Statuses.cs deleted file mode 100644 index 26cb84e2b20..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/Models/Statuses.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Text.Json.Serialization; - -namespace Altinn.Common.EFormidlingClient.Models -{ - /// - /// Entity representing Statuses. Initializes a new instance of the class. - /// - [ExcludeFromCodeCoverage] - public class Statuses - { - /// - /// Gets or sets the Content - /// - [JsonPropertyName("content")] - public List Content { get; set; } - - /// - /// Gets or sets the Pageable - /// - [JsonPropertyName("pageable")] - public Pageable Pageable { get; set; } - - /// - /// Gets or sets the TotalElements. The total number of elements - /// - [JsonPropertyName("totalElements")] - public int TotalElements { get; set; } - - /// - /// Gets or sets the Last. A boolean value indicating if this is the last page or not. - /// - [JsonPropertyName("last")] - public bool Last { get; set; } - - /// - /// Gets or sets the TotalPages. The total number of pages - /// - [JsonPropertyName("totalPages")] - public int TotalPages { get; set; } - - /// - /// Gets or sets the Sort - /// - [JsonPropertyName("sort")] - public Sort Sort { get; set; } - - /// - /// Gets or sets the NumberOfElements. Number of elements returned in the page. - /// - [JsonPropertyName("numberOfElements")] - public int NumberOfElements { get; set; } - - /// - /// Gets or sets the First. A boolean value indicating if this is the first page or not. - /// - [JsonPropertyName("first")] - public bool First { get; set; } - - /// - /// Gets or sets the Size. The page size - /// - [JsonPropertyName("size")] - public int Size { get; set; } - - /// - /// Gets or sets the Number. The page number - /// - [JsonPropertyName("number")] - public int Number { get; set; } - - /// - /// Gets or sets the Empty. True if the page is empty. False if not. - /// - [JsonPropertyName("empty")] - public bool Empty { get; set; } - } - - /// - /// Entity representing Content. Initializes a new instance of the class. - /// - public class Content - { - /// - /// Gets or sets the Id. The numeric message status ID. - /// - [JsonPropertyName("id")] - public int Id { get; set; } - - /// - /// Gets or sets the LastUpdate. Date and time of status. - /// - [JsonPropertyName("lastUpdate")] - public DateTime LastUpdate { get; set; } - - /// - /// Gets or sets the Status. The message status. Can be one of: OPPRETTET, SENDT, MOTTATT, LEVERT, LEST, FEIL, ANNET, INNKOMMENDE_MOTTATT, INNKOMMENDE_LEVERT, LEVETID_UTLOPT. - /// - [JsonPropertyName("status")] - public string Status { get; set; } - - /// - /// Gets or sets the Description - /// - [JsonPropertyName("description")] - public string Description { get; set; } - - /// - /// Gets or sets the ConvId. The numeric conversation ID. - /// - [JsonPropertyName("convId")] - public int ConvId { get; set; } - - /// - /// Gets or sets the ConversationId. The numeric conversation ID. - /// - [JsonPropertyName("conversationId")] - public string ConversationId { get; set; } - - /// - /// Gets or sets the MessageId. The messageId. Typically an UUID. - /// - [JsonPropertyName("messageId")] - public string MessageId { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Sort - { - /// - /// Gets or sets the Sorted. True if the result set is sorted. False otherwise. - /// - [JsonPropertyName("sorted")] - public bool Sorted { get; set; } - - /// - /// Gets or sets the Unsorted. True if the result set is unsorted. False otherwise. - /// - [JsonPropertyName("unsorted")] - public bool Unsorted { get; set; } - - /// - /// Gets or sets the Empty. True if no sorting. False otherwise - /// - [JsonPropertyName("empty")] - public bool Empty { get; set; } - } - - /// - /// Initializes a new instance of the class. - /// - public class Pageable - { - /// - /// Gets or sets the Sort - /// - [JsonPropertyName("sort")] - public Sort Sort { get; set; } - - /// - /// Gets or sets the PageNumber - /// - [JsonPropertyName("pageNumber")] - public int PageNumber { get; set; } - - /// - /// Gets or sets the PageSize - /// - [JsonPropertyName("pageSize")] - public int PageSize { get; set; } - - /// - /// Gets or sets the Offset. The offset to be taken according to the underlying page and page size. - /// - [JsonPropertyName("offset")] - public int Offset { get; set; } - - /// - /// Gets or sets the Paged - /// - [JsonPropertyName("paged")] - public bool Paged { get; set; } - - /// - /// Gets or sets the Unpaged - /// - [JsonPropertyName("unpaged")] - public bool Unpaged { get; set; } - } -} diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/README.md b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/README.md deleted file mode 100644 index 0285847edea..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn.EFormidlingClient/README.md +++ /dev/null @@ -1,108 +0,0 @@ - -## Getting Started - -These instructions will get help you setup eFormidlingClient package in your solution as well as how to test it. - -### Prerequisites - -The client package is written in C# and targets .Net Standard 2.1. The API solution which the package communicates with, i.e. the integration point, is written in Java. The integration point is possible to run locally with mock settings for testing purposes, requiring minimum Java 8 SDK. Ref test section. - -#### eFormidlingClient package -1. [.NET Standard 2.0](https://docs.microsoft.com/en-us/dotnet/standard/net-standard) -2. Code editor of your choice -3. Newest [Git](https://git-scm.com/downloads) -4. Solution is cloned - -#### Integration Point -1. [Java OpenJDK](https://openjdk.java.net/projects/jdk/15/) -2. [Integration Point](https://docs.digdir.no/eformidling_download_ip.html) - -#### Mock Solution for testing -1. [eFormidling Mock](https://github.com/felleslosninger/efm-mocks) -2. [Docker](https://docs.docker.com/docker-for-windows/install/) -3. [Nodejs](https://nodejs.org/en/download/) - - - -## Setup eFormidlingClient package in solution - -Download the eFormidlingClient nuget package from Nuget Package Manager where source is nuget.org. Search for its name: Altinn.Common.EFormidlingClient, then download and install. - -In order to debug the solution it is possible to retrieve the source code from: https://github.com/Altinn/altinn-studio and then add the project as a reference instead of the nuget package in Altinn.EFormidlingClient.csproj. - -In startup class, IEFormidlingClient and EFormidlingClientSettings are injected. - - -### Running eFormidling Integration Point locally -The integration point is a REST based API which is documented here: -https://docs.digdir.no/eformidling_nm_restdocs.html - -After downloading the Integration Point (IP) from https://docs.digdir.no/eformidling_download_ip.html, start the jar executable by running the following command in CLI: - -```cmd -java -Dspring.profiles.active=mock -jar integrasjonspunkt-.jar -``` - -Make sure that Java is set in the PATH and its version is at least 8. The -Dspring.profiles.active=mock argument indicates that the IP is running with mock properties, allowing for debug and testing locally during development. - -The solution should be ran with at least 2GB memory available. If needed, increase Java runtime parameter with following memory setting "-Xms2048m" as it defaults to 256 MB. In order to verify it is running correctly, run the tests specified in the test section or the tests that comes with the eFormidling Mock Solution. - -For more information, consult eFormidling Integration Point documentation at https://docs.digdir.no/eformidling_forutsetninger.html - - -### Running eFormidling mock solution locally - -Retrieve the mock solution by running: -```cmd -git clone https://github.com/difi/move-mocks.git. -``` -AON, March 2021, it is recommended to use the development branch as there were some problems with master (prod). - -Next, make sure the Integration Point is running locally and then run 'docker-compose up' in the root folder of the project. This will bring up the following services that constitute the mock solution: - -* localhost:8090: Wiremock - Simulates SR. -* localhost:8080: DPI mock. -* localhost:8001: DPO, DPV, DPF, og DPE mock. -* localhost:8002: Sak/arkivsystem mock. -* localhost:9094: Receiver Integration Point. - -localhost:8001 provides a dashboard that will display successfully sent messages. -localhost:8002 provides a dashboard that functions as a sak/arkivsystem on the receiver side. Here it is possible to perform end-to-end testing using DPO (Digital Post Offentlig) and DPE (Digital Post eInnsyn) service providers. - - -### Running tests eFormidling Client -The eFormidling Client library comes with unit- and integration tests. -Use your IDE to run the tests or use 'dotnet test' command to run the tests. The integration tests require that the integration point is running, also in some build server in CI/CD pipeline. In the appsettings there is a baseUrl, this should be pointed to the correct environment where the IP is running, e.g. if testing locally: - -```cmd -http://localhost:PORT/api/ -``` - -There is an Int test called Verify_Sent_Attachments. This can be used as an 'end-to-end' test, by examining the content of the package sent via eFormidling is correct. An ASIC-E container is built on the IP side containing all files sent. -In order to get access to this container, send the message to self, i.e. same senderId as receiverId. The message will become available on the incomming message queue. -First perform a peek of the queue, verify that the SBD and InstanceIdentifier is the correct ID. Next, pop the message to retrieve the ASIC-E. Download the content and write to file, and then delete the message from the queue. Open the file 'sent_package.zip' and examine the content. - -### Running tests move-mocks - -In order to test the mock solution and the integration point, navigate to the 'tests/next-move' folder. Run with Node the following command: node NextMove.js dpi dpiprint dpe dpf dpv dpo. This will execute a complete test. Verify in the dashboard on localhost:8001 that the messages were sent successfully. Moreover, the NextMove class written in Javascript, contains examples on how to create and send a message. - - -For more information, consult the README.md in the mock solution. - -## Contributing - -Please read [CONTRIBUTING.md](https://github.com/Altinn/altinn-studio/blob/master/CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. - -## Authors - -- **Altinn Studio development team** - If you want to get in touch, just [create a new issue](https://github.com/Altinn/altinn-studio/issues/new). - -See also the list of [contributors](https://github.com/Altinn/altinn-studio/graphs/contributors) who participated in this project. - -## License - -This project is licensed under the 3-Clause BSD License - see the [LICENSE.md](https://github.com/Altinn/altinn-studio/blob/master/LICENSE.md) file for details. - - - - diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn3.ruleset b/src/Altinn.Common/Altinn.EFormidlingClient/Altinn3.ruleset deleted file mode 100644 index f38bb26fda4..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Altinn3.ruleset +++ /dev/null @@ -1,177 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/Settings.StyleCop b/src/Altinn.Common/Altinn.EFormidlingClient/Settings.StyleCop deleted file mode 100644 index fd124ef0269..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/Settings.StyleCop +++ /dev/null @@ -1,237 +0,0 @@ - - - - preprocessor, pre-processor - shortlived, short-lived - - - altinn - arbeidsgiveravgift - aspx - BankID - brreg - Buypass - Commfides - compat - Compat.browser - Creuna - css - dequeue - Dequeue - deserializing - Determinator - enum - en-US - formset - Functoid - ID-Porten - js - leveranse - linq - msdn - oppgave - orid - participant - Porten - psa - referer - reportee - sone - ssn - subform - subforms - virksomhet - Winnovative - xfd - xsd - Guid - Api - OAuth - Auth - mpcId - mpc - Sdp - Difi - Difis - Rijndael - eq - orderby - Oppgaveregister - Seres - reportees - - 10000 - - - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - True - - - - - - - False - - - - - - - - - - False - - - - - False - - - - - - a1 - as - at - d - db - dn - do - dr - ds - dt - e - e2 - er - f - fs - go - id - if - in - ip - is - js - li - my - no - ns - on - or - pi - pv - sa - sb - se - si - so - sp - tc - to - tr - ui - un - wf - ws - x - y - - - - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - False - - - - - - - - - - False - - - - - - - - - - False - - - - - - - \ No newline at end of file diff --git a/src/Altinn.Common/Altinn.EFormidlingClient/stylecop.json b/src/Altinn.Common/Altinn.EFormidlingClient/stylecop.json deleted file mode 100644 index 4eef0f17a50..00000000000 --- a/src/Altinn.Common/Altinn.EFormidlingClient/stylecop.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - // ACTION REQUIRED: This file was automatically added to your project, but it - // will not take effect until additional steps are taken to enable it. See the - // following page for additional information: - // - // https://github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/documentation/EnableConfiguration.md - - "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", - "settings": { - "documentationRules": { - "companyName": "PlaceholderCompany" - }, - "orderingRules": { - "usingDirectivesPlacement": "outsideNamespace", - "systemUsingDirectivesFirst": true, - "blankLinesBetweenUsingGroups": "allow" - }, - "namingRules": { - "allowCommonHungarianPrefixes": true, - "allowedHungarianPrefixes": [ - "as", - "d", - "db", - "dn", - "do", - "dr", - "ds", - "dt", - "e", - "e2", - "er", - "f", - "fs", - "go", - "id", - "if", - "in", - "ip", - "is", - "js", - "li", - "my", - "no", - "ns", - "on", - "or", - "pi", - "pv", - "sa", - "sb", - "se", - "si", - "so", - "sp", - "tc", - "to", - "tr", - "ui", - "un", - "wf", - "ws", - "x", - "y", - "j", - "js" - ] - } - } -} From 005164995cbfe774903e9a8cd2a49a0698027544 Mon Sep 17 00:00:00 2001 From: Nina Kylstad Date: Mon, 9 Dec 2024 14:56:35 +0100 Subject: [PATCH 18/35] chore: Add new user story template (#14247) --- .github/ISSUE_TEMPLATE/user_story.yaml | 49 ++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/user_story.yaml diff --git a/.github/ISSUE_TEMPLATE/user_story.yaml b/.github/ISSUE_TEMPLATE/user_story.yaml new file mode 100644 index 00000000000..a38255cfbda --- /dev/null +++ b/.github/ISSUE_TEMPLATE/user_story.yaml @@ -0,0 +1,49 @@ +name: User Story 😃 +description: Create a new user story +labels: ["kind/user-story", "status/draft"] +body: + - type: markdown + attributes: + value: | + * Please make sure this user story hasn't been already submitted by someone by looking through other open/closed user stories. + * Consider the [INVEST](https://www.pivotaltracker.com/blog/how-to-invest-in-your-user-stories) qualities when writing the story + + - type: textarea + id: description + attributes: + label: Description + description: Give us a brief WHO, WHAT, and WHY of this user story. + value: | + As a [persona], I want to [do something] so that [I can achieve a goal]. + validations: + required: true + + - type: textarea + id: design + attributes: + label: Design - Screenshots and Figma links + description: Add screenshots where relevant, and always link to the Figma design if available. + + - type: textarea + id: additional-information + attributes: + label: Additional Information + description: Add more details as needed, like links, open questions, etc. + + - type: textarea + id: tasks + attributes: + label: Tasks + description: Add tasks to be done as part of this story. + + - type: textarea + id: acceptance-criterias + attributes: + label: Acceptance Criterias + description: Define the acceptance criterias that this user story should testet against + + - type: markdown + attributes: + value: | + * Check the [Definition of Ready](https://docs.altinn.studio/community/devops/definition-of-ready/) if you need hints on what to include. + * Remember to add the correct labels (status/*, org/*, ...) From 02612f1eb002fa412fec732ba0d80904bb44fa18 Mon Sep 17 00:00:00 2001 From: Martin Gunnerud Date: Mon, 9 Dec 2024 15:11:22 +0100 Subject: [PATCH 19/35] fix(resource-adm): show correct environments for org (#14252) --- .../Controllers/ResourceAdminController.cs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/src/Designer/Controllers/ResourceAdminController.cs b/backend/src/Designer/Controllers/ResourceAdminController.cs index 4d86405ae04..a6ac0fc4c38 100644 --- a/backend/src/Designer/Controllers/ResourceAdminController.cs +++ b/backend/src/Designer/Controllers/ResourceAdminController.cs @@ -14,7 +14,6 @@ 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; @@ -35,7 +34,6 @@ public class ResourceAdminController : ControllerBase private readonly CacheSettings _cacheSettings; private readonly IOrgService _orgService; private readonly IResourceRegistry _resourceRegistry; - private readonly IEnvironmentsService _environmentsService; public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRegistryOptions resourceRegistryOptions, IMemoryCache memoryCache, IOptions cacheSettings, IOrgService orgService, IResourceRegistry resourceRegistry, IEnvironmentsService environmentsService) { @@ -46,7 +44,6 @@ public ResourceAdminController(IGitea gitea, IRepository repository, IResourceRe _cacheSettings = cacheSettings.Value; _orgService = orgService; _resourceRegistry = resourceRegistry; - _environmentsService = environmentsService; } [HttpPost] @@ -176,7 +173,7 @@ public async Task>> GetRepositoryReso if (includeEnvResources) { - IEnumerable environments = await GetEnvironmentsForOrg(org); + IEnumerable environments = GetEnvironmentsForOrg(org); foreach (string environment in environments) { string cacheKey = $"resourcelist_${environment}"; @@ -242,7 +239,7 @@ public async Task> GetPublishStatusById(stri PublishedVersions = [] }; - IEnumerable environments = await GetEnvironmentsForOrg(org); + IEnumerable environments = GetEnvironmentsForOrg(org); foreach (string envir in environments) { resourceStatus.PublishedVersions.Add(await AddEnvironmentResourceStatus(envir, id)); @@ -655,10 +652,14 @@ private string GetRepositoryName(string org) return string.Format("{0}-resources", org); } - private async Task> GetEnvironmentsForOrg(string org) + private List GetEnvironmentsForOrg(string org) { - IEnumerable environments = await _environmentsService.GetOrganizationEnvironments(org); - return environments.Select(environment => environment.Name == "production" ? "prod" : environment.Name); + List environmentsForOrg = ["prod", "tt02"]; + if (OrgUtil.IsTestEnv(org)) + { + environmentsForOrg.AddRange(["at22", "at23", "at24", "yt01"]); + } + return environmentsForOrg; } } } From 61f7546b87d1573315962b5e679af18492503350 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Mon, 9 Dec 2024 16:18:57 +0100 Subject: [PATCH 20/35] refactor: Add Override type (#14248) Co-authored-by: andreastanderen <71079896+standeren@users.noreply.github.com> --- .../components/StudioButton/StudioButton.tsx | 14 +++++++---- .../StudioDecimalInput/StudioDecimalInput.tsx | 16 +++++++----- .../StudioTextResourcePicker.tsx | 25 ++++++++----------- .../studio-components/src/types/Override.ts | 1 + 4 files changed, 31 insertions(+), 25 deletions(-) create mode 100644 frontend/libs/studio-components/src/types/Override.ts diff --git a/frontend/libs/studio-components/src/components/StudioButton/StudioButton.tsx b/frontend/libs/studio-components/src/components/StudioButton/StudioButton.tsx index e02b555ca8d..7e1bfea3a50 100644 --- a/frontend/libs/studio-components/src/components/StudioButton/StudioButton.tsx +++ b/frontend/libs/studio-components/src/components/StudioButton/StudioButton.tsx @@ -8,12 +8,16 @@ import type { OverridableComponent } from '../../types/OverridableComponent'; import type { IconPlacement } from '../../types/IconPlacement'; import type { OverridableComponentRef } from '../../types/OverridableComponentRef'; import type { OverridableComponentProps } from '../../types/OverridableComponentProps'; +import type { Override } from '../../types/Override'; -export type StudioButtonProps = Omit & { - icon?: ReactNode; - iconPlacement?: IconPlacement; - color?: ButtonProps['color'] | 'inverted'; -}; +export type StudioButtonProps = Override< + { + icon?: ReactNode; + iconPlacement?: IconPlacement; + color?: ButtonProps['color'] | 'inverted'; + }, + Omit +>; const StudioButton: OverridableComponent = forwardRef( ( diff --git a/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx b/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx index 15cce037c26..ffa2989bff4 100644 --- a/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx +++ b/frontend/libs/studio-components/src/components/StudioDecimalInput/StudioDecimalInput.tsx @@ -8,13 +8,17 @@ import React, { } from 'react'; import { convertNumberToString, convertStringToNumber, isStringValidDecimalNumber } from './utils'; import { type StudioTextfieldProps, StudioTextfield } from '../StudioTextfield'; +import type { Override } from '../../types/Override'; -export interface StudioDecimalInputProps extends Omit { - description?: string; - onChange: (value: number) => void; - value?: number; - validationErrorMessage: string; -} +export type StudioDecimalInputProps = Override< + { + description?: string; + onChange: (value: number) => void; + value?: number; + validationErrorMessage: string; + }, + StudioTextfieldProps +>; export const StudioDecimalInput = forwardRef( ( diff --git a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx index 7b2c906b2d4..cd5ecd92fd4 100644 --- a/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx +++ b/frontend/libs/studio-components/src/components/StudioTextResourcePicker/StudioTextResourcePicker.tsx @@ -3,20 +3,17 @@ import React, { forwardRef, useCallback } from 'react'; import type { TextResource } from '../../types/TextResource'; import type { StudioComboboxProps } from '../StudioCombobox'; import { StudioCombobox } from '../StudioCombobox'; - -export type StudioTextResourcePickerProps = Omit & - OverriddenProps & - AdditionalProps; - -type OverriddenProps = { - onValueChange: (id: string) => void; - value?: string; -}; - -type AdditionalProps = { - emptyListText: string; - textResources: TextResource[]; -}; +import type { Override } from '../../types/Override'; + +export type StudioTextResourcePickerProps = Override< + { + emptyListText: string; + onValueChange: (id: string) => void; + textResources: TextResource[]; + value?: string; + }, + StudioComboboxProps +>; export const StudioTextResourcePicker = forwardRef( ({ textResources, onSelect, onValueChange, emptyListText, value, ...rest }, ref) => { diff --git a/frontend/libs/studio-components/src/types/Override.ts b/frontend/libs/studio-components/src/types/Override.ts new file mode 100644 index 00000000000..6d51e6135c6 --- /dev/null +++ b/frontend/libs/studio-components/src/types/Override.ts @@ -0,0 +1 @@ +export type Override = Primary & Omit; From 2ab780b533d6e6489353f7fd0f642c2103f8b1b7 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Mon, 9 Dec 2024 23:37:14 +0100 Subject: [PATCH 21/35] feat: Interface for feature flags (#14231) --- .../SettingsModal/SettingsModal.tsx | 8 +-- .../types/HeaderMenu/HeaderMenuItem.ts | 4 +- .../utils/headerMenu/headerMenuUtils.test.ts | 6 +- frontend/language/src/nb.json | 1 + .../src/contexts/BpmnContext.tsx | 7 ++- .../src/utils/featureToggleUtils.test.ts | 31 +++++----- .../shared/src/utils/featureToggleUtils.ts | 43 +++++++------- .../src/components/TextResource.test.tsx | 4 +- .../src/components/TextResource.tsx | 4 +- .../config/EditFormComponent.test.tsx | 7 ++- .../components/config/EditFormComponent.tsx | 11 ++-- .../Elements/LayoutSetsContainer.test.tsx | 9 +-- .../Elements/LayoutSetsContainer.tsx | 6 +- .../EditBinding/EditBinding.tsx | 6 +- .../EditBinding/SelectDataModelBinding.tsx | 4 +- .../OptionTabs/OptionTabs.test.tsx | 6 +- .../EditOptions/OptionTabs/OptionTabs.tsx | 4 +- .../OptionTabs/SelectTab/SelectTab.tsx | 2 +- .../AddItem/ToggleAddComponentPoc.tsx | 11 ++-- .../src/containers/DesignView/FormLayout.tsx | 4 +- .../DesignView/FormTree/FormItem/FormItem.tsx | 4 +- .../ux-editor/src/containers/FormDesigner.tsx | 10 ++-- .../ux-editor/src/data/formItemConfig.ts | 8 +-- .../pages/ResourcePage/ResourcePage.test.tsx | 4 +- .../pages/ResourcePage/ResourcePage.tsx | 4 +- frontend/studio-root/app/App.tsx | 3 +- .../pages/FlagsPage/FlagsPage.module.css | 6 ++ .../pages/FlagsPage/FlagsPage.test.tsx | 57 +++++++++++++++++++ .../studio-root/pages/FlagsPage/FlagsPage.tsx | 51 +++++++++++++++++ frontend/studio-root/pages/FlagsPage/index.ts | 1 + .../setFeatureFlagInLocalStorage.test.ts | 20 +++++++ .../FlagsPage/setFeatureFlagInLocalStorage.ts | 14 +++++ 32 files changed, 259 insertions(+), 101 deletions(-) create mode 100644 frontend/studio-root/pages/FlagsPage/FlagsPage.module.css create mode 100644 frontend/studio-root/pages/FlagsPage/FlagsPage.test.tsx create mode 100644 frontend/studio-root/pages/FlagsPage/FlagsPage.tsx create mode 100644 frontend/studio-root/pages/FlagsPage/index.ts create mode 100644 frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.test.ts create mode 100644 frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.ts diff --git a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/SettingsModal.tsx b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/SettingsModal.tsx index c5dad2b42fe..6df151a2914 100644 --- a/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/SettingsModal.tsx +++ b/frontend/app-development/layout/PageHeader/SubHeader/SettingsModalButton/SettingsModal/SettingsModal.tsx @@ -3,9 +3,9 @@ import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } import classes from './SettingsModal.module.css'; import { CogIcon } from '@studio/icons'; import { - StudioModal, StudioContentMenu, type StudioContentMenuButtonTabProps, + StudioModal, } from '@studio/components'; import type { SettingsModalTabId } from '../../../../../types/SettingsModalTabId'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,7 @@ import { SetupTab } from './components/Tabs/SetupTab'; import { type SettingsModalHandle } from '../../../../../types/SettingsModalHandle'; import { useSettingsModalMenuTabConfigs } from './hooks/useSettingsModalMenuTabConfigs'; import { Maskinporten } from './components/Tabs/Maskinporten'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; export const SettingsModal = forwardRef(({}, ref): ReactElement => { const { t } = useTranslation(); @@ -54,7 +54,7 @@ export const SettingsModal = forwardRef(({}, ref): Reac return ; } case 'maskinporten': { - return shouldDisplayFeature('maskinporten') ? : null; + return shouldDisplayFeature(FeatureFlag.Maskinporten) ? : null; } } }; @@ -94,7 +94,7 @@ SettingsModal.displayName = 'SettingsModal'; function filterFeatureFlag( menuTabConfigs: Array>, ) { - return shouldDisplayFeature('maskinporten') + return shouldDisplayFeature(FeatureFlag.Maskinporten) ? menuTabConfigs : menuTabConfigs.filter((tab) => tab.tabId !== 'maskinporten'); } diff --git a/frontend/app-development/types/HeaderMenu/HeaderMenuItem.ts b/frontend/app-development/types/HeaderMenu/HeaderMenuItem.ts index f0cb676fda3..562b13cd428 100644 --- a/frontend/app-development/types/HeaderMenu/HeaderMenuItem.ts +++ b/frontend/app-development/types/HeaderMenu/HeaderMenuItem.ts @@ -1,14 +1,14 @@ import { type HeaderMenuGroupKey } from 'app-development/enums/HeaderMenuGroupKey'; import { type HeaderMenuItemKey } from 'app-development/enums/HeaderMenuItemKey'; import { type RepositoryType } from 'app-shared/types/global'; -import { type SupportedFeatureFlags } from 'app-shared/utils/featureToggleUtils'; +import { type FeatureFlag } from 'app-shared/utils/featureToggleUtils'; export interface HeaderMenuItem { key: HeaderMenuItemKey; link: string; icon?: React.FC>; repositoryTypes: RepositoryType[]; - featureFlagName?: SupportedFeatureFlags; + featureFlagName?: FeatureFlag; isBeta?: boolean; group: HeaderMenuGroupKey; } diff --git a/frontend/app-development/utils/headerMenu/headerMenuUtils.test.ts b/frontend/app-development/utils/headerMenu/headerMenuUtils.test.ts index 29a4b8f3d16..14d2bfe5f05 100644 --- a/frontend/app-development/utils/headerMenu/headerMenuUtils.test.ts +++ b/frontend/app-development/utils/headerMenu/headerMenuUtils.test.ts @@ -15,7 +15,7 @@ import { RoutePaths } from 'app-development/enums/RoutePaths'; import { DatabaseIcon } from '@studio/icons'; import { HeaderMenuGroupKey } from 'app-development/enums/HeaderMenuGroupKey'; import { typedLocalStorage } from '@studio/pure-functions'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; jest.mock('app-shared/utils/featureToggleUtils'); @@ -137,7 +137,7 @@ describe('headerMenuUtils', () => { icon: DatabaseIcon, repositoryTypes: [RepositoryType.App, RepositoryType.DataModels], group: HeaderMenuGroupKey.Tools, - featureFlagName: 'shouldOverrideAppLibCheck', + featureFlagName: FeatureFlag.ShouldOverrideAppLibCheck, }; expect(filterRoutesByFeatureFlag(menuItem)).toBe(true); @@ -152,7 +152,7 @@ describe('headerMenuUtils', () => { icon: DatabaseIcon, repositoryTypes: [RepositoryType.App, RepositoryType.DataModels], group: HeaderMenuGroupKey.Tools, - featureFlagName: 'shouldOverrideAppLibCheck', + featureFlagName: FeatureFlag.ShouldOverrideAppLibCheck, }; expect(filterRoutesByFeatureFlag(menuItem)).toBe(false); diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 9c19cc6a6fa..36829085b11 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -281,6 +281,7 @@ "expression.valueType.null": "Ikke satt", "expression.valueType.number": "Tall", "expression.valueType.string": "Tekst", + "feature_flags.heading": "Funksjonsflagg", "form_filler.file_uploader_validation_error_upload": "Noe gikk galt da filen skulle lastes opp, prøv igjen senere.", "general.action": "Handling", "general.actions": "Handlinger", diff --git a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx index 7f858681841..b07090475eb 100644 --- a/frontend/packages/process-editor/src/contexts/BpmnContext.tsx +++ b/frontend/packages/process-editor/src/contexts/BpmnContext.tsx @@ -1,6 +1,6 @@ -import React, { createContext, useContext, useRef, useState, type MutableRefObject } from 'react'; +import React, { createContext, type MutableRefObject, useContext, useRef, useState } from 'react'; import { supportsProcessEditor } from '../utils/processEditorUtils'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; import type Modeler from 'bpmn-js/lib/Modeler'; import type { BpmnDetails } from '../types/BpmnDetails'; @@ -29,7 +29,8 @@ export const BpmnContextProvider = ({ const [bpmnDetails, setBpmnDetails] = useState(null); const isEditAllowed = - supportsProcessEditor(appLibVersion) || shouldDisplayFeature('shouldOverrideAppLibCheck'); + supportsProcessEditor(appLibVersion) || + shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck); const modelerRef = useRef(null); diff --git a/frontend/packages/shared/src/utils/featureToggleUtils.test.ts b/frontend/packages/shared/src/utils/featureToggleUtils.test.ts index 82ad734b839..592f3299e99 100644 --- a/frontend/packages/shared/src/utils/featureToggleUtils.test.ts +++ b/frontend/packages/shared/src/utils/featureToggleUtils.test.ts @@ -3,6 +3,7 @@ import { addFeatureFlagToLocalStorage, removeFeatureFlagFromLocalStorage, shouldDisplayFeature, + FeatureFlag, } from './featureToggleUtils'; describe('featureToggle localStorage', () => { @@ -10,21 +11,21 @@ describe('featureToggle localStorage', () => { it('should return true if feature is enabled in the localStorage', () => { typedLocalStorage.setItem('featureFlags', ['shouldOverrideAppLibCheck']); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy(); }); it('should return true if featureFlag includes in feature params', () => { typedLocalStorage.setItem('featureFlags', ['demo', 'shouldOverrideAppLibCheck']); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy(); }); it('should return false if feature is not enabled in the localStorage', () => { typedLocalStorage.setItem('featureFlags', ['demo']); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy(); }); it('should return false if feature is not enabled in the localStorage', () => { - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy(); }); }); @@ -35,22 +36,22 @@ describe('featureToggle url', () => { }); it('should return true if feature is enabled in the url', () => { window.history.pushState({}, 'PageUrl', '/?featureFlags=shouldOverrideAppLibCheck'); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy(); }); it('should return true if featureFlag includes in feature params', () => { window.history.pushState({}, 'PageUrl', '/?featureFlags=demo,shouldOverrideAppLibCheck'); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy(); }); it('should return false if feature is not included in the url', () => { window.history.pushState({}, 'PageUrl', '/?featureFlags=demo'); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy(); }); it('should return false if feature is not included in the url', () => { window.history.pushState({}, 'PageUrl', '/'); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeFalsy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeFalsy(); }); it('should persist features in sessionStorage when persistFeatureFlag is set in url', () => { @@ -59,9 +60,9 @@ describe('featureToggle url', () => { 'PageUrl', '/?featureFlags=resourceMigration,shouldOverrideAppLibCheck&persistFeatureFlag=true', ); - expect(shouldDisplayFeature('componentConfigBeta')).toBeFalsy(); - expect(shouldDisplayFeature('shouldOverrideAppLibCheck')).toBeTruthy(); - expect(shouldDisplayFeature('resourceMigration')).toBeTruthy(); + expect(shouldDisplayFeature(FeatureFlag.ComponentConfigBeta)).toBeFalsy(); + expect(shouldDisplayFeature(FeatureFlag.ShouldOverrideAppLibCheck)).toBeTruthy(); + expect(shouldDisplayFeature(FeatureFlag.ResourceMigration)).toBeTruthy(); expect(typedSessionStorage.getItem('featureFlags')).toEqual([ 'shouldOverrideAppLibCheck', 'resourceMigration', @@ -75,14 +76,14 @@ describe('addFeatureToLocalStorage', () => { typedLocalStorage.removeItem('featureFlags'); }); it('should add feature to local storage', () => { - addFeatureFlagToLocalStorage('shouldOverrideAppLibCheck'); + addFeatureFlagToLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck); expect(typedLocalStorage.getItem('featureFlags')).toEqual([ 'shouldOverrideAppLibCheck', ]); }); it('should append provided feature to existing features in local storage', () => { typedLocalStorage.setItem('featureFlags', ['demo']); - addFeatureFlagToLocalStorage('shouldOverrideAppLibCheck'); + addFeatureFlagToLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck); expect(typedLocalStorage.getItem('featureFlags')).toEqual([ 'demo', 'shouldOverrideAppLibCheck', @@ -96,12 +97,12 @@ describe('removeFeatureFromLocalStorage', () => { }); it('should remove feature from local storage', () => { typedLocalStorage.setItem('featureFlags', ['shouldOverrideAppLibCheck']); - removeFeatureFlagFromLocalStorage('shouldOverrideAppLibCheck'); + removeFeatureFlagFromLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck); expect(typedLocalStorage.getItem('featureFlags')).toEqual([]); }); it('should only remove specified feature from local storage', () => { typedLocalStorage.setItem('featureFlags', ['shouldOverrideAppLibCheck', 'demo']); - removeFeatureFlagFromLocalStorage('shouldOverrideAppLibCheck'); + removeFeatureFlagFromLocalStorage(FeatureFlag.ShouldOverrideAppLibCheck); expect(typedLocalStorage.getItem('featureFlags')).toEqual(['demo']); }); }); diff --git a/frontend/packages/shared/src/utils/featureToggleUtils.ts b/frontend/packages/shared/src/utils/featureToggleUtils.ts index 6314d0254d2..242a026db79 100644 --- a/frontend/packages/shared/src/utils/featureToggleUtils.ts +++ b/frontend/packages/shared/src/utils/featureToggleUtils.ts @@ -3,26 +3,25 @@ import { typedLocalStorage, typedSessionStorage } from '@studio/pure-functions'; const featureFlagKey = 'featureFlags'; const persistFeatureKey = 'persistFeatureFlag'; -// All the features that you want to be toggle on/off should be added here. To ensure that we type check the feature name. -export type SupportedFeatureFlags = - | 'componentConfigBeta' - | 'shouldOverrideAppLibCheck' - | 'resourceMigration' - | 'multipleDataModelsPerTask' - | 'exportForm' - | 'addComponentModal' - | 'subform' - | 'summary2' - | 'codeListEditor' - | 'optionListEditor' - | 'maskinporten'; +export enum FeatureFlag { + AddComponentModal = 'addComponentModal', + ComponentConfigBeta = 'componentConfigBeta', + ExportForm = 'exportForm', + Maskinporten = 'maskinporten', + MultipleDataModelsPerTask = 'multipleDataModelsPerTask', + OptionListEditor = 'optionListEditor', + ResourceMigration = 'resourceMigration', + ShouldOverrideAppLibCheck = 'shouldOverrideAppLibCheck', + Subform = 'subform', + Summary2 = 'summary2', +} /* * Please add all the features that you want to be toggle on by default here. * Remember that all the features that are listed here will be available to the users in production, * since this is the default active features. */ -const defaultActiveFeatures: SupportedFeatureFlags[] = []; +const defaultActiveFeatures: FeatureFlag[] = []; /** * @param featureFlag @@ -31,7 +30,7 @@ const defaultActiveFeatures: SupportedFeatureFlags[] = []; * @example shouldDisplayFeature('myFeatureName') && * @example The feature can be toggled and persisted by the url query, (url)?featureFlags=[featureName]&persistFeatureFlag=true */ -export const shouldDisplayFeature = (featureFlag: SupportedFeatureFlags): boolean => { +export const shouldDisplayFeature = (featureFlag: FeatureFlag): boolean => { // Check if feature should be persisted in session storage, (url)?persistFeatureFlag=true if (shouldPersistInSession() && isFeatureActivatedByUrl(featureFlag)) { addFeatureFlagToSessionStorage(featureFlag); @@ -46,12 +45,12 @@ export const shouldDisplayFeature = (featureFlag: SupportedFeatureFlags): boolea }; // Check if the feature is one of the default active features -const isDefaultActivatedFeature = (featureFlag: SupportedFeatureFlags): boolean => { +const isDefaultActivatedFeature = (featureFlag: FeatureFlag): boolean => { return defaultActiveFeatures.includes(featureFlag); }; // Check if feature includes in the url query, (url)?featureFlags=[featureName] -const isFeatureActivatedByUrl = (featureFlag: SupportedFeatureFlags): boolean => { +const isFeatureActivatedByUrl = (featureFlag: FeatureFlag): boolean => { const urlParams = new URLSearchParams(window.location.search); const featureParam = urlParams.get(featureFlagKey); @@ -64,7 +63,7 @@ const isFeatureActivatedByUrl = (featureFlag: SupportedFeatureFlags): boolean => }; // Check if feature includes in local storage, featureFlags: ["featureName"] -const isFeatureActivatedByLocalStorage = (featureFlag: SupportedFeatureFlags): boolean => { +export const isFeatureActivatedByLocalStorage = (featureFlag: FeatureFlag): boolean => { const featureFlagsFromStorage = typedLocalStorage.getItem(featureFlagKey) || []; return featureFlagsFromStorage.includes(featureFlag); }; @@ -74,7 +73,7 @@ const isFeatureActivatedByLocalStorage = (featureFlag: SupportedFeatureFlags): b * @description This function will add the feature flag to local storage * @example addFeatureToLocalStorage('myFeatureName') */ -export const addFeatureFlagToLocalStorage = (featureFlag: SupportedFeatureFlags): void => { +export const addFeatureFlagToLocalStorage = (featureFlag: FeatureFlag): void => { const featureFlagsFromStorage = typedLocalStorage.getItem(featureFlagKey) || []; featureFlagsFromStorage.push(featureFlag); typedLocalStorage.setItem(featureFlagKey, featureFlagsFromStorage); @@ -85,14 +84,14 @@ export const addFeatureFlagToLocalStorage = (featureFlag: SupportedFeatureFlags) * @description This function will remove the feature flag from local storage * @example removeFeatureFromLocalStorage('myFeatureName') */ -export const removeFeatureFlagFromLocalStorage = (featureFlag: SupportedFeatureFlags): void => { +export const removeFeatureFlagFromLocalStorage = (featureFlag: FeatureFlag): void => { const featureFlagsFromStorage = typedLocalStorage.getItem(featureFlagKey) || []; const filteredFeatureFlags = featureFlagsFromStorage.filter((feature) => feature !== featureFlag); typedLocalStorage.setItem(featureFlagKey, filteredFeatureFlags); }; // Check if feature includes in session storage, featureFlags: ["featureName"] -const isFeatureActivatedBySessionStorage = (featureFlag: SupportedFeatureFlags): boolean => { +const isFeatureActivatedBySessionStorage = (featureFlag: FeatureFlag): boolean => { const featureFlagsFromStorage = typedSessionStorage.getItem(featureFlagKey) || []; return featureFlagsFromStorage.includes(featureFlag); }; @@ -105,7 +104,7 @@ const shouldPersistInSession = (): boolean => { }; // Add feature to session storage to persist the feature in the current session -const addFeatureFlagToSessionStorage = (featureFlag: SupportedFeatureFlags): void => { +const addFeatureFlagToSessionStorage = (featureFlag: FeatureFlag): void => { const featureFlagsFromStorage = typedSessionStorage.getItem(featureFlagKey) || []; const featureFlagAlreadyExist = featureFlagsFromStorage.includes(featureFlag); diff --git a/frontend/packages/ux-editor-v3/src/components/TextResource.test.tsx b/frontend/packages/ux-editor-v3/src/components/TextResource.test.tsx index c36a3c55dce..85d94bd88f7 100644 --- a/frontend/packages/ux-editor-v3/src/components/TextResource.test.tsx +++ b/frontend/packages/ux-editor-v3/src/components/TextResource.test.tsx @@ -11,7 +11,7 @@ import { textMock } from '@studio/testing/mocks/i18nMock'; import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery'; import { DEFAULT_LANGUAGE } from 'app-shared/constants'; import { typedLocalStorage } from '@studio/pure-functions'; -import { addFeatureFlagToLocalStorage } from 'app-shared/utils/featureToggleUtils'; +import { addFeatureFlagToLocalStorage, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; import { app, org } from '@studio/testing/testids'; const user = userEvent.setup(); @@ -226,7 +226,7 @@ describe('TextResource', () => { }); it('Renders delete button as enabled when handleRemoveTextResource is given and componentConfigBeta feature flag is enabled', async () => { - addFeatureFlagToLocalStorage('componentConfigBeta'); + addFeatureFlagToLocalStorage(FeatureFlag.ComponentConfigBeta); await render({ textResourceId: 'test', handleRemoveTextResource: jest.fn() }); expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeEnabled(); }); diff --git a/frontend/packages/ux-editor-v3/src/components/TextResource.tsx b/frontend/packages/ux-editor-v3/src/components/TextResource.tsx index 333dd7a169e..05bb3ca70d8 100644 --- a/frontend/packages/ux-editor-v3/src/components/TextResource.tsx +++ b/frontend/packages/ux-editor-v3/src/components/TextResource.tsx @@ -19,7 +19,7 @@ import type { ITextResource } from 'app-shared/types/global'; import { FormField } from './FormField'; import { AltinnConfirmDialog } from 'app-shared/components/AltinnConfirmDialog'; import { useTranslation } from 'react-i18next'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; import { StudioButton, StudioNativeSelect } from '@studio/components'; export interface TextResourceProps { @@ -181,7 +181,7 @@ export const TextResource = ({ color='second' disabled={ !handleRemoveTextResource || - !(!!textResourceId || shouldDisplayFeature('componentConfigBeta')) + !(!!textResourceId || shouldDisplayFeature(FeatureFlag.ComponentConfigBeta)) } icon={} onClick={() => setIsConfirmDeleteDialogOpen(true)} diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx index 6ddf4fcd5c1..4157a123ddb 100644 --- a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx +++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx @@ -11,7 +11,10 @@ import type { DataModelMetadataResponse } from 'app-shared/types/api'; import { dataModelNameMock, layoutSet1NameMock } from '@altinn/ux-editor-v3/testing/layoutSetsMock'; import { app, org } from '@studio/testing/testids'; import { textMock } from '@studio/testing/mocks/i18nMock'; -import { removeFeatureFlagFromLocalStorage } from 'app-shared/utils/featureToggleUtils'; +import { + removeFeatureFlagFromLocalStorage, + FeatureFlag, +} from 'app-shared/utils/featureToggleUtils'; // Test data: const srcValueLabel = 'Source'; @@ -68,7 +71,7 @@ const getDataModelMetadata = () => describe('EditFormComponent', () => { beforeEach(() => { - removeFeatureFlagFromLocalStorage('componentConfigBeta'); + removeFeatureFlagFromLocalStorage(FeatureFlag.ComponentConfigBeta); jest.clearAllMocks(); }); diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.tsx b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.tsx index d1238471a18..1608ead0f91 100644 --- a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.tsx +++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.tsx @@ -1,9 +1,9 @@ import React from 'react'; import type { EditSettings, IGenericEditComponent } from './componentConfig'; -import { configComponents, componentSpecificEditConfig } from './componentConfig'; +import { componentSpecificEditConfig, configComponents } from './componentConfig'; import { ComponentSpecificContent } from './componentSpecificContent'; -import { Switch, Fieldset, Heading } from '@digdir/designsystemet-react'; +import { Fieldset, Heading, Switch } from '@digdir/designsystemet-react'; import classes from './EditFormComponent.module.css'; import type { FormComponent } from '../../types/FormComponent'; import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors'; @@ -19,6 +19,7 @@ import { addFeatureFlagToLocalStorage, removeFeatureFlagFromLocalStorage, shouldDisplayFeature, + FeatureFlag, } from 'app-shared/utils/featureToggleUtils'; import { formItemConfigs } from '../../data/formItemConfig'; import { UnknownComponentAlert } from '../UnknownComponentAlert'; @@ -37,7 +38,7 @@ export const EditFormComponent = ({ const selectedLayout = useSelector(selectedLayoutNameSelector); const { t } = useTranslation(); const [showComponentConfigBeta, setShowComponentConfigBeta] = React.useState( - shouldDisplayFeature('componentConfigBeta'), + shouldDisplayFeature(FeatureFlag.ComponentConfigBeta), ); useLayoutSchemaQuery(); // Ensure we load the layout schemas so that component schemas can be loaded @@ -65,9 +66,9 @@ export const EditFormComponent = ({ setShowComponentConfigBeta(event.target.checked); // Ensure choice of feature toggling is persisted in local storage if (event.target.checked) { - addFeatureFlagToLocalStorage('componentConfigBeta'); + addFeatureFlagToLocalStorage(FeatureFlag.ComponentConfigBeta); } else { - removeFeatureFlagFromLocalStorage('componentConfigBeta'); + removeFeatureFlagFromLocalStorage(FeatureFlag.ComponentConfigBeta); } }; 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 258cb6bd773..bc76a9faeb4 100644 --- a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.test.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.test.tsx @@ -17,6 +17,7 @@ import { app, org } from '@studio/testing/testids'; import { addFeatureFlagToLocalStorage, removeFeatureFlagFromLocalStorage, + FeatureFlag, } from 'app-shared/utils/featureToggleUtils'; import { textMock } from '@studio/testing/mocks/i18nMock'; import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; @@ -65,7 +66,7 @@ describe('LayoutSetsContainer', () => { }); it('should render the delete subform button when feature is enabled and selected layoutset is a subform', () => { - addFeatureFlagToLocalStorage('subform'); + addFeatureFlagToLocalStorage(FeatureFlag.Subform); render({ layoutSets: { sets: [{ id: layoutSet3SubformNameMock, type: 'subform' }] }, selectedLayoutSet: layoutSet3SubformNameMock, @@ -74,11 +75,11 @@ describe('LayoutSetsContainer', () => { name: textMock('ux_editor.delete.subform'), }); expect(deleteSubformButton).toBeInTheDocument(); - removeFeatureFlagFromLocalStorage('subform'); + removeFeatureFlagFromLocalStorage(FeatureFlag.Subform); }); it('should not render the delete subform button when feature is enabled and selected layoutset is not a subform', () => { - addFeatureFlagToLocalStorage('subform'); + addFeatureFlagToLocalStorage(FeatureFlag.Subform); render({ layoutSets: { sets: [{ id: layoutSet1NameMock, dataType: 'data-model' }] }, selectedLayoutSet: layoutSet1NameMock, @@ -87,7 +88,7 @@ describe('LayoutSetsContainer', () => { name: textMock('ux_editor.delete.subform'), }); expect(deleteSubformButton).not.toBeInTheDocument(); - removeFeatureFlagFromLocalStorage('subform'); + removeFeatureFlagFromLocalStorage(FeatureFlag.Subform); }); it('should not render the delete subform button when feature is disabled', () => { diff --git a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx index 5604810d357..e2c9d1a17a2 100644 --- a/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx +++ b/frontend/packages/ux-editor/src/components/Elements/LayoutSetsContainer.tsx @@ -3,7 +3,7 @@ import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmen import { useAppContext } from '../../hooks'; import classes from './LayoutSetsContainer.module.css'; import { ExportForm } from './ExportForm'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; import { StudioCombobox } from '@studio/components'; import { DeleteSubformWrapper } from './Subform/DeleteSubformWrapper'; import { useLayoutSetsExtendedQuery } from 'app-shared/hooks/queries/useLayoutSetsExtendedQuery'; @@ -60,8 +60,8 @@ export function LayoutSetsContainer() { ))} - {shouldDisplayFeature('exportForm') && } - {shouldDisplayFeature('subform') && ( + {shouldDisplayFeature(FeatureFlag.ExportForm) && } + {shouldDisplayFeature(FeatureFlag.Subform) && ( { }); it('should render EditOptionChoice when featureFlag is enabled', async () => { - addFeatureFlagToLocalStorage('optionListEditor'); + addFeatureFlagToLocalStorage(FeatureFlag.OptionListEditor); const optionsId = 'optionsId'; renderEditOptions({ componentProps: { @@ -131,7 +131,7 @@ describe('EditOptions', () => { }); it('should switch to referenceId input clicking referenceId tab', async () => { - addFeatureFlagToLocalStorage('optionListEditor'); + addFeatureFlagToLocalStorage(FeatureFlag.OptionListEditor); const user = userEvent.setup(); renderEditOptions({ componentProps: { options: [] }, diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx index 3c1dafe76c8..062d7e595d9 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/OptionTabs.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { StudioTabs } from '@studio/components'; import { ReferenceTab } from './ReferenceTab/ReferenceTab'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; import { ManualTab } from './ManualTab'; import { EditTab } from './EditTab'; import { SelectedOptionsType } from '../EditOptions'; @@ -22,7 +22,7 @@ type OptionTabsProps = { export function OptionTabs({ component, handleComponentChange, optionListIds }: OptionTabsProps) { return ( <> - {shouldDisplayFeature('optionListEditor') ? ( + {shouldDisplayFeature(FeatureFlag.OptionListEditor) ? ( { - if (shouldDisplayFeature('addComponentModal')) { - removeFeatureFlagFromLocalStorage('addComponentModal'); + if (shouldDisplayFeature(FeatureFlag.AddComponentModal)) { + removeFeatureFlagFromLocalStorage(FeatureFlag.AddComponentModal); } else { - addFeatureFlagToLocalStorage('addComponentModal'); + addFeatureFlagToLocalStorage(FeatureFlag.AddComponentModal); } window.location.reload(); }; @@ -30,7 +31,7 @@ export function ToggleAddComponentPoc(): React.ReactElement { <>
@@ -51,7 +52,7 @@ export function ToggleAddComponentPoc(): React.ReactElement {
- {shouldDisplayFeature('addComponentModal') && } + {shouldDisplayFeature(FeatureFlag.AddComponentModal) && } ); } diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx index 36ec06ee09c..65e02b368b6 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormLayout.tsx @@ -7,7 +7,7 @@ import { Alert, Paragraph } from '@digdir/designsystemet-react'; import { FormLayoutWarning } from './FormLayoutWarning'; import { BASE_CONTAINER_ID } from 'app-shared/constants'; import { AddItem } from './AddItem'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; export interface FormLayoutProps { layout: IInternalLayout; @@ -24,7 +24,7 @@ export const FormLayout = ({ layout, isInvalid, duplicateComponents }: FormLayou {hasMultiPageGroup(layout) && } {/** 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') && ( + {shouldDisplayFeature(FeatureFlag.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 7fcfe5f7496..3a3c5299c92 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 @@ -9,7 +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'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; export type FormItemProps = { layout: IInternalLayout; @@ -39,7 +39,7 @@ export const FormItem = ({ layout, id, duplicateComponents }: FormItemProps) => ); const shouldDisplayAddButton = - isContainer(layout, id) && shouldDisplayFeature('addComponentModal'); + isContainer(layout, id) && shouldDisplayFeature(FeatureFlag.AddComponentModal); return ( { const { org, app } = useStudioEnvironmentParams(); @@ -164,7 +164,7 @@ export const FormDesigner = (): JSX.Element => { * The following check is done for a live user test behind feature flag. It can be removed if this is not something * that is going to be used in the future. */} - {!shouldDisplayFeature('addComponentModal') && ( + {!shouldDisplayFeature(FeatureFlag.AddComponentModal) && ( { )} diff --git a/frontend/packages/ux-editor/src/data/formItemConfig.ts b/frontend/packages/ux-editor/src/data/formItemConfig.ts index a8e7c62e615..c22711e41e1 100644 --- a/frontend/packages/ux-editor/src/data/formItemConfig.ts +++ b/frontend/packages/ux-editor/src/data/formItemConfig.ts @@ -21,7 +21,6 @@ import { LongTextIcon, NavBarIcon, PaperclipIcon, - TextIcon, PaymentDetailsIcon, PinIcon, PresentationIcon, @@ -31,6 +30,7 @@ import { ShortTextIcon, TableIcon, TasklistIcon, + TextIcon, TitleIcon, WalletIcon, } from '@studio/icons'; @@ -38,7 +38,7 @@ import type { ContainerComponentType } from '../types/ContainerComponent'; import { LayoutItemType } from '../types/global'; import type { ComponentSpecificConfig } from 'app-shared/types/ComponentSpecificConfig'; import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; import { FilterUtils } from './FilterUtils'; export type FormItemConfig = { @@ -507,7 +507,7 @@ export const advancedItems: FormItemConfigs[ComponentType][] = [ formItemConfigs[ComponentType.Custom], formItemConfigs[ComponentType.RepeatingGroup], formItemConfigs[ComponentType.PaymentDetails], - shouldDisplayFeature('subform') && formItemConfigs[ComponentType.Subform], + shouldDisplayFeature(FeatureFlag.Subform) && formItemConfigs[ComponentType.Subform], ].filter(FilterUtils.filterOutDisabledFeatureItems); export const schemaComponents: FormItemConfigs[ComponentType][] = [ @@ -532,7 +532,7 @@ export const schemaComponents: FormItemConfigs[ComponentType][] = [ formItemConfigs[ComponentType.IFrame], formItemConfigs[ComponentType.InstanceInformation], formItemConfigs[ComponentType.Summary], - shouldDisplayFeature('summary2') && formItemConfigs[ComponentType.Summary2], + shouldDisplayFeature(FeatureFlag.Summary2) && formItemConfigs[ComponentType.Summary2], ].filter(FilterUtils.filterOutDisabledFeatureItems); export const textComponents: FormItemConfigs[ComponentType][] = [ diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx b/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx index 306efcc6677..4fd93e753e5 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.test.tsx @@ -10,7 +10,7 @@ import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; import type { QueryClient } from '@tanstack/react-query'; import { queriesMock } from 'app-shared/mocks/queriesMock'; -import { addFeatureFlagToLocalStorage } from 'app-shared/utils/featureToggleUtils'; +import { addFeatureFlagToLocalStorage, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; const mockResource1: Resource = { identifier: 'r1', @@ -93,7 +93,7 @@ describe('ResourcePage', () => { }); it('displays migrate tab in left navigation bar when resource reference is present in resource', async () => { - addFeatureFlagToLocalStorage('resourceMigration'); + addFeatureFlagToLocalStorage(FeatureFlag.ResourceMigration); const getResource = jest .fn() diff --git a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx index ec780461b0f..f22bb83b974 100644 --- a/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx +++ b/frontend/resourceadm/pages/ResourcePage/ResourcePage.tsx @@ -26,7 +26,7 @@ import { ResourceAccessLists } from '../../components/ResourceAccessLists'; import { AccessListDetail } from '../../components/AccessListDetails'; import { useGetAccessListQuery } from '../../hooks/queries/useGetAccessListQuery'; import { useUrlParams } from '../../hooks/useUrlParams'; -import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { shouldDisplayFeature, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; import { StudioContentMenu } from '@studio/components'; import type { StudioContentMenuButtonTabProps } from '@studio/components'; @@ -162,7 +162,7 @@ export const ResourcePage = (): React.JSX.Element => { * Decide if the migration page should be accessible or not */ const isMigrateEnabled = (): boolean => { - return !!altinn2References && shouldDisplayFeature('resourceMigration'); + return !!altinn2References && shouldDisplayFeature(FeatureFlag.ResourceMigration); }; const aboutPageId = 'about'; diff --git a/frontend/studio-root/app/App.tsx b/frontend/studio-root/app/App.tsx index 6f86bf1ab7e..bb433c3e175 100644 --- a/frontend/studio-root/app/App.tsx +++ b/frontend/studio-root/app/App.tsx @@ -4,10 +4,10 @@ import { Route, Routes } from 'react-router-dom'; import { StudioNotFoundPage } from '@studio/components'; import { Paragraph, Link } from '@digdir/designsystemet-react'; import { useTranslation, Trans } from 'react-i18next'; - import './App.css'; import { PageLayout } from '../pages/PageLayout'; import { ContactPage } from '../pages/Contact/ContactPage'; +import { FlagsPage } from '../pages/FlagsPage'; export const App = (): JSX.Element => { return ( @@ -15,6 +15,7 @@ export const App = (): JSX.Element => { }> } /> + } /> } /> diff --git a/frontend/studio-root/pages/FlagsPage/FlagsPage.module.css b/frontend/studio-root/pages/FlagsPage/FlagsPage.module.css new file mode 100644 index 00000000000..05642c64ac6 --- /dev/null +++ b/frontend/studio-root/pages/FlagsPage/FlagsPage.module.css @@ -0,0 +1,6 @@ +.root { + display: flex; + flex-direction: column; + gap: var(--fds-spacing-3); + padding: var(--fds-spacing-6); +} diff --git a/frontend/studio-root/pages/FlagsPage/FlagsPage.test.tsx b/frontend/studio-root/pages/FlagsPage/FlagsPage.test.tsx new file mode 100644 index 00000000000..29e2f87332f --- /dev/null +++ b/frontend/studio-root/pages/FlagsPage/FlagsPage.test.tsx @@ -0,0 +1,57 @@ +import { FeatureFlag } from 'app-shared/utils/featureToggleUtils'; +import { typedLocalStorage } from '@studio/pure-functions'; // Todo: Move this to a more suitable place: https://github.com/Altinn/altinn-studio/issues/14230 +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { FlagsPage } from './FlagsPage'; +import React from 'react'; +import { userEvent } from '@testing-library/user-event'; + +const flags: FeatureFlag[] = Object.values(FeatureFlag); + +describe('FlagsPage', () => { + beforeEach(() => typedLocalStorage.removeItem('featureFlags')); + + it('Renders a checkbox for each flag', () => { + renderFlagsPage(); + flags.forEach((flag) => { + expect(screen.getByRole('checkbox', { name: flag })).toBeInTheDocument(); + }); + }); + + it('Renders the chechkboxes as unchecked by default', () => { + renderFlagsPage(); + flags.forEach((flag) => { + expect(screen.getByRole('checkbox', { name: flag })).not.toBeChecked(); + }); + }); + + it('Renders the chechkbox as checked when the corresponding flag is enabled', () => { + const enabledFlag = flags[0]; + typedLocalStorage.setItem('featureFlags', [enabledFlag]); + renderFlagsPage(); + expect(screen.getByRole('checkbox', { name: enabledFlag })).toBeChecked(); + }); + + it('Adds the flag to the list of enabled flags when the user checks the checkbox', async () => { + const user = userEvent.setup(); + renderFlagsPage(); + const flagToEnable = flags[0]; + const checkbox = screen.getByRole('checkbox', { name: flagToEnable }); + await user.click(checkbox); + expect(typedLocalStorage.getItem('featureFlags')).toEqual([flagToEnable]); + }); + + it('Removes the flag from the list of enabled flags when the user unchecks the checkbox', async () => { + const user = userEvent.setup(); + const enabledFlag = flags[0]; + typedLocalStorage.setItem('featureFlags', [enabledFlag]); + renderFlagsPage(); + const checkbox = screen.getByRole('checkbox', { name: enabledFlag }); + await user.click(checkbox); + expect(typedLocalStorage.getItem('featureFlags')).toEqual([]); + }); +}); + +function renderFlagsPage(): RenderResult { + return render(); +} diff --git a/frontend/studio-root/pages/FlagsPage/FlagsPage.tsx b/frontend/studio-root/pages/FlagsPage/FlagsPage.tsx new file mode 100644 index 00000000000..b5c62f72689 --- /dev/null +++ b/frontend/studio-root/pages/FlagsPage/FlagsPage.tsx @@ -0,0 +1,51 @@ +import type { ChangeEvent, ReactElement } from 'react'; +import React, { useCallback, useState } from 'react'; +import { isFeatureActivatedByLocalStorage, FeatureFlag } from 'app-shared/utils/featureToggleUtils'; +import { StudioSwitch, StudioCodeFragment, StudioHeading } from '@studio/components'; +import { setFeatureFlagInLocalStorage } from './setFeatureFlagInLocalStorage'; +import classes from './FlagsPage.module.css'; +import { useTranslation } from 'react-i18next'; + +export function FlagsPage(): ReactElement { + const { t } = useTranslation(); + + return ( +
+ {t('feature_flags.heading')} + +
+ ); +} + +function FlagList(): ReactElement { + return ( + <> + {Object.values(FeatureFlag).map((flag) => { + return ; + })} + + ); +} + +type FeatureFlagProps = { + flagName: FeatureFlag; +}; + +function Flag({ flagName }: FeatureFlagProps): ReactElement { + const [enabled, setEnabled] = useState(isFeatureActivatedByLocalStorage(flagName)); + + const handleToggle = useCallback( + (e: ChangeEvent): void => { + const { checked } = e.target; + setFeatureFlagInLocalStorage(flagName, checked); + setEnabled(checked); + }, + [flagName, setEnabled], + ); + + return ( + + {flagName} + + ); +} diff --git a/frontend/studio-root/pages/FlagsPage/index.ts b/frontend/studio-root/pages/FlagsPage/index.ts new file mode 100644 index 00000000000..dd833255979 --- /dev/null +++ b/frontend/studio-root/pages/FlagsPage/index.ts @@ -0,0 +1 @@ +export * from './FlagsPage'; diff --git a/frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.test.ts b/frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.test.ts new file mode 100644 index 00000000000..c58aacbe0cf --- /dev/null +++ b/frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.test.ts @@ -0,0 +1,20 @@ +import { typedLocalStorage } from '@studio/pure-functions'; // Todo: Move this to a more suitable place: https://github.com/Altinn/altinn-studio/issues/14230 +import { setFeatureFlagInLocalStorage } from './setFeatureFlagInLocalStorage'; +import type { FeatureFlag } from 'app-shared/utils/featureToggleUtils'; + +const testFlag = 'testFeature' as FeatureFlag; // Using casting here instead of a real flag because the list will change over time + +describe('setFeatureFlagInLocalStorage', () => { + beforeEach(() => typedLocalStorage.removeItem('featureFlags')); + + it('Adds the feature flag to the local storage when the state is true', () => { + setFeatureFlagInLocalStorage(testFlag, true); + expect(typedLocalStorage.getItem('featureFlags')).toEqual([testFlag]); + }); + + it('Removes the feature flag from the local storage when the state is false', () => { + typedLocalStorage.setItem('featureFlags', [testFlag]); + setFeatureFlagInLocalStorage(testFlag, false); + expect(typedLocalStorage.getItem('featureFlags')).toEqual([]); + }); +}); diff --git a/frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.ts b/frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.ts new file mode 100644 index 00000000000..6b0dad1e7f7 --- /dev/null +++ b/frontend/studio-root/pages/FlagsPage/setFeatureFlagInLocalStorage.ts @@ -0,0 +1,14 @@ +import type { FeatureFlag } from 'app-shared/utils/featureToggleUtils'; +import { + addFeatureFlagToLocalStorage, + removeFeatureFlagFromLocalStorage, +} from 'app-shared/utils/featureToggleUtils'; + +export function setFeatureFlagInLocalStorage(flag: FeatureFlag, state: boolean): void { + const changeInLocalStorage = retrieveChangeFunction(state); + return changeInLocalStorage(flag); +} + +function retrieveChangeFunction(state: boolean): (flag: FeatureFlag) => void { + return state ? addFeatureFlagToLocalStorage : removeFeatureFlagFromLocalStorage; +} From bf8df32b08a132aa5a77afcdda0305af82172dcd Mon Sep 17 00:00:00 2001 From: William Thorenfeldt <48119543+wrt95@users.noreply.github.com> Date: Tue, 10 Dec 2024 00:08:47 +0100 Subject: [PATCH 22/35] refactor: replace altinn confirm dialog with studio components (#14197) Co-authored-by: David Ovrelid <46874830+framitdavid@users.noreply.github.com> --- .../components/{ => Deploy}/Deploy.test.tsx | 3 +- .../components/{ => Deploy}/Deploy.tsx | 2 +- .../DeployDropdown}/DeployDropdown.module.css | 0 .../DeployDropdown/DeployDropdown.test.tsx | 200 +++++++++++ .../Deploy/DeployDropdown/DeployDropdown.tsx | 82 +++++ .../DeployPopover/DeployPopover.module.css | 16 + .../DeployPopover/DeployPopover.test.tsx | 145 ++++++++ .../DeployPopover/DeployPopover.tsx | 83 +++++ .../DeployDropdown/DeployPopover/index.ts | 1 + .../components/Deploy/DeployDropdown/index.ts | 1 + .../Deploy/DeployDropdown/utils.test.ts | 97 ++++++ .../components/Deploy/DeployDropdown/utils.ts | 30 ++ .../appPublish/components/Deploy/index.ts | 1 + .../components/DeployDropdown.test.tsx | 315 ------------------ .../appPublish/components/DeployDropdown.tsx | 115 ------- frontend/language/src/nb.json | 1 + 16 files changed, 660 insertions(+), 432 deletions(-) rename frontend/app-development/features/appPublish/components/{ => Deploy}/Deploy.test.tsx (98%) rename frontend/app-development/features/appPublish/components/{ => Deploy}/Deploy.tsx (97%) rename frontend/app-development/features/appPublish/components/{ => Deploy/DeployDropdown}/DeployDropdown.module.css (100%) create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.test.tsx create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.tsx create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.module.css create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.test.tsx create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.tsx create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/index.ts create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/index.ts create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.test.ts create mode 100644 frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.ts create mode 100644 frontend/app-development/features/appPublish/components/Deploy/index.ts delete mode 100644 frontend/app-development/features/appPublish/components/DeployDropdown.test.tsx delete mode 100644 frontend/app-development/features/appPublish/components/DeployDropdown.tsx diff --git a/frontend/app-development/features/appPublish/components/Deploy.test.tsx b/frontend/app-development/features/appPublish/components/Deploy/Deploy.test.tsx similarity index 98% rename from frontend/app-development/features/appPublish/components/Deploy.test.tsx rename to frontend/app-development/features/appPublish/components/Deploy/Deploy.test.tsx index 113dd7be2b4..a0f458efa4d 100644 --- a/frontend/app-development/features/appPublish/components/Deploy.test.tsx +++ b/frontend/app-development/features/appPublish/components/Deploy/Deploy.test.tsx @@ -9,6 +9,7 @@ import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import type { AppRelease } from 'app-shared/types/AppRelease'; import { appRelease } from 'app-shared/mocks/mocks'; import { BuildResult } from 'app-shared/types/Build'; +import { type ImageOption } from '../ImageOption'; const defaultProps: DeployProps = { appDeployedVersion: 'test', @@ -42,7 +43,7 @@ const appReleases: AppRelease[] = [ }, ]; -const imageOptions = [ +const imageOptions: ImageOption[] = [ { label: textMock('app_deployment.version_label', { tagName: appReleases[0].tagName, diff --git a/frontend/app-development/features/appPublish/components/Deploy.tsx b/frontend/app-development/features/appPublish/components/Deploy/Deploy.tsx similarity index 97% rename from frontend/app-development/features/appPublish/components/Deploy.tsx rename to frontend/app-development/features/appPublish/components/Deploy/Deploy.tsx index abbac066020..fac3b69db3d 100644 --- a/frontend/app-development/features/appPublish/components/Deploy.tsx +++ b/frontend/app-development/features/appPublish/components/Deploy/Deploy.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { DeployDropdown } from './DeployDropdown'; -import { useCreateDeploymentMutation } from '../../../hooks/mutations'; +import { useCreateDeploymentMutation } from '../../../../hooks/mutations'; import { Trans, useTranslation } from 'react-i18next'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { toast } from 'react-toastify'; diff --git a/frontend/app-development/features/appPublish/components/DeployDropdown.module.css b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.module.css similarity index 100% rename from frontend/app-development/features/appPublish/components/DeployDropdown.module.css rename to frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.module.css diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.test.tsx b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.test.tsx new file mode 100644 index 00000000000..0ad424c32ff --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { DeployDropdownProps } from './DeployDropdown'; +import { DeployDropdown } from './DeployDropdown'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { renderWithProviders } from 'app-development/test/mocks'; +import type { AppRelease } from 'app-shared/types/AppRelease'; +import { BuildResult } from 'app-shared/types/Build'; +import { appRelease } from 'app-shared/mocks/mocks'; +import { type ImageOption } from '../../ImageOption'; + +const defaultProps: DeployDropdownProps = { + appDeployedVersion: '', + disabled: false, + setSelectedImageTag: jest.fn(), + selectedImageTag: 'test1', + startDeploy: jest.fn(), + isPending: false, +}; + +const created = '01.01.2024 18:53'; + +const appReleases: AppRelease[] = [ + { + ...appRelease, + tagName: 'test1', + created, + build: { + ...appRelease.build, + result: BuildResult.succeeded, + }, + }, + { + ...appRelease, + tagName: 'test2', + created, + build: { + ...appRelease.build, + result: BuildResult.succeeded, + }, + }, +]; + +const imageOptions: ImageOption[] = [ + { + label: textMock('app_deployment.version_label', { + tagName: appReleases[0].tagName, + createdDateTime: created, + }), + value: appReleases[0].tagName, + }, + { + label: textMock('app_deployment.version_label', { + tagName: appReleases[1].tagName, + createdDateTime: created, + }), + value: appReleases[1].tagName, + }, +]; + +describe('DeployDropdown', () => { + afterEach(jest.clearAllMocks); + + it('renders a spinner while loading data', () => { + renderDeployDropdown(); + + expect(screen.getByTitle(textMock('app_deployment.releases_loading'))).toBeInTheDocument(); + }); + + it('renders an error message if an error occurs while loading data', async () => { + renderDeployDropdown( + {}, + { + getAppReleases: jest.fn().mockImplementation(() => Promise.reject()), + }, + ); + await waitForElementToBeRemoved(() => + screen.queryByTitle(textMock('app_deployment.releases_loading')), + ); + + expect(screen.getByText(textMock('app_deployment.releases_error'))).toBeInTheDocument(); + }); + + it('render no image options message when image options are empty', async () => { + const user = userEvent.setup(); + + renderDeployDropdown( + {}, + { + getAppReleases: jest.fn().mockImplementation(() => + Promise.resolve({ + results: [], + }), + ), + }, + ); + await waitForSpinnerToBeRemoved(); + + const select = await screen.findByLabelText(textMock('app_deployment.choose_version')); + await user.click(select); + + expect(screen.getByText(textMock('app_deployment.no_versions'))).toBeInTheDocument(); + }); + + it('renders image options', async () => { + const user = userEvent.setup(); + + renderDeployDropdown(); + await waitForSpinnerToBeRemoved(); + + const select = screen.getByLabelText(textMock('app_deployment.choose_version')); + await user.click(select); + + expect(screen.getByRole('option', { name: imageOptions[0].label })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: imageOptions[1].label })).toBeInTheDocument(); + }); + + it('selects default image option', async () => { + renderDeployDropdown({ selectedImageTag: imageOptions[0].value }); + await waitForSpinnerToBeRemoved(); + + expect(screen.getByRole('combobox')).toHaveValue(imageOptions[0].label); + }); + + it('selects new image option', async () => { + const user = userEvent.setup(); + + renderDeployDropdown(); + await waitForSpinnerToBeRemoved(); + + const select = screen.getByLabelText(textMock('app_deployment.choose_version')); + await user.click(select); + + const option = screen.getByRole('option', { name: imageOptions[1].label }); + await user.click(option); + + await waitFor(() => { + expect(defaultProps.setSelectedImageTag).toHaveBeenCalledWith(imageOptions[1].value); + }); + }); + + it('shows a loding spinner when mutation is pending', async () => { + renderDeployDropdown({ isPending: true }); + await waitForSpinnerToBeRemoved(); + + expect(screen.getByTitle(textMock('app_deployment.deploy_loading'))).toBeInTheDocument(); + }); + + it('disables both dropdown and button when deploy is not possible', async () => { + renderDeployDropdown({ disabled: true }); + await waitForSpinnerToBeRemoved(); + + expect(screen.getByLabelText(textMock('app_deployment.choose_version'))).toBeDisabled(); + + const deployButton = screen.getByRole('button', { + name: textMock('app_deployment.btn_deploy_new_version'), + }); + expect(deployButton).toBeDisabled(); + }); + + it('should confirm and close the dialog when clicking the confirm button', async () => { + const user = userEvent.setup(); + + renderDeployDropdown(); + await waitForSpinnerToBeRemoved(); + + const deployButton = screen.getByRole('button', { + name: textMock('app_deployment.btn_deploy_new_version'), + }); + await user.click(deployButton); + + const confirmButton = screen.getByRole('button', { name: textMock('general.yes') }); + await user.click(confirmButton); + + expect(defaultProps.startDeploy).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); +}); + +const waitForSpinnerToBeRemoved = async () => { + await waitForElementToBeRemoved(() => + screen.queryByTitle(textMock('app_deployment.releases_loading')), + ); +}; + +const renderDeployDropdown = ( + props?: Partial, + queries?: Partial, +) => { + return renderWithProviders({ + getAppReleases: jest.fn().mockImplementation(() => + Promise.resolve({ + results: appReleases, + }), + ), + ...queries, + })(); +}; diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.tsx b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.tsx new file mode 100644 index 00000000000..4df23e450ce --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployDropdown.tsx @@ -0,0 +1,82 @@ +import React, { type ReactElement } from 'react'; +import classes from './DeployDropdown.module.css'; +import { StudioCombobox, StudioError, StudioSpinner } from '@studio/components'; +import type { ImageOption } from '../../ImageOption'; +import { useTranslation } from 'react-i18next'; +import { useAppReleasesQuery } from 'app-development/hooks/queries'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { DeployPopover } from './DeployPopover'; +import { type AppRelease } from 'app-shared/types/AppRelease'; +import { filterSucceededReleases, mapAppReleasesToImageOptions } from './utils'; + +export type DeployDropdownProps = { + appDeployedVersion: string; + disabled: boolean; + setSelectedImageTag: (tag: string) => void; + selectedImageTag: string; + startDeploy: () => void; + isPending: boolean; +}; + +export const DeployDropdown = ({ + appDeployedVersion, + selectedImageTag, + setSelectedImageTag, + disabled, + startDeploy, + isPending, +}: DeployDropdownProps): ReactElement => { + const { org, app } = useStudioEnvironmentParams(); + const { t } = useTranslation(); + + const { + data: releases = [], + isPending: isPendingReleases, + isError: hasReleasesError, + } = useAppReleasesQuery(org, app, { hideDefaultError: true }); + + if (isPendingReleases) + return ( + + ); + + if (hasReleasesError) return {t('app_deployment.releases_error')}; + + const successfullyBuiltAppReleases: AppRelease[] = filterSucceededReleases(releases); + const imageOptions: ImageOption[] = mapAppReleasesToImageOptions(successfullyBuiltAppReleases, t); + + const hasSelectedImageTag = selectedImageTag && imageOptions?.length > 0; + const selectedVersion = hasSelectedImageTag ? [selectedImageTag] : undefined; + + return ( +
+ + setSelectedImageTag(selectedImageOptions[0]) + } + disabled={disabled} + > + {imageOptions.map((imageOption: ImageOption) => { + return ( + + {imageOption.label} + + ); + })} + {t('app_deployment.no_versions')} + +
+ +
+
+ ); +}; diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.module.css b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.module.css new file mode 100644 index 00000000000..e66a243b79a --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.module.css @@ -0,0 +1,16 @@ +.popover { + position: fixed !important; + max-width: 25rem; + padding: var(--fds-spacing-4); + z-index: 1301; +} + +.popover > *:first-child { + margin-top: 0; +} + +.buttonContainer { + display: flex; + flex-direction: row; + gap: var(--fds-spacing-2); +} diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.test.tsx b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.test.tsx new file mode 100644 index 00000000000..c783dc4550e --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { DeployPopover, type DeployPopoverProps } from './DeployPopover'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import '@testing-library/jest-dom'; +import { type ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { renderWithProviders } from 'app-development/test/mocks'; +import { type AppRelease } from 'app-shared/types/AppRelease'; +import { appRelease } from 'app-shared/mocks/mocks'; +import { BuildResult } from 'app-shared/types/Build'; +import userEvent from '@testing-library/user-event'; + +const created = '01.01.2024 18:53'; +const appReleases: AppRelease[] = [ + { + ...appRelease, + tagName: 'test1', + created, + build: { + ...appRelease.build, + result: BuildResult.succeeded, + }, + }, + { + ...appRelease, + tagName: 'test2', + created, + build: { + ...appRelease.build, + result: BuildResult.succeeded, + }, + }, +]; + +const defaultProps: DeployPopoverProps = { + appDeployedVersion: '1.0.0', + selectedImageTag: '1.1.0', + disabled: false, + isPending: false, + onConfirm: jest.fn(), +}; + +describe('DeployPopover', () => { + it('should render the deploy button with the correct text', () => { + renderDeployPopover(); + expect( + screen.getByRole('button', { name: textMock('app_deployment.btn_deploy_new_version') }), + ).toBeInTheDocument(); + }); + + it('should disable the button if no selected image tag is provided', () => { + renderDeployPopover({ componentProps: { selectedImageTag: '' } }); + + expect( + screen.getByRole('button', { name: textMock('app_deployment.btn_deploy_new_version') }), + ).toBeDisabled(); + }); + + it('should show a spinner if deployment is pending', () => { + renderDeployPopover({ componentProps: { isPending: true } }); + + expect(screen.getByTitle(textMock('app_deployment.deploy_loading'))).toBeInTheDocument(); + }); + + it('should display the deploy confirmation message with app version when the popover is open', async () => { + const user = userEvent.setup(); + renderDeployPopover(); + + const button = screen.getByRole('button', { + name: textMock('app_deployment.btn_deploy_new_version'), + }); + await user.click(button); + + expect( + screen.getByText( + textMock('app_deployment.deploy_confirmation', { + selectedImageTag: '1.1.0', + appDeployedVersion: '1.0.0', + }), + ), + ).toBeInTheDocument(); + }); + + it('should display the short confirmation message if no app version is provided', async () => { + const user = userEvent.setup(); + renderDeployPopover({ componentProps: { appDeployedVersion: '' } }); + + const button = screen.getByRole('button', { + name: textMock('app_deployment.btn_deploy_new_version'), + }); + await user.click(button); + + expect( + screen.getByText( + textMock('app_deployment.deploy_confirmation_short', { selectedImageTag: '1.1.0' }), + ), + ).toBeInTheDocument(); + }); + + it('should call onConfirm and close the popover when "Yes" button is clicked', async () => { + const user = userEvent.setup(); + const onConfirmMock = jest.fn(); + renderDeployPopover({ componentProps: { onConfirm: onConfirmMock } }); + + const button = screen.getByRole('button', { + name: textMock('app_deployment.btn_deploy_new_version'), + }); + await user.click(button); + await user.click(screen.getByRole('button', { name: textMock('general.yes') })); + + expect(onConfirmMock).toHaveBeenCalledTimes(1); + expect(screen.queryByText(textMock('general.yes'))).not.toBeInTheDocument(); + }); + + it('should close the popover when "Cancel" button is clicked', async () => { + const user = userEvent.setup(); + renderDeployPopover(); + + const button = screen.getByRole('button', { + name: textMock('app_deployment.btn_deploy_new_version'), + }); + await user.click(button); + await user.click(screen.getByRole('button', { name: textMock('general.cancel') })); + + expect(screen.queryByText(textMock('general.cancel'))).not.toBeInTheDocument(); + }); +}); + +type Props = { + componentProps?: Partial; + queries?: Partial; +}; + +const renderDeployPopover = (props: Partial = {}) => { + const { componentProps, queries } = props; + + return renderWithProviders({ + getAppReleases: jest.fn().mockImplementation(() => + Promise.resolve({ + results: appReleases, + }), + ), + ...queries, + })(); +}; diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.tsx b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.tsx new file mode 100644 index 00000000000..8e62fa68923 --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/DeployPopover.tsx @@ -0,0 +1,83 @@ +import React, { type ReactElement, useState } from 'react'; +import classes from './DeployPopover.module.css'; +import { StudioButton, StudioParagraph, StudioPopover, StudioSpinner } from '@studio/components'; +import { useTranslation } from 'react-i18next'; + +export type DeployPopoverProps = { + appDeployedVersion: string; + selectedImageTag: string; + disabled: boolean; + isPending: boolean; + onConfirm: () => void; +}; + +export const DeployPopover = ({ + appDeployedVersion, + selectedImageTag, + disabled, + isPending, + onConfirm, +}: DeployPopoverProps): ReactElement => { + const { t } = useTranslation(); + + const [isConfirmDeployDialogOpen, setIsConfirmDeployDialogOpen] = useState(); + + return ( + + setIsConfirmDeployDialogOpen((prevState) => !prevState)} + size='sm' + > + {isPending && } + {t('app_deployment.btn_deploy_new_version')} + + + + {appDeployedVersion + ? t('app_deployment.deploy_confirmation', { + selectedImageTag, + appDeployedVersion, + }) + : t('app_deployment.deploy_confirmation_short', { selectedImageTag })} + +
+ ) => { + event.stopPropagation(); + onConfirm(); + setIsConfirmDeployDialogOpen(false); + }} + size='sm' + > + {t('general.yes')} + + ) => { + event.stopPropagation(); + setIsConfirmDeployDialogOpen(false); + }} + size='sm' + > + {t('general.cancel')} + +
+
+
+ ); +}; + +const PopoverSpinner = () => { + const { t } = useTranslation(); + return ( + + ); +}; diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/index.ts b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/index.ts new file mode 100644 index 00000000000..73328313921 --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/DeployPopover/index.ts @@ -0,0 +1 @@ +export { DeployPopover } from './DeployPopover'; diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/index.ts b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/index.ts new file mode 100644 index 00000000000..45b057e4f0d --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/index.ts @@ -0,0 +1 @@ +export { DeployDropdown } from './DeployDropdown'; diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.test.ts b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.test.ts new file mode 100644 index 00000000000..612abd5f7d3 --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.test.ts @@ -0,0 +1,97 @@ +import { filterSucceededReleases, mapAppReleasesToImageOptions } from './utils'; // Adjust the import paths as needed +import { BuildResult } from 'app-shared/types/Build'; +import type { AppRelease } from 'app-shared/types/AppRelease'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { appRelease } from 'app-shared/mocks/mocks'; + +const created = '01.01.2024 18:53'; +const mockAppReleaseSuccess: AppRelease = { + ...appRelease, + tagName: 'test1', + created, + build: { + ...appRelease.build, + result: BuildResult.succeeded, + }, +}; +const mockAppReleaseFailed: AppRelease = { + ...appRelease, + tagName: 'test2', + created, + build: { + ...appRelease.build, + result: BuildResult.failed, + }, +}; + +const mockAppReleases: AppRelease[] = [mockAppReleaseSuccess, mockAppReleaseFailed]; + +describe('filterSucceededReleases', () => { + it('filters out releases with failed build results', () => { + const result = filterSucceededReleases(mockAppReleases); + expect(result).toEqual([mockAppReleaseSuccess]); + }); + + it('returns an empty array when no releases have succeeded', () => { + const result = filterSucceededReleases([mockAppReleaseFailed]); + expect(result).toEqual([]); + }); + + it('returns all releases if all have succeeded', () => { + const result = filterSucceededReleases([mockAppReleaseSuccess]); + expect(result).toEqual([mockAppReleaseSuccess]); + }); + + it('handles an empty array of releases gracefully', () => { + const result = filterSucceededReleases([]); + expect(result).toEqual([]); + }); +}); + +describe('mapAppReleasesToImageOptions', () => { + it('maps succeeded releases to ImageOption objects', () => { + const appReleases: AppRelease[] = [mockAppReleaseSuccess]; + + const result = mapAppReleasesToImageOptions(appReleases, textMock); + expect(result).toEqual([ + { + value: mockAppReleaseSuccess.tagName, + label: textMock('app_deployment.version_label', { + tagName: mockAppReleaseSuccess.tagName, + createdDateTime: created, + }), + }, + ]); + }); + + it('returns an empty array when no releases are provided', () => { + const result = mapAppReleasesToImageOptions([], textMock); + expect(result).toEqual([]); + }); + + it('returns ImageOption objects for multiple releases', () => { + const newTagName: string = 'test3'; + const appReleases: AppRelease[] = [ + mockAppReleaseSuccess, + { ...mockAppReleaseSuccess, tagName: newTagName }, + ]; + + const result = mapAppReleasesToImageOptions(appReleases, textMock); + expect(result).toEqual([ + { + value: mockAppReleaseSuccess.tagName, + label: textMock('app_deployment.version_label', { + tagName: mockAppReleaseSuccess.tagName, + createdDateTime: created, + }), + }, + { + value: newTagName, + label: textMock('app_deployment.version_label', { + tagName: newTagName, + createdDateTime: created, + }), + }, + ]); + }); +}); diff --git a/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.ts b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.ts new file mode 100644 index 00000000000..da5e1727311 --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/DeployDropdown/utils.ts @@ -0,0 +1,30 @@ +import { type AppRelease } from 'app-shared/types/AppRelease'; +import { type ImageOption } from '../../ImageOption'; +import { BuildResult } from 'app-shared/types/Build'; +import { DateUtils } from '@studio/pure-functions'; +import type i18next from 'i18next'; + +export const filterSucceededReleases = (appReleases: AppRelease[]): AppRelease[] => { + return appReleases.filter((appRelease: AppRelease) => isAppReleaseBuiltSuccessful(appRelease)); +}; + +const isAppReleaseBuiltSuccessful = (appRelease: AppRelease): boolean => { + return appRelease.build.result === BuildResult.succeeded; +}; + +export const mapAppReleasesToImageOptions = ( + appReleases: AppRelease[], + t: typeof i18next.t, +): ImageOption[] => { + return appReleases.map((appRelease: AppRelease) => mapAppReleaseToImageOption(appRelease, t)); +}; + +const mapAppReleaseToImageOption = (appRelease: AppRelease, t: typeof i18next.t): ImageOption => { + return { + value: appRelease.tagName, + label: t('app_deployment.version_label', { + tagName: appRelease.tagName, + createdDateTime: DateUtils.formatDateTime(appRelease.created), + }), + }; +}; diff --git a/frontend/app-development/features/appPublish/components/Deploy/index.ts b/frontend/app-development/features/appPublish/components/Deploy/index.ts new file mode 100644 index 00000000000..972b2783048 --- /dev/null +++ b/frontend/app-development/features/appPublish/components/Deploy/index.ts @@ -0,0 +1 @@ +export { Deploy } from './Deploy'; diff --git a/frontend/app-development/features/appPublish/components/DeployDropdown.test.tsx b/frontend/app-development/features/appPublish/components/DeployDropdown.test.tsx deleted file mode 100644 index b2af165200b..00000000000 --- a/frontend/app-development/features/appPublish/components/DeployDropdown.test.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import React from 'react'; -import { screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { DeployDropdownProps } from './DeployDropdown'; -import { DeployDropdown } from './DeployDropdown'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; -import { renderWithProviders } from 'app-development/test/mocks'; -import type { AppRelease } from 'app-shared/types/AppRelease'; -import { BuildResult } from 'app-shared/types/Build'; -import { appRelease } from 'app-shared/mocks/mocks'; - -const defaultProps: DeployDropdownProps = { - appDeployedVersion: '', - disabled: false, - setSelectedImageTag: jest.fn(), - selectedImageTag: 'test1', - startDeploy: jest.fn(), - isPending: false, -}; - -const created = '01.01.2024 18:53'; - -const appReleases: AppRelease[] = [ - { - ...appRelease, - tagName: 'test1', - created, - build: { - ...appRelease.build, - result: BuildResult.succeeded, - }, - }, - { - ...appRelease, - tagName: 'test2', - created, - build: { - ...appRelease.build, - result: BuildResult.succeeded, - }, - }, -]; - -const imageOptions = [ - { - label: textMock('app_deployment.version_label', { - tagName: appReleases[0].tagName, - createdDateTime: created, - }), - value: appReleases[0].tagName, - }, - { - label: textMock('app_deployment.version_label', { - tagName: appReleases[1].tagName, - createdDateTime: created, - }), - value: appReleases[1].tagName, - }, -]; - -describe('DeployDropdown', () => { - describe('Dropdown', () => { - afterEach(jest.clearAllMocks); - - it('renders a spinner while loading data', () => { - render(); - - expect(screen.getByTitle(textMock('app_deployment.releases_loading'))).toBeInTheDocument(); - }); - - it('renders an error message if an error occurs while loading data', async () => { - render( - {}, - { - getAppReleases: jest.fn().mockImplementation(() => Promise.reject()), - }, - ); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - expect(screen.getByText(textMock('app_deployment.releases_error'))).toBeInTheDocument(); - }); - - it('render no image options message when image options are empty', async () => { - const user = userEvent.setup(); - - render( - {}, - { - getAppReleases: jest.fn().mockImplementation(() => - Promise.resolve({ - results: [], - }), - ), - }, - ); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const select = await screen.findByLabelText(textMock('app_deployment.choose_version')); - await user.click(select); - - expect(screen.getByText(textMock('app_deployment.no_versions'))).toBeInTheDocument(); - }); - - it('renders image options', async () => { - const user = userEvent.setup(); - - render(); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const select = screen.getByLabelText(textMock('app_deployment.choose_version')); - await user.click(select); - - expect(screen.getByRole('option', { name: imageOptions[0].label })).toBeInTheDocument(); - expect(screen.getByRole('option', { name: imageOptions[1].label })).toBeInTheDocument(); - }); - - it('selects default image option', async () => { - render({ selectedImageTag: imageOptions[0].value }); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - expect(screen.getByRole('combobox')).toHaveValue(imageOptions[0].label); - }); - - it('selects new image option', async () => { - const user = userEvent.setup(); - - render(); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const select = screen.getByLabelText(textMock('app_deployment.choose_version')); - await user.click(select); - - const option = screen.getByRole('option', { name: imageOptions[1].label }); - await user.click(option); - - await waitFor(() => { - expect(defaultProps.setSelectedImageTag).toHaveBeenCalledWith(imageOptions[1].value); - }); - }); - - it('shows a loding spinner when mutation is pending', async () => { - render({ isPending: true }); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const deployButton = screen.getByRole('button', { - name: textMock('app_deployment.btn_deploy_new_version'), - }); - expect(within(deployButton).getByTestId('spinner-test-id')).toBeInTheDocument(); - }); - - it('disables both dropdown and button when deploy is not possible', async () => { - render({ disabled: true }); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - expect(screen.getByLabelText(textMock('app_deployment.choose_version'))).toBeDisabled(); - - const deployButton = screen.getByRole('button', { - name: textMock('app_deployment.btn_deploy_new_version'), - }); - expect(deployButton).toBeDisabled(); - }); - }); - - describe('Confirmation dialog', () => { - afterEach(jest.clearAllMocks); - - it('should open the confirmation dialog when clicking the deploy button', async () => { - const user = userEvent.setup(); - - render(); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const deployButton = screen.getByRole('button', { - name: textMock('app_deployment.btn_deploy_new_version'), - }); - await user.click(deployButton); - - const dialog = screen.getByRole('dialog'); - expect(dialog).toBeInTheDocument(); - - const text = await screen.findByText( - textMock('app_deployment.deploy_confirmation_short', { - selectedImageTag: imageOptions[0].value, - }), - ); - expect(text).toBeInTheDocument(); - - const confirmButton = screen.getByRole('button', { name: textMock('general.yes') }); - expect(confirmButton).toBeInTheDocument(); - - const cancelButton = screen.getByRole('button', { name: textMock('general.cancel') }); - expect(cancelButton).toBeInTheDocument(); - }); - - it('should open the confirmation dialog with a warning message when clicking the deploy button to an environment that alreadys has a deployed application', async () => { - const user = userEvent.setup(); - - render({ - appDeployedVersion: '1', - }); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const deployButton = screen.getByRole('button', { - name: textMock('app_deployment.btn_deploy_new_version'), - }); - await user.click(deployButton); - - const dialog = screen.getByRole('dialog'); - expect(dialog).toBeInTheDocument(); - - const text = await screen.findByText( - textMock('app_deployment.deploy_confirmation', { - selectedImageTag: imageOptions[0].value, - appDeployedVersion: '1', - }), - ); - expect(text).toBeInTheDocument(); - - const confirmButton = screen.getByRole('button', { name: textMock('general.yes') }); - expect(confirmButton).toBeInTheDocument(); - - const cancelButton = screen.getByRole('button', { name: textMock('general.cancel') }); - expect(cancelButton).toBeInTheDocument(); - }); - - it('should confirm and close the dialog when clicking the confirm button', async () => { - const user = userEvent.setup(); - - render(); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const deployButton = screen.getByRole('button', { - name: textMock('app_deployment.btn_deploy_new_version'), - }); - await user.click(deployButton); - - const confirmButton = screen.getByRole('button', { name: textMock('general.yes') }); - await user.click(confirmButton); - - expect(defaultProps.startDeploy).toHaveBeenCalledTimes(1); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('should close the confirmation dialog when clicking the cancel button', async () => { - const user = userEvent.setup(); - - render(); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const deployButton = screen.getByRole('button', { - name: textMock('app_deployment.btn_deploy_new_version'), - }); - await user.click(deployButton); - - const cancelButton = screen.getByRole('button', { name: textMock('general.cancel') }); - await user.click(cancelButton); - - expect(defaultProps.startDeploy).toHaveBeenCalledTimes(0); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - - it('should close when clicking outside the popover', async () => { - const user = userEvent.setup(); - - render(); - await waitForElementToBeRemoved(() => - screen.queryByTitle(textMock('app_deployment.releases_loading')), - ); - - const deployButton = screen.getByRole('button', { - name: textMock('app_deployment.btn_deploy_new_version'), - }); - await user.click(deployButton); - - await user.click(document.body); - - expect(defaultProps.startDeploy).toHaveBeenCalledTimes(0); - await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); - }); - }); -}); - -const render = (props?: Partial, queries?: Partial) => { - return renderWithProviders({ - getAppReleases: jest.fn().mockImplementation(() => - Promise.resolve({ - results: appReleases, - }), - ), - ...queries, - })(); -}; diff --git a/frontend/app-development/features/appPublish/components/DeployDropdown.tsx b/frontend/app-development/features/appPublish/components/DeployDropdown.tsx deleted file mode 100644 index 2d835dfd0c0..00000000000 --- a/frontend/app-development/features/appPublish/components/DeployDropdown.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React, { useState } from 'react'; -import classes from './DeployDropdown.module.css'; -import { AltinnConfirmDialog } from 'app-shared/components'; -import { StudioButton, StudioError, StudioSpinner } from '@studio/components'; -import { Combobox, Spinner } from '@digdir/designsystemet-react'; -import type { ImageOption } from './ImageOption'; -import { useTranslation } from 'react-i18next'; -import { useAppReleasesQuery } from 'app-development/hooks/queries'; -import { BuildResult } from 'app-shared/types/Build'; -import { DateUtils } from '@studio/pure-functions'; -import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; - -export interface DeployDropdownProps { - appDeployedVersion: string; - disabled: boolean; - setSelectedImageTag: (tag: string) => void; - selectedImageTag: string; - startDeploy: () => void; - isPending: boolean; -} - -export const DeployDropdown = ({ - appDeployedVersion, - selectedImageTag, - setSelectedImageTag, - disabled, - startDeploy, - isPending, -}: DeployDropdownProps) => { - const { org, app } = useStudioEnvironmentParams(); - const [isConfirmDeployDialogOpen, setIsConfirmDeployDialogOpen] = useState(); - const { t } = useTranslation(); - - const { - data: releases = [], - isPending: releasesIsPending, - isError: releasesIsError, - } = useAppReleasesQuery(org, app, { hideDefaultError: true }); - - if (releasesIsPending) - return ( - - ); - - if (releasesIsError) return {t('app_deployment.releases_error')}; - - const imageOptions: ImageOption[] = releases - .filter((image) => image.build.result === BuildResult.succeeded) - .map((image) => ({ - value: image.tagName, - label: t('app_deployment.version_label', { - tagName: image.tagName, - createdDateTime: DateUtils.formatDateTime(image.created), - }), - })); - - return ( -
-
- 0 ? [selectedImageTag] : undefined} - label={t('app_deployment.choose_version')} - onValueChange={(selectedImageOptions: string[]) => - setSelectedImageTag(selectedImageOptions[0]) - } - disabled={disabled} - > - {imageOptions.map((imageOption) => { - return ( - - {imageOption.label} - - ); - })} - {t('app_deployment.no_versions')} - -
-
- setIsConfirmDeployDialogOpen(false)} - placement='right' - trigger={ - setIsConfirmDeployDialogOpen((prevState) => !prevState)} - > - {isPending && ( - - )} - {t('app_deployment.btn_deploy_new_version')} - - } - > -

- {appDeployedVersion - ? t('app_deployment.deploy_confirmation', { - selectedImageTag, - appDeployedVersion, - }) - : t('app_deployment.deploy_confirmation_short', { selectedImageTag })} -

-
-
-
- ); -}; diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 36829085b11..f0cd16df0dd 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -63,6 +63,7 @@ "app_deployment.choose_version": "Velg den versjonen du vil publisere.", "app_deployment.deploy_confirmation": "Er du sikker på at du vil publisere versjon {{selectedImageTag}} til miljøet? Da overskriver du den eksisterende versjonen {{appDeployedVersion}}.", "app_deployment.deploy_confirmation_short": "Er du sikker på at du vil publisere versjon {{selectedImageTag}} til miljøet?", + "app_deployment.deploy_loading": "Laster...", "app_deployment.error": "Kunne ikke laste inn statusen og aktiviteten for denne appen. Prøv igjen senere.", "app_deployment.last_published": "Sist publisert {{lastPublishedDate}}.", "app_deployment.loading": "Laster inn statusen og aktiviteten for denne appen...", From 3eb814944cc9c15f6ee0619e7d92354ccdfee9b2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 08:41:58 +0100 Subject: [PATCH 23/35] chore(deps): update npm non-major dependencies (#14242) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Erling Hauan --- development/azure-devops-mock/package.json | 4 +- frontend/app-development/package.json | 6 +- frontend/app-preview/package.json | 4 +- frontend/dashboard/package.json | 4 +- frontend/libs/studio-components/package.json | 2 +- frontend/libs/studio-hooks/package.json | 2 +- frontend/libs/studio-icons/package.json | 2 +- frontend/packages/schema-editor/package.json | 2 +- frontend/packages/shared/package.json | 4 +- frontend/packages/ux-editor-v3/package.json | 2 +- frontend/packages/ux-editor/package.json | 2 +- frontend/resourceadm/package.json | 2 +- frontend/scripts/package.json | 2 +- frontend/scripts/yarn.lock | 10 +- frontend/testing/cypress/package.json | 2 +- frontend/testing/setupTests.ts | 3 + package.json | 24 +- yarn.lock | 582 ++++++++++++------- 18 files changed, 408 insertions(+), 251 deletions(-) diff --git a/development/azure-devops-mock/package.json b/development/azure-devops-mock/package.json index e2df1bf7674..3594cd34bc4 100644 --- a/development/azure-devops-mock/package.json +++ b/development/azure-devops-mock/package.json @@ -4,9 +4,9 @@ "version": "1.0.0", "author": "The Altinn Studio Team", "dependencies": { - "axios": "1.7.8", + "axios": "1.7.9", "cors": "2.8.5", - "express": "4.21.1", + "express": "4.21.2", "morgan": "1.10.0", "nodemon": "3.1.7", "p-queue": "8.0.1" diff --git a/frontend/app-development/package.json b/frontend/app-development/package.json index 88ccc7670da..f783b3b79ed 100644 --- a/frontend/app-development/package.json +++ b/frontend/app-development/package.json @@ -14,19 +14,19 @@ "@studio/hooks": "workspace:^", "@studio/icons": "workspace:^", "@studio/pure-functions": "workspace:^", - "axios": "1.7.8", + "axios": "1.7.9", "classnames": "2.5.1", "i18next": "23.16.8", "react": "18.3.1", "react-dom": "18.3.1", "react-i18next": "15.1.3", - "react-router-dom": "6.27.0" + "react-router-dom": "6.28.0" }, "devDependencies": { "cross-env": "7.0.3", "jest": "29.7.0", "typescript": "5.7.2", - "webpack": "5.96.1", + "webpack": "5.97.1", "webpack-dev-server": "5.1.0" }, "license": "3-Clause BSD", diff --git a/frontend/app-preview/package.json b/frontend/app-preview/package.json index a8e267fcbbc..d552270971a 100644 --- a/frontend/app-preview/package.json +++ b/frontend/app-preview/package.json @@ -11,13 +11,13 @@ "dependencies": { "react": "18.3.1", "react-dom": "18.3.1", - "react-router-dom": "6.27.0" + "react-router-dom": "6.28.0" }, "devDependencies": { "cross-env": "7.0.3", "jest": "29.7.0", "typescript": "5.7.2", - "webpack": "5.96.1", + "webpack": "5.97.1", "webpack-dev-server": "5.1.0" }, "license": "3-Clause BSD", diff --git a/frontend/dashboard/package.json b/frontend/dashboard/package.json index 58b33cbad70..1d5477b6416 100644 --- a/frontend/dashboard/package.json +++ b/frontend/dashboard/package.json @@ -11,13 +11,13 @@ "dependencies": { "react": "18.3.1", "react-dom": "18.3.1", - "react-router-dom": "6.27.0" + "react-router-dom": "6.28.0" }, "devDependencies": { "cross-env": "7.0.3", "jest": "29.7.0", "typescript": "5.7.2", - "webpack": "5.96.1", + "webpack": "5.97.1", "webpack-dev-server": "5.1.0" }, "license": "3-Clause BSD", diff --git a/frontend/libs/studio-components/package.json b/frontend/libs/studio-components/package.json index 7c056bca8b9..56a3dd4a574 100644 --- a/frontend/libs/studio-components/package.json +++ b/frontend/libs/studio-components/package.json @@ -30,7 +30,7 @@ "@storybook/react-webpack5": "8.4.7", "@storybook/test": "8.4.7", "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.0.1", + "@testing-library/react": "16.1.0", "@types/jest": "^29.5.5", "eslint": "8.57.1", "eslint-plugin-storybook": "^0.11.0", diff --git a/frontend/libs/studio-hooks/package.json b/frontend/libs/studio-hooks/package.json index ed8978b5011..a4b69f447a3 100644 --- a/frontend/libs/studio-hooks/package.json +++ b/frontend/libs/studio-hooks/package.json @@ -15,7 +15,7 @@ }, "devDependencies": { "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.0.1", + "@testing-library/react": "16.1.0", "@types/jest": "^29.5.5", "eslint": "8.57.1", "jest": "^29.7.0", diff --git a/frontend/libs/studio-icons/package.json b/frontend/libs/studio-icons/package.json index 2c58f7e7d14..c15899555e3 100644 --- a/frontend/libs/studio-icons/package.json +++ b/frontend/libs/studio-icons/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.0.1", + "@testing-library/react": "16.1.0", "@types/jest": "^29.5.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", diff --git a/frontend/packages/schema-editor/package.json b/frontend/packages/schema-editor/package.json index 9015ea16f3c..812312a3680 100644 --- a/frontend/packages/schema-editor/package.json +++ b/frontend/packages/schema-editor/package.json @@ -6,7 +6,7 @@ "jest": "29.7.0" }, "peerDependencies": { - "axios": "1.7.8", + "axios": "1.7.9", "classnames": "2.5.1", "react": "18.3.1", "react-dom": "18.3.1" diff --git a/frontend/packages/shared/package.json b/frontend/packages/shared/package.json index 27dcb6b421f..06889aa14c4 100644 --- a/frontend/packages/shared/package.json +++ b/frontend/packages/shared/package.json @@ -7,10 +7,10 @@ "qs": "6.13.1", "react": "18.3.1", "react-dom": "18.3.1", - "react-router-dom": "6.27.0" + "react-router-dom": "6.28.0" }, "devDependencies": { - "@types/react": "18.3.12", + "@types/react": "18.3.14", "jest": "29.7.0", "typescript": "5.7.2" }, diff --git a/frontend/packages/ux-editor-v3/package.json b/frontend/packages/ux-editor-v3/package.json index e365e1e6fd3..b0dac298412 100644 --- a/frontend/packages/ux-editor-v3/package.json +++ b/frontend/packages/ux-editor-v3/package.json @@ -21,7 +21,7 @@ "license": "3-Clause BSD", "main": "index.js", "peerDependencies": { - "webpack": "5.96.1" + "webpack": "5.97.1" }, "scripts": { "test": "jest --maxWorkers=50%" diff --git a/frontend/packages/ux-editor/package.json b/frontend/packages/ux-editor/package.json index 829a6f70f48..94967ac5441 100644 --- a/frontend/packages/ux-editor/package.json +++ b/frontend/packages/ux-editor/package.json @@ -17,7 +17,7 @@ "license": "3-Clause BSD", "main": "index.js", "peerDependencies": { - "webpack": "5.96.1" + "webpack": "5.97.1" }, "scripts": { "test": "jest --maxWorkers=50%" diff --git a/frontend/resourceadm/package.json b/frontend/resourceadm/package.json index 96a3f74dbfb..796672b1824 100644 --- a/frontend/resourceadm/package.json +++ b/frontend/resourceadm/package.json @@ -17,7 +17,7 @@ "cross-env": "7.0.3", "jest": "29.7.0", "typescript": "5.7.2", - "webpack": "5.96.1", + "webpack": "5.97.1", "webpack-dev-server": "5.1.0" }, "license": "3-Clause BSD", diff --git a/frontend/scripts/package.json b/frontend/scripts/package.json index 296b2e6950c..8cf50fecdf9 100644 --- a/frontend/scripts/package.json +++ b/frontend/scripts/package.json @@ -1,7 +1,7 @@ { "name": "altinn-studio-internal-stats", "dependencies": { - "axios": "1.7.8" + "axios": "1.7.9" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "7.18.0", diff --git a/frontend/scripts/yarn.lock b/frontend/scripts/yarn.lock index a4a1fe955fa..870201d051e 100644 --- a/frontend/scripts/yarn.lock +++ b/frontend/scripts/yarn.lock @@ -370,7 +370,7 @@ __metadata: dependencies: "@typescript-eslint/eslint-plugin": "npm:7.18.0" "@typescript-eslint/parser": "npm:7.18.0" - axios: "npm:1.7.8" + axios: "npm:1.7.9" eslint: "npm:8.57.1" eslint-plugin-import: "npm:2.31.0" glob: "npm:11.0.0" @@ -570,14 +570,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.7.8": - version: 1.7.8 - resolution: "axios@npm:1.7.8" +"axios@npm:1.7.9": + version: 1.7.9 + resolution: "axios@npm:1.7.9" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/7ddcde188041ac55090186254b4025eb2af842be3cf615ce45393fd7f543c1eab0ad2fdd2017a5f6190695e3ecea73ee5e9c37f204854aec2698f9579046efdf + checksum: 10/b7a5f660ea53ba9c2a745bf5ad77ad8bf4f1338e13ccc3f9f09f810267d6c638c03dac88b55dae8dc98b79c57d2d6835be651d58d2af97c174f43d289a9fd007 languageName: node linkType: hard diff --git a/frontend/testing/cypress/package.json b/frontend/testing/cypress/package.json index 3afda542a3c..f31efe32e97 100644 --- a/frontend/testing/cypress/package.json +++ b/frontend/testing/cypress/package.json @@ -5,7 +5,7 @@ "devDependencies": { "@testing-library/cypress": "10.0.2", "axe-core": "4.10.2", - "cypress": "13.16.0", + "cypress": "13.16.1", "cypress-axe": "1.5.0", "cypress-plugin-tab": "1.0.5", "eslint": "8.57.1" diff --git a/frontend/testing/setupTests.ts b/frontend/testing/setupTests.ts index cd00ba0cb63..53dfd272de1 100644 --- a/frontend/testing/setupTests.ts +++ b/frontend/testing/setupTests.ts @@ -11,6 +11,9 @@ import type { WithTranslationProps } from 'react-i18next'; failOnConsole({ shouldFailOnWarn: true, + silenceMessage(message) { + return /React Router Future Flag Warning/.test(message); // TODO: remove when react router has been updated to v7 + }, }); Object.defineProperty(window, 'matchMedia', { diff --git a/package.json b/package.json index 236e40ef520..f3293a304ad 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "@microsoft/applicationinsights-react-js": "17.3.4", "@microsoft/applicationinsights-web": "3.3.4", "@microsoft/signalr": "8.0.7", - "@tanstack/react-query": "5.62.0", - "@tanstack/react-query-devtools": "5.62.0", + "@tanstack/react-query": "5.62.2", + "@tanstack/react-query-devtools": "5.62.2", "ajv": "8.17.1", "ajv-formats": "3.0.1", "i18next": "23.16.8", @@ -20,21 +20,21 @@ "react-dom": "18.3.1", "react-error-boundary": "4.1.2", "react-i18next": "15.1.3", - "react-router-dom": "6.27.0", + "react-router-dom": "6.28.0", "react-toastify": "10.0.6" }, "devDependencies": { "@svgr/webpack": "8.1.0", - "@swc/core": "1.9.3", + "@swc/core": "1.10.0", "@swc/jest": "0.2.37", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.0.1", + "@testing-library/react": "16.1.0", "@testing-library/user-event": "14.5.2", "@types/css-modules": "1.0.5", "@types/jest": "29.5.14", - "@types/react": "18.3.12", - "@types/react-dom": "18.3.1", + "@types/react": "18.3.14", + "@types/react-dom": "18.3.2", "@typescript-eslint/eslint-plugin": "7.18.0", "@typescript-eslint/parser": "7.18.0", "cross-env": "7.0.3", @@ -45,7 +45,7 @@ "eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-react": "7.37.2", "eslint-plugin-react-hooks": "5.1.0", - "eslint-plugin-testing-library": "7.1.0", + "eslint-plugin-testing-library": "7.1.1", "glob": "11.0.0", "husky": "9.1.7", "jest": "29.7.0", @@ -54,21 +54,21 @@ "jest-junit": "16.0.0", "lint-staged": "15.2.10", "mini-css-extract-plugin": "2.9.2", - "prettier": "3.4.1", + "prettier": "3.4.2", "source-map-loader": "5.0.0", "swc-loader": "0.2.6", "terser-webpack-plugin": "5.3.10", "typescript": "5.7.2", "typescript-plugin-css-modules": "5.1.0", - "webpack": "5.96.1", + "webpack": "5.97.1", "webpack-cli": "5.1.4", "webpack-dev-server": "5.1.0", "whatwg-fetch": "3.6.20" }, "resolutions": { "@testing-library/dom": "10.4.0", - "@babel/traverse": "7.25.9", - "caniuse-lite": "1.0.30001685" + "@babel/traverse": "7.26.4", + "caniuse-lite": "1.0.30001687" }, "lint-staged": { "*{js,jsx,tsx,ts,css,md}": "prettier -w", diff --git a/yarn.lock b/yarn.lock index 9079a7f7115..5c126b74132 100644 --- a/yarn.lock +++ b/yarn.lock @@ -87,7 +87,7 @@ __metadata: dependencies: jest: "npm:29.7.0" peerDependencies: - axios: 1.7.8 + axios: 1.7.9 classnames: 2.5.1 react: 18.3.1 react-dom: 18.3.1 @@ -132,7 +132,7 @@ __metadata: typescript: "npm:5.7.2" uuid: "npm:10.0.0" peerDependencies: - webpack: 5.96.1 + webpack: 5.97.1 languageName: unknown linkType: soft @@ -148,7 +148,7 @@ __metadata: typescript: "npm:5.7.2" uuid: "npm:10.0.0" peerDependencies: - webpack: 5.96.1 + webpack: 5.97.1 languageName: unknown linkType: soft @@ -181,7 +181,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.25.9": +"@babel/code-frame@npm:^7.25.9, @babel/code-frame@npm:^7.26.2": version: 7.26.2 resolution: "@babel/code-frame@npm:7.26.2" dependencies: @@ -311,16 +311,16 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.25.9": - version: 7.26.2 - resolution: "@babel/generator@npm:7.26.2" +"@babel/generator@npm:^7.26.3": + version: 7.26.3 + resolution: "@babel/generator@npm:7.26.3" dependencies: - "@babel/parser": "npm:^7.26.2" - "@babel/types": "npm:^7.26.0" + "@babel/parser": "npm:^7.26.3" + "@babel/types": "npm:^7.26.3" "@jridgewell/gen-mapping": "npm:^0.3.5" "@jridgewell/trace-mapping": "npm:^0.3.25" jsesc: "npm:^3.0.2" - checksum: 10/71ace82b5b07a554846a003624bfab93275ccf73cdb9f1a37a4c1094bf9dc94bb677c67e8b8c939dbd6c5f0eda2e8f268aa2b0d9c3b9511072565660e717e045 + checksum: 10/c1d8710cc1c52af9d8d67f7d8ea775578aa500887b327d2a81e27494764a6ef99e438dd7e14cf7cd3153656492ee27a8362980dc438087c0ca39d4e75532c638 languageName: node linkType: hard @@ -746,7 +746,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.25.9, @babel/parser@npm:^7.26.2": +"@babel/parser@npm:^7.25.9": version: 7.26.2 resolution: "@babel/parser@npm:7.26.2" dependencies: @@ -757,6 +757,17 @@ __metadata: languageName: node linkType: hard +"@babel/parser@npm:^7.26.3": + version: 7.26.3 + resolution: "@babel/parser@npm:7.26.3" + dependencies: + "@babel/types": "npm:^7.26.3" + bin: + parser: ./bin/babel-parser.js + checksum: 10/e7e3814b2dc9ee3ed605d38223471fa7d3a84cbe9474d2b5fa7ac57dc1ddf75577b1fd3a93bf7db8f41f28869bda795cddd80223f980be23623b6434bf4c88a8 + languageName: node + linkType: hard + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:^7.18.6": version: 7.18.6 resolution: "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@npm:7.18.6" @@ -1845,18 +1856,18 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:7.25.9": - version: 7.25.9 - resolution: "@babel/traverse@npm:7.25.9" +"@babel/traverse@npm:7.26.4": + version: 7.26.4 + resolution: "@babel/traverse@npm:7.26.4" dependencies: - "@babel/code-frame": "npm:^7.25.9" - "@babel/generator": "npm:^7.25.9" - "@babel/parser": "npm:^7.25.9" + "@babel/code-frame": "npm:^7.26.2" + "@babel/generator": "npm:^7.26.3" + "@babel/parser": "npm:^7.26.3" "@babel/template": "npm:^7.25.9" - "@babel/types": "npm:^7.25.9" + "@babel/types": "npm:^7.26.3" debug: "npm:^4.3.1" globals: "npm:^11.1.0" - checksum: 10/7431614d76d4a053e429208db82f2846a415833f3d9eb2e11ef72eeb3c64dfd71f4a4d983de1a4a047b36165a1f5a64de8ca2a417534cc472005c740ffcb9c6a + checksum: 10/30c81a80d66fc39842814bc2e847f4705d30f3859156f130d90a0334fe1d53aa81eed877320141a528ecbc36448acc0f14f544a7d410fa319d1c3ab63b50b58f languageName: node linkType: hard @@ -1903,6 +1914,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.26.3": + version: 7.26.3 + resolution: "@babel/types@npm:7.26.3" + dependencies: + "@babel/helper-string-parser": "npm:^7.25.9" + "@babel/helper-validator-identifier": "npm:^7.25.9" + checksum: 10/c31d0549630a89abfa11410bf82a318b0c87aa846fbf5f9905e47ba5e2aa44f41cc746442f105d622c519e4dc532d35a8d8080460ff4692f9fc7485fbf3a00eb + languageName: node + linkType: hard + "@bcoe/v8-coverage@npm:^0.2.3": version: 0.2.3 resolution: "@bcoe/v8-coverage@npm:0.2.3" @@ -3587,10 +3608,10 @@ __metadata: languageName: node linkType: hard -"@remix-run/router@npm:1.20.0": - version: 1.20.0 - resolution: "@remix-run/router@npm:1.20.0" - checksum: 10/e1d2420db94a1855b97f1784898d0ae389cf3b77129b8f419e51d4833b77ca2c92ac09e2cb558015324d64580a138fd6faa31e52fcc3ba90e3cc382a1a324d4a +"@remix-run/router@npm:1.21.0": + version: 1.21.0 + resolution: "@remix-run/router@npm:1.21.0" + checksum: 10/cf0fb69d19c1b79095ff67c59cea89086f3982a9a54c8a993818a60fc76e0ebab5a8db647c1a96a662729fad8e806ddd0a96622adf473f5a9f0b99998b2dbad4 languageName: node linkType: hard @@ -3887,9 +3908,9 @@ __metadata: languageName: node linkType: hard -"@storybook/core@npm:8.4.6": - version: 8.4.6 - resolution: "@storybook/core@npm:8.4.6" +"@storybook/core@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/core@npm:8.4.7" dependencies: "@storybook/csf": "npm:^0.1.11" better-opn: "npm:^3.0.2" @@ -3907,7 +3928,7 @@ __metadata: peerDependenciesMeta: prettier: optional: true - checksum: 10/bda4eb0c2dafe8b378447b842383c19dd7bf1e347f7d439f1129d92ee2906472ec13d28a2f8eb50b34b97be2bdf4158c3db0ba0ba64bf9c27e9b9177faa00249 + checksum: 10/a0bc9e1ea05ae69a914e508966f27208815de7aa2a4bed010c2c194bbdf397742f83e19ffa2efd98d2c04f08854c9b0b327632f6b0a3a90d2d3dd4c5002f14c5 languageName: node linkType: hard @@ -4122,7 +4143,7 @@ __metadata: "@studio/icons": "workspace:^" "@studio/pure-functions": "workspace:^" "@testing-library/jest-dom": "npm:6.6.3" - "@testing-library/react": "npm:16.0.1" + "@testing-library/react": "npm:16.1.0" "@types/jest": "npm:^29.5.5" ajv: "npm:8.17.1" eslint: "npm:8.57.1" @@ -4162,7 +4183,7 @@ __metadata: dependencies: "@studio/pure-functions": "workspace:^" "@testing-library/jest-dom": "npm:6.6.3" - "@testing-library/react": "npm:16.0.1" + "@testing-library/react": "npm:16.1.0" "@types/jest": "npm:^29.5.5" eslint: "npm:8.57.1" jest: "npm:^29.7.0" @@ -4183,7 +4204,7 @@ __metadata: dependencies: "@navikt/aksel-icons": "npm:^6.0.0" "@testing-library/jest-dom": "npm:6.6.3" - "@testing-library/react": "npm:16.0.1" + "@testing-library/react": "npm:16.1.0" "@types/jest": "npm:^29.5.5" jest: "npm:^29.7.0" jest-environment-jsdom: "npm:^29.7.0" @@ -4363,6 +4384,13 @@ __metadata: languageName: node linkType: hard +"@swc/core-darwin-arm64@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-darwin-arm64@npm:1.10.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@swc/core-darwin-arm64@npm:1.7.6": version: 1.7.6 resolution: "@swc/core-darwin-arm64@npm:1.7.6" @@ -4370,10 +4398,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-arm64@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-darwin-arm64@npm:1.9.3" - conditions: os=darwin & cpu=arm64 +"@swc/core-darwin-x64@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-darwin-x64@npm:1.10.0" + conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -4384,10 +4412,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-darwin-x64@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-darwin-x64@npm:1.9.3" - conditions: os=darwin & cpu=x64 +"@swc/core-linux-arm-gnueabihf@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-linux-arm-gnueabihf@npm:1.10.0" + conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -4398,10 +4426,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-arm-gnueabihf@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-linux-arm-gnueabihf@npm:1.9.3" - conditions: os=linux & cpu=arm +"@swc/core-linux-arm64-gnu@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-linux-arm64-gnu@npm:1.10.0" + conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard @@ -4412,10 +4440,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-arm64-gnu@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-linux-arm64-gnu@npm:1.9.3" - conditions: os=linux & cpu=arm64 & libc=glibc +"@swc/core-linux-arm64-musl@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-linux-arm64-musl@npm:1.10.0" + conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard @@ -4426,10 +4454,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-arm64-musl@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-linux-arm64-musl@npm:1.9.3" - conditions: os=linux & cpu=arm64 & libc=musl +"@swc/core-linux-x64-gnu@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-linux-x64-gnu@npm:1.10.0" + conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard @@ -4440,10 +4468,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-x64-gnu@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-linux-x64-gnu@npm:1.9.3" - conditions: os=linux & cpu=x64 & libc=glibc +"@swc/core-linux-x64-musl@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-linux-x64-musl@npm:1.10.0" + conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard @@ -4454,10 +4482,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-linux-x64-musl@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-linux-x64-musl@npm:1.9.3" - conditions: os=linux & cpu=x64 & libc=musl +"@swc/core-win32-arm64-msvc@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-win32-arm64-msvc@npm:1.10.0" + conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -4468,10 +4496,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-win32-arm64-msvc@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-win32-arm64-msvc@npm:1.9.3" - conditions: os=win32 & cpu=arm64 +"@swc/core-win32-ia32-msvc@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-win32-ia32-msvc@npm:1.10.0" + conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -4482,10 +4510,10 @@ __metadata: languageName: node linkType: hard -"@swc/core-win32-ia32-msvc@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-win32-ia32-msvc@npm:1.9.3" - conditions: os=win32 & cpu=ia32 +"@swc/core-win32-x64-msvc@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core-win32-x64-msvc@npm:1.10.0" + conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -4496,27 +4524,20 @@ __metadata: languageName: node linkType: hard -"@swc/core-win32-x64-msvc@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core-win32-x64-msvc@npm:1.9.3" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - -"@swc/core@npm:1.9.3": - version: 1.9.3 - resolution: "@swc/core@npm:1.9.3" - dependencies: - "@swc/core-darwin-arm64": "npm:1.9.3" - "@swc/core-darwin-x64": "npm:1.9.3" - "@swc/core-linux-arm-gnueabihf": "npm:1.9.3" - "@swc/core-linux-arm64-gnu": "npm:1.9.3" - "@swc/core-linux-arm64-musl": "npm:1.9.3" - "@swc/core-linux-x64-gnu": "npm:1.9.3" - "@swc/core-linux-x64-musl": "npm:1.9.3" - "@swc/core-win32-arm64-msvc": "npm:1.9.3" - "@swc/core-win32-ia32-msvc": "npm:1.9.3" - "@swc/core-win32-x64-msvc": "npm:1.9.3" +"@swc/core@npm:1.10.0": + version: 1.10.0 + resolution: "@swc/core@npm:1.10.0" + dependencies: + "@swc/core-darwin-arm64": "npm:1.10.0" + "@swc/core-darwin-x64": "npm:1.10.0" + "@swc/core-linux-arm-gnueabihf": "npm:1.10.0" + "@swc/core-linux-arm64-gnu": "npm:1.10.0" + "@swc/core-linux-arm64-musl": "npm:1.10.0" + "@swc/core-linux-x64-gnu": "npm:1.10.0" + "@swc/core-linux-x64-musl": "npm:1.10.0" + "@swc/core-win32-arm64-msvc": "npm:1.10.0" + "@swc/core-win32-ia32-msvc": "npm:1.10.0" + "@swc/core-win32-x64-msvc": "npm:1.10.0" "@swc/counter": "npm:^0.1.3" "@swc/types": "npm:^0.1.17" peerDependencies: @@ -4545,7 +4566,7 @@ __metadata: peerDependenciesMeta: "@swc/helpers": optional: true - checksum: 10/0a95ce8a2d21370c82e2b0e744c30eacdbd709a7b470950786f3c25a6272c0aa079206a3543aaccc022ca98af87a2a5536387a0259b5377e94d34fac28143cd0 + checksum: 10/986c85e762c83b6651423bcaa3b8cef78a39993c42f417fa944c95dba239a01ca0fbff26a6b3d55d5abd4aba22d9efb42750ead0abac35abde2211319f0e2b34 languageName: node linkType: hard @@ -4633,10 +4654,10 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.62.0": - version: 5.62.0 - resolution: "@tanstack/query-core@npm:5.62.0" - checksum: 10/f3172cd997907ef2b3c767ee51116fffd739f26b504f2d3ce5854cc118c4e2ad1bd1d1d5f223c5ed431fb90f96e649425572402b580852b6ee7aaee72a2dac12 +"@tanstack/query-core@npm:5.62.2": + version: 5.62.2 + resolution: "@tanstack/query-core@npm:5.62.2" + checksum: 10/ea5c5c69b93e62cc89723d20fc2c5a1cee1190f6e4d185dfef266d6af917c409ed58ce977bc7cd2d5371b96e02bc61f848578e25be3a8d8e5f95c607abc34e4e languageName: node linkType: hard @@ -4647,26 +4668,26 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-query-devtools@npm:5.62.0": - version: 5.62.0 - resolution: "@tanstack/react-query-devtools@npm:5.62.0" +"@tanstack/react-query-devtools@npm:5.62.2": + version: 5.62.2 + resolution: "@tanstack/react-query-devtools@npm:5.62.2" dependencies: "@tanstack/query-devtools": "npm:5.61.4" peerDependencies: - "@tanstack/react-query": ^5.62.0 + "@tanstack/react-query": ^5.62.2 react: ^18 || ^19 - checksum: 10/cabc9b7b4b7e8c16bec422e4fb969e3190632cdab41197f5401515003d9977a052c1651dca211f6a11931daea0f2dc315746366bbf507d026b83ce6994da5c9b + checksum: 10/e06e00b5ecab01c8b775308626bb882d8cda7d1c122c000fe88865e640caa326ca6ba0a4e1fdf2ad9a7a1966fe610e626d392509b7e5344c7d0cff8678668c6b languageName: node linkType: hard -"@tanstack/react-query@npm:5.62.0": - version: 5.62.0 - resolution: "@tanstack/react-query@npm:5.62.0" +"@tanstack/react-query@npm:5.62.2": + version: 5.62.2 + resolution: "@tanstack/react-query@npm:5.62.2" dependencies: - "@tanstack/query-core": "npm:5.62.0" + "@tanstack/query-core": "npm:5.62.2" peerDependencies: react: ^18 || ^19 - checksum: 10/69712c822e35a32a27fc06009d7e12904e72369bd5dbc039f7fd1023811440f60a9df096e22196a53a64564b6bebfcae755de4b468ce0056e8092f769b0bac52 + checksum: 10/2d449d8fc1935a3b3ce4d3092feab3317e58db95160bc6cc11781878c703fa67199c559b71be5882285f5829e8dc398a58fc6409ba4ee770ef1398849a006c1c languageName: node linkType: hard @@ -4747,23 +4768,23 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:16.0.1": - version: 16.0.1 - resolution: "@testing-library/react@npm:16.0.1" +"@testing-library/react@npm:16.1.0": + version: 16.1.0 + resolution: "@testing-library/react@npm:16.1.0" dependencies: "@babel/runtime": "npm:^7.12.5" peerDependencies: "@testing-library/dom": ^10.0.0 - "@types/react": ^18.0.0 - "@types/react-dom": ^18.0.0 - react: ^18.0.0 - react-dom: ^18.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true "@types/react-dom": optional: true - checksum: 10/904b48881cf5bd208e25899e168f5c99c78ed6d77389544838d9d861a038d2c5c5385863ee9a367436770cbf7d21c5e05a991b9e24a33806e9ac985df2448185 + checksum: 10/2a20e0dbfadbc93d45a84e82281ed47deed54a6a5fc1461a523172d7fbc0481e8502cf98a2080f38aba94290b3d745671a1c9e320e6f76ad6afcca67c580b963 languageName: node linkType: hard @@ -5212,33 +5233,22 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:18.3.1": - version: 18.3.1 - resolution: "@types/react-dom@npm:18.3.1" +"@types/react-dom@npm:18.3.2": + version: 18.3.2 + resolution: "@types/react-dom@npm:18.3.2" dependencies: - "@types/react": "npm:*" - checksum: 10/33f9ba79b26641ddf00a8699c30066b7e3573ab254e97475bf08f82fab83a6d3ce8d4ebad86afeb49bb8df3374390a9ba93125cece33badc4b3e8f7eac3c84d8 + "@types/react": "npm:^18" + checksum: 10/29337fe650ec49178121a815f0c5b7f6081e63b216a29bd6f90bb5e5b0367d60a95b50a25b1848031c82458de2dfb62f78b18e72d0e6fed6f77951e64e3897d1 languageName: node linkType: hard -"@types/react@npm:*": - version: 18.0.28 - resolution: "@types/react@npm:18.0.28" +"@types/react@npm:18.3.14, @types/react@npm:^18": + version: 18.3.14 + resolution: "@types/react@npm:18.3.14" dependencies: "@types/prop-types": "npm:*" - "@types/scheduler": "npm:*" csstype: "npm:^3.0.2" - checksum: 10/7a752e6c5e76139f258bc14827d2c574bb76d6e7eb1b240f24f79b269153cb88668c34ba7078d3de99ec1973b7022e1f788e71117bd52a287f382d24bb80be40 - languageName: node - linkType: hard - -"@types/react@npm:18.3.12": - version: 18.3.12 - resolution: "@types/react@npm:18.3.12" - dependencies: - "@types/prop-types": "npm:*" - csstype: "npm:^3.0.2" - checksum: 10/c9bbdfeacd5347d2240e0d2cb5336bc57dbc1b9ff557b6c4024b49df83419e4955553518169d3736039f1b62608e15b35762a6c03d49bd86e33add4b43b19033 + checksum: 10/683927b1e24293276cf62f60f1baa666b7f1053c87ec8d8c79d2d4bc105b99e0492482f801ffce7cdef9d656c11294faa423051807a500c7475f4fbd7661bd8d languageName: node linkType: hard @@ -5256,13 +5266,6 @@ __metadata: languageName: node linkType: hard -"@types/scheduler@npm:*": - version: 0.16.2 - resolution: "@types/scheduler@npm:0.16.2" - checksum: 10/b6b4dcfeae6deba2e06a70941860fb1435730576d3689225a421280b7742318d1548b3d22c1f66ab68e414f346a9542f29240bc955b6332c5b11e561077583bc - languageName: node - linkType: hard - "@types/semver@npm:^7.3.4": version: 7.5.8 resolution: "@types/semver@npm:7.5.8" @@ -5718,6 +5721,16 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/ast@npm:1.14.1" + dependencies: + "@webassemblyjs/helper-numbers": "npm:1.13.2" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + checksum: 10/f83e6abe38057f5d87c1fb356513a371a8b43c9b87657f2790741a66b1ef8ecf958d1391bc42f27c5fb33f58ab8286a38ea849fdd21f433cd4df1307424bab45 + languageName: node + linkType: hard + "@webassemblyjs/floating-point-hex-parser@npm:1.11.6": version: 1.11.6 resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.11.6" @@ -5725,6 +5738,13 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/floating-point-hex-parser@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/floating-point-hex-parser@npm:1.13.2" + checksum: 10/e866ec8433f4a70baa511df5e8f2ebcd6c24f4e2cc6274c7c5aabe2bcce3459ea4680e0f35d450e1f3602acf3913b6b8e4f15069c8cfd34ae8609fb9a7d01795 + languageName: node + linkType: hard + "@webassemblyjs/helper-api-error@npm:1.11.6": version: 1.11.6 resolution: "@webassemblyjs/helper-api-error@npm:1.11.6" @@ -5732,6 +5752,13 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/helper-api-error@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/helper-api-error@npm:1.13.2" + checksum: 10/48b5df7fd3095bb252f59a139fe2cbd999a62ac9b488123e9a0da3906ad8a2f2da7b2eb21d328c01a90da987380928706395c2897d1f3ed9e2125b6d75a920d0 + languageName: node + linkType: hard + "@webassemblyjs/helper-buffer@npm:1.12.1": version: 1.12.1 resolution: "@webassemblyjs/helper-buffer@npm:1.12.1" @@ -5739,6 +5766,13 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/helper-buffer@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/helper-buffer@npm:1.14.1" + checksum: 10/9690afeafa5e765a34620aa6216e9d40f9126d4e37e9726a2594bf60cab6b211ef20ab6670fd3c4449dd4a3497e69e49b2b725c8da0fb213208c7f45f15f5d5b + languageName: node + linkType: hard + "@webassemblyjs/helper-numbers@npm:1.11.6": version: 1.11.6 resolution: "@webassemblyjs/helper-numbers@npm:1.11.6" @@ -5750,6 +5784,17 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/helper-numbers@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/helper-numbers@npm:1.13.2" + dependencies: + "@webassemblyjs/floating-point-hex-parser": "npm:1.13.2" + "@webassemblyjs/helper-api-error": "npm:1.13.2" + "@xtuc/long": "npm:4.2.2" + checksum: 10/e4c7d0b09811e1cda8eec644a022b560b28f4e974f50195375ccd007df5ee48a922a6dcff5ac40b6a8ec850d56d0ea6419318eee49fec7819ede14e90417a6a4 + languageName: node + linkType: hard + "@webassemblyjs/helper-wasm-bytecode@npm:1.11.6": version: 1.11.6 resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.11.6" @@ -5757,6 +5802,13 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/helper-wasm-bytecode@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/helper-wasm-bytecode@npm:1.13.2" + checksum: 10/3edd191fff7296df1ef3b023bdbe6cb5ea668f6386fd197ccfce46015c6f2a8cc9763cfb86503a0b94973ad27996645afff2252ee39a236513833259a47af6ed + languageName: node + linkType: hard + "@webassemblyjs/helper-wasm-section@npm:1.12.1": version: 1.12.1 resolution: "@webassemblyjs/helper-wasm-section@npm:1.12.1" @@ -5769,6 +5821,18 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/helper-wasm-section@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/helper-wasm-section@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-buffer": "npm:1.14.1" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/wasm-gen": "npm:1.14.1" + checksum: 10/6b73874f906532512371181d7088460f767966f26309e836060c5a8e4e4bfe6d523fb5f4c034b34aa22ebb1192815f95f0e264298769485c1f0980fdd63ae0ce + languageName: node + linkType: hard + "@webassemblyjs/ieee754@npm:1.11.6": version: 1.11.6 resolution: "@webassemblyjs/ieee754@npm:1.11.6" @@ -5778,6 +5842,15 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/ieee754@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/ieee754@npm:1.13.2" + dependencies: + "@xtuc/ieee754": "npm:^1.2.0" + checksum: 10/d7e3520baa37a7309fa7db4d73d69fb869878853b1ebd4b168821bd03fcc4c0e1669c06231315b0039035d9a7a462e53de3ad982da4a426a4b0743b5888e8673 + languageName: node + linkType: hard + "@webassemblyjs/leb128@npm:1.11.6": version: 1.11.6 resolution: "@webassemblyjs/leb128@npm:1.11.6" @@ -5787,6 +5860,15 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/leb128@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/leb128@npm:1.13.2" + dependencies: + "@xtuc/long": "npm:4.2.2" + checksum: 10/3a10542c86807061ec3230bac8ee732289c852b6bceb4b88ebd521a12fbcecec7c432848284b298154f28619e2746efbed19d6904aef06c49ef20a0b85f650cf + languageName: node + linkType: hard + "@webassemblyjs/utf8@npm:1.11.6": version: 1.11.6 resolution: "@webassemblyjs/utf8@npm:1.11.6" @@ -5794,6 +5876,13 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/utf8@npm:1.13.2": + version: 1.13.2 + resolution: "@webassemblyjs/utf8@npm:1.13.2" + checksum: 10/27885e5d19f339501feb210867d69613f281eda695ac508f04d69fa3398133d05b6870969c0242b054dc05420ed1cc49a64dea4fe0588c18d211cddb0117cc54 + languageName: node + linkType: hard + "@webassemblyjs/wasm-edit@npm:^1.12.1": version: 1.12.1 resolution: "@webassemblyjs/wasm-edit@npm:1.12.1" @@ -5810,6 +5899,22 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/wasm-edit@npm:^1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-edit@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-buffer": "npm:1.14.1" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/helper-wasm-section": "npm:1.14.1" + "@webassemblyjs/wasm-gen": "npm:1.14.1" + "@webassemblyjs/wasm-opt": "npm:1.14.1" + "@webassemblyjs/wasm-parser": "npm:1.14.1" + "@webassemblyjs/wast-printer": "npm:1.14.1" + checksum: 10/c62c50eadcf80876713f8c9f24106b18cf208160ab842fcb92060fd78c37bf37e7fcf0b7cbf1afc05d230277c2ce0f3f728432082c472dd1293e184a95f9dbdd + languageName: node + linkType: hard + "@webassemblyjs/wasm-gen@npm:1.12.1": version: 1.12.1 resolution: "@webassemblyjs/wasm-gen@npm:1.12.1" @@ -5823,6 +5928,19 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/wasm-gen@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-gen@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/ieee754": "npm:1.13.2" + "@webassemblyjs/leb128": "npm:1.13.2" + "@webassemblyjs/utf8": "npm:1.13.2" + checksum: 10/6085166b0987d3031355fe17a4f9ef0f412e08098d95454059aced2bd72a4c3df2bc099fa4d32d640551fc3eca1ac1a997b44432e46dc9d84642688e42c17ed4 + languageName: node + linkType: hard + "@webassemblyjs/wasm-opt@npm:1.12.1": version: 1.12.1 resolution: "@webassemblyjs/wasm-opt@npm:1.12.1" @@ -5835,6 +5953,18 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/wasm-opt@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-opt@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-buffer": "npm:1.14.1" + "@webassemblyjs/wasm-gen": "npm:1.14.1" + "@webassemblyjs/wasm-parser": "npm:1.14.1" + checksum: 10/fa5d1ef8d2156e7390927f938f513b7fb4440dd6804b3d6c8622b7b1cf25a3abf1a5809f615896d4918e04b27b52bc3cbcf18faf2d563cb563ae0a9204a492db + languageName: node + linkType: hard + "@webassemblyjs/wasm-parser@npm:1.12.1, @webassemblyjs/wasm-parser@npm:^1.12.1": version: 1.12.1 resolution: "@webassemblyjs/wasm-parser@npm:1.12.1" @@ -5849,6 +5979,20 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/wasm-parser@npm:1.14.1, @webassemblyjs/wasm-parser@npm:^1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wasm-parser@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@webassemblyjs/helper-api-error": "npm:1.13.2" + "@webassemblyjs/helper-wasm-bytecode": "npm:1.13.2" + "@webassemblyjs/ieee754": "npm:1.13.2" + "@webassemblyjs/leb128": "npm:1.13.2" + "@webassemblyjs/utf8": "npm:1.13.2" + checksum: 10/07d9805fda88a893c984ed93d5a772d20d671e9731358ab61c6c1af8e0e58d1c42fc230c18974dfddebc9d2dd7775d514ba4d445e70080b16478b4b16c39c7d9 + languageName: node + linkType: hard + "@webassemblyjs/wast-printer@npm:1.12.1": version: 1.12.1 resolution: "@webassemblyjs/wast-printer@npm:1.12.1" @@ -5859,6 +6003,16 @@ __metadata: languageName: node linkType: hard +"@webassemblyjs/wast-printer@npm:1.14.1": + version: 1.14.1 + resolution: "@webassemblyjs/wast-printer@npm:1.14.1" + dependencies: + "@webassemblyjs/ast": "npm:1.14.1" + "@xtuc/long": "npm:4.2.2" + checksum: 10/cef09aad2fcd291bfcf9efdae2ea1e961a1ba0f925d1d9dcdd8c746d32fbaf431b6d26a0241699c0e39f82139018aa720b4ceb84ac6f4c78f13072747480db69 + languageName: node + linkType: hard + "@webpack-cli/configtest@npm:^2.1.1": version: 2.1.1 resolution: "@webpack-cli/configtest@npm:2.1.1" @@ -6155,18 +6309,18 @@ __metadata: "@microsoft/applicationinsights-web": "npm:3.3.4" "@microsoft/signalr": "npm:8.0.7" "@svgr/webpack": "npm:8.1.0" - "@swc/core": "npm:1.9.3" + "@swc/core": "npm:1.10.0" "@swc/jest": "npm:0.2.37" - "@tanstack/react-query": "npm:5.62.0" - "@tanstack/react-query-devtools": "npm:5.62.0" + "@tanstack/react-query": "npm:5.62.2" + "@tanstack/react-query-devtools": "npm:5.62.2" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:6.6.3" - "@testing-library/react": "npm:16.0.1" + "@testing-library/react": "npm:16.1.0" "@testing-library/user-event": "npm:14.5.2" "@types/css-modules": "npm:1.0.5" "@types/jest": "npm:29.5.14" - "@types/react": "npm:18.3.12" - "@types/react-dom": "npm:18.3.1" + "@types/react": "npm:18.3.14" + "@types/react-dom": "npm:18.3.2" "@typescript-eslint/eslint-plugin": "npm:7.18.0" "@typescript-eslint/parser": "npm:7.18.0" ajv: "npm:8.17.1" @@ -6179,7 +6333,7 @@ __metadata: eslint-plugin-jsx-a11y: "npm:6.10.2" eslint-plugin-react: "npm:7.37.2" eslint-plugin-react-hooks: "npm:5.1.0" - eslint-plugin-testing-library: "npm:7.1.0" + eslint-plugin-testing-library: "npm:7.1.1" glob: "npm:11.0.0" husky: "npm:9.1.7" i18next: "npm:23.16.8" @@ -6190,19 +6344,19 @@ __metadata: jest-junit: "npm:16.0.0" lint-staged: "npm:15.2.10" mini-css-extract-plugin: "npm:2.9.2" - prettier: "npm:3.4.1" + prettier: "npm:3.4.2" react: "npm:18.3.1" react-dom: "npm:18.3.1" react-error-boundary: "npm:4.1.2" react-i18next: "npm:15.1.3" - react-router-dom: "npm:6.27.0" + react-router-dom: "npm:6.28.0" react-toastify: "npm:10.0.6" source-map-loader: "npm:5.0.0" swc-loader: "npm:0.2.6" terser-webpack-plugin: "npm:5.3.10" typescript: "npm:5.7.2" typescript-plugin-css-modules: "npm:5.1.0" - webpack: "npm:5.96.1" + webpack: "npm:5.97.1" webpack-cli: "npm:5.1.4" webpack-dev-server: "npm:5.1.0" whatwg-fetch: "npm:3.6.20" @@ -6308,7 +6462,7 @@ __metadata: "@studio/hooks": "workspace:^" "@studio/icons": "workspace:^" "@studio/pure-functions": "workspace:^" - axios: "npm:1.7.8" + axios: "npm:1.7.9" classnames: "npm:2.5.1" cross-env: "npm:7.0.3" i18next: "npm:23.16.8" @@ -6316,9 +6470,9 @@ __metadata: react: "npm:18.3.1" react-dom: "npm:18.3.1" react-i18next: "npm:15.1.3" - react-router-dom: "npm:6.27.0" + react-router-dom: "npm:6.28.0" typescript: "npm:5.7.2" - webpack: "npm:5.96.1" + webpack: "npm:5.97.1" webpack-dev-server: "npm:5.1.0" languageName: unknown linkType: soft @@ -6331,9 +6485,9 @@ __metadata: jest: "npm:29.7.0" react: "npm:18.3.1" react-dom: "npm:18.3.1" - react-router-dom: "npm:6.27.0" + react-router-dom: "npm:6.28.0" typescript: "npm:5.7.2" - webpack: "npm:5.96.1" + webpack: "npm:5.97.1" webpack-dev-server: "npm:5.1.0" languageName: unknown linkType: soft @@ -6342,13 +6496,13 @@ __metadata: version: 0.0.0-use.local resolution: "app-shared@workspace:frontend/packages/shared" dependencies: - "@types/react": "npm:18.3.12" + "@types/react": "npm:18.3.14" classnames: "npm:2.5.1" jest: "npm:29.7.0" qs: "npm:6.13.1" react: "npm:18.3.1" react-dom: "npm:18.3.1" - react-router-dom: "npm:6.27.0" + react-router-dom: "npm:6.28.0" typescript: "npm:5.7.2" languageName: unknown linkType: soft @@ -6735,14 +6889,14 @@ __metadata: languageName: node linkType: hard -"axios@npm:1.7.8": - version: 1.7.8 - resolution: "axios@npm:1.7.8" +"axios@npm:1.7.9": + version: 1.7.9 + resolution: "axios@npm:1.7.9" dependencies: follow-redirects: "npm:^1.15.6" form-data: "npm:^4.0.0" proxy-from-env: "npm:^1.1.0" - checksum: 10/7ddcde188041ac55090186254b4025eb2af842be3cf615ce45393fd7f543c1eab0ad2fdd2017a5f6190695e3ecea73ee5e9c37f204854aec2698f9579046efdf + checksum: 10/b7a5f660ea53ba9c2a745bf5ad77ad8bf4f1338e13ccc3f9f09f810267d6c638c03dac88b55dae8dc98b79c57d2d6835be651d58d2af97c174f43d289a9fd007 languageName: node linkType: hard @@ -6757,9 +6911,9 @@ __metadata: version: 0.0.0-use.local resolution: "azure-devops-mock@workspace:development/azure-devops-mock" dependencies: - axios: "npm:1.7.8" + axios: "npm:1.7.9" cors: "npm:2.8.5" - express: "npm:4.21.1" + express: "npm:4.21.2" morgan: "npm:1.10.0" nodemon: "npm:3.1.7" p-queue: "npm:8.0.1" @@ -7296,10 +7450,10 @@ __metadata: languageName: node linkType: hard -"caniuse-lite@npm:1.0.30001685": - version: 1.0.30001685 - resolution: "caniuse-lite@npm:1.0.30001685" - checksum: 10/33ea5c4df6ba120c977907d909bca9c9c7eccd527055d5d806947c6e5e56a2b6a47893258487ead32a6d197fad02e348adf6ed0fda24501014ecaa57dcd83e29 +"caniuse-lite@npm:1.0.30001687": + version: 1.0.30001687 + resolution: "caniuse-lite@npm:1.0.30001687" + checksum: 10/0b6a064d5df185ec60b842dba5a27d2c54a66967b7f89571bfd0a8256f0863b1f2a910da6a19ed1b8f534bedf0663cae90309a4a6899bba2286205d459b32f95 languageName: node linkType: hard @@ -8243,16 +8397,16 @@ __metadata: dependencies: "@testing-library/cypress": "npm:10.0.2" axe-core: "npm:4.10.2" - cypress: "npm:13.16.0" + cypress: "npm:13.16.1" cypress-axe: "npm:1.5.0" cypress-plugin-tab: "npm:1.0.5" eslint: "npm:8.57.1" languageName: unknown linkType: soft -"cypress@npm:13.16.0": - version: 13.16.0 - resolution: "cypress@npm:13.16.0" +"cypress@npm:13.16.1": + version: 13.16.1 + resolution: "cypress@npm:13.16.1" dependencies: "@cypress/request": "npm:^3.0.6" "@cypress/xvfb": "npm:^1.2.4" @@ -8299,7 +8453,7 @@ __metadata: yauzl: "npm:^2.10.0" bin: cypress: bin/cypress - checksum: 10/f26f4aec806c0826e8fec457cf9d1c19414717b162912af8fd73901e11ae0f2c999779351738c9c61665938135e70685aa940cd6ca2bcd36245e0dba90990373 + checksum: 10/b79835cf5c6bf22a67b4469dc08c805ab3a469d3ea25ff19f700b344b2e790a44462dc2ccdd2c0679f902a44fcbb1efec0b1e9b5ce44be7e99548ce3b9af5cf8 languageName: node linkType: hard @@ -8318,9 +8472,9 @@ __metadata: jest: "npm:29.7.0" react: "npm:18.3.1" react-dom: "npm:18.3.1" - react-router-dom: "npm:6.27.0" + react-router-dom: "npm:6.28.0" typescript: "npm:5.7.2" - webpack: "npm:5.96.1" + webpack: "npm:5.97.1" webpack-dev-server: "npm:5.1.0" languageName: unknown linkType: soft @@ -8867,9 +9021,9 @@ __metadata: linkType: hard "dotenv@npm:*, dotenv@npm:^16.3.1, dotenv@npm:^16.4.2": - version: 16.4.5 - resolution: "dotenv@npm:16.4.5" - checksum: 10/55a3134601115194ae0f924e54473459ed0d9fc340ae610b676e248cca45aa7c680d86365318ea964e6da4e2ea80c4514c1adab5adb43d6867fb57ff068f95c8 + version: 16.4.7 + resolution: "dotenv@npm:16.4.7" + checksum: 10/f13bfe97db88f0df4ec505eeffb8925ec51f2d56a3d0b6d916964d8b4af494e6fb1633ba5d09089b552e77ab2a25de58d70259b2c5ed45ec148221835fc99a0c languageName: node linkType: hard @@ -9707,15 +9861,15 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-testing-library@npm:7.1.0": - version: 7.1.0 - resolution: "eslint-plugin-testing-library@npm:7.1.0" +"eslint-plugin-testing-library@npm:7.1.1": + version: 7.1.1 + resolution: "eslint-plugin-testing-library@npm:7.1.1" dependencies: "@typescript-eslint/scope-manager": "npm:^8.15.0" "@typescript-eslint/utils": "npm:^8.15.0" peerDependencies: eslint: ^8.57.0 || ^9.0.0 - checksum: 10/c250daad343514e107efcd7011cbb41e0e4ccd8c16cef1a1ba3612ad28970d431f81109bb38b6c2c3d6cb4217587aeb6c60f89d0faae6931065ef858dd2ba641 + checksum: 10/48a7a7f93afd16f9cf9cccaf7a1e7ba2e2ea9072d598558ce758d396c7a4d6a71e49b4ec654feef67350141f4f2737d7460c07dbfaed4eb60a09d1c7ceb11558 languageName: node linkType: hard @@ -10037,9 +10191,9 @@ __metadata: languageName: node linkType: hard -"express@npm:4.21.1": - version: 4.21.1 - resolution: "express@npm:4.21.1" +"express@npm:4.21.2": + version: 4.21.2 + resolution: "express@npm:4.21.2" dependencies: accepts: "npm:~1.3.8" array-flatten: "npm:1.1.1" @@ -10060,7 +10214,7 @@ __metadata: methods: "npm:~1.1.2" on-finished: "npm:2.4.1" parseurl: "npm:~1.3.3" - path-to-regexp: "npm:0.1.10" + path-to-regexp: "npm:0.1.12" proxy-addr: "npm:~2.0.7" qs: "npm:6.13.0" range-parser: "npm:~1.2.1" @@ -10072,7 +10226,7 @@ __metadata: type-is: "npm:~1.6.18" utils-merge: "npm:1.0.1" vary: "npm:~1.1.2" - checksum: 10/5d4a36dd03c1d1cce93172e9b185b5cd13a978d29ee03adc51cd278be7b4a514ae2b63e2fdaec0c00fdc95c6cfb396d9dd1da147917ffd337d6cd0778e08c9bc + checksum: 10/34571c442fc8c9f2c4b442d2faa10ea1175cf8559237fc6a278f5ce6254a8ffdbeb9a15d99f77c1a9f2926ab183e3b7ba560e3261f1ad4149799e3412ab66bd1 languageName: node linkType: hard @@ -14524,10 +14678,10 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:0.1.10": - version: 0.1.10 - resolution: "path-to-regexp@npm:0.1.10" - checksum: 10/894e31f1b20e592732a87db61fff5b95c892a3fe430f9ab18455ebe69ee88ef86f8eb49912e261f9926fc53da9f93b46521523e33aefd9cb0a7b0d85d7096006 +"path-to-regexp@npm:0.1.12": + version: 0.1.12 + resolution: "path-to-regexp@npm:0.1.12" + checksum: 10/2e30f6a0144679c1f95c98e166b96e6acd1e72be9417830fefc8de7ac1992147eb9a4c7acaa59119fb1b3c34eec393b2129ef27e24b2054a3906fc4fb0d1398e languageName: node linkType: hard @@ -15188,12 +15342,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:3.4.1": - version: 3.4.1 - resolution: "prettier@npm:3.4.1" +"prettier@npm:3.4.2": + version: 3.4.2 + resolution: "prettier@npm:3.4.2" bin: prettier: bin/prettier.cjs - checksum: 10/1ee4d1b1a9b6761cbb847cd81b9d87e51a0f4b2a4d5fe5755627c24828afe057a7ee9b764c3ee777d84abd46218d173d8a204ee9cb3acdd321ff9a6b25f99c1c + checksum: 10/a3e806fb0b635818964d472d35d27e21a4e17150c679047f5501e1f23bd4aa806adf660f0c0d35214a210d5d440da6896c2e86156da55f221a57938278dc326e languageName: node linkType: hard @@ -15666,27 +15820,27 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:6.27.0": - version: 6.27.0 - resolution: "react-router-dom@npm:6.27.0" +"react-router-dom@npm:6.28.0": + version: 6.28.0 + resolution: "react-router-dom@npm:6.28.0" dependencies: - "@remix-run/router": "npm:1.20.0" - react-router: "npm:6.27.0" + "@remix-run/router": "npm:1.21.0" + react-router: "npm:6.28.0" peerDependencies: react: ">=16.8" react-dom: ">=16.8" - checksum: 10/cfbcbc1d387d3341a335e3a075e487cc09dcbb62f1b83bc827fc3eec937523d5647a2c4488c804dc61581e65561823d0166d17b5dbc8579998c25b5a0bcabad6 + checksum: 10/e637825132ea96c3514ef7b8322f9bf0b752a942d6b4ffc4c20e389b5911726adf3dba8208ed4b97bf5b9c3bd465d9d1a1db1a58a610a8d528f18d890e0b143f languageName: node linkType: hard -"react-router@npm:6.27.0": - version: 6.27.0 - resolution: "react-router@npm:6.27.0" +"react-router@npm:6.28.0": + version: 6.28.0 + resolution: "react-router@npm:6.28.0" dependencies: - "@remix-run/router": "npm:1.20.0" + "@remix-run/router": "npm:1.21.0" peerDependencies: react: ">=16.8" - checksum: 10/352e3af2075cdccf9d114b7e06d94a1b46a2147ba9d6e8643787a92464f5fd9ead950252a98d551f99f21860288bcf3a4f088cb5f46b28d1274a4e2ba24cc0f9 + checksum: 10/f021a644513144884a567d9c2dcc432e8e3233f931378c219c5a3b5b842340f0faca86225a708bafca1e9010965afe1a7dada28aef5b7b6138c885c0552d9a7d languageName: node linkType: hard @@ -16125,7 +16279,7 @@ __metadata: react: "npm:18.3.1" react-dom: "npm:18.3.1" typescript: "npm:5.7.2" - webpack: "npm:5.96.1" + webpack: "npm:5.97.1" webpack-dev-server: "npm:5.1.0" languageName: unknown linkType: soft @@ -16992,10 +17146,10 @@ __metadata: linkType: hard "storybook@npm:^8.0.4": - version: 8.4.6 - resolution: "storybook@npm:8.4.6" + version: 8.4.7 + resolution: "storybook@npm:8.4.7" dependencies: - "@storybook/core": "npm:8.4.6" + "@storybook/core": "npm:8.4.7" peerDependencies: prettier: ^2 || ^3 peerDependenciesMeta: @@ -17005,7 +17159,7 @@ __metadata: getstorybook: ./bin/index.cjs sb: ./bin/index.cjs storybook: ./bin/index.cjs - checksum: 10/7d2e430494e8dc1055e83a9c40799f77af1c3cb03207134c7f5d4de5691da742441b1712529dfcd0e64530ec321fdd052afac66903bc7c28ae378c9b583356d7 + checksum: 10/827979504f98b69397bf91c395d0eea030d5702d0d28ccea4919a5037f628038129b287113aec9d8ecd1062e40b8b22423a300a32381c2d0b340b6960e3b42ea languageName: node linkType: hard @@ -18658,15 +18812,15 @@ __metadata: languageName: node linkType: hard -"webpack@npm:5.96.1": - version: 5.96.1 - resolution: "webpack@npm:5.96.1" +"webpack@npm:5.97.1": + version: 5.97.1 + resolution: "webpack@npm:5.97.1" dependencies: "@types/eslint-scope": "npm:^3.7.7" "@types/estree": "npm:^1.0.6" - "@webassemblyjs/ast": "npm:^1.12.1" - "@webassemblyjs/wasm-edit": "npm:^1.12.1" - "@webassemblyjs/wasm-parser": "npm:^1.12.1" + "@webassemblyjs/ast": "npm:^1.14.1" + "@webassemblyjs/wasm-edit": "npm:^1.14.1" + "@webassemblyjs/wasm-parser": "npm:^1.14.1" acorn: "npm:^8.14.0" browserslist: "npm:^4.24.0" chrome-trace-event: "npm:^1.0.2" @@ -18690,7 +18844,7 @@ __metadata: optional: true bin: webpack: bin/webpack.js - checksum: 10/d3419ffd198252e1d0301bd0c072cee93172f3e47937c745aa8202691d2f5d529d4ba4a1965d1450ad89a1bcd3c1f70ae09e57232b0d01dd38d69c1060e964d5 + checksum: 10/665bd3b8c84b20f0b1f250159865e4d3e9b76c682030313d49124d5f8e96357ccdcc799dd9fe0ebf010fdb33dbc59d9863d79676a308e868e360ac98f7c09987 languageName: node linkType: hard From c4f664625244c146551e4802e8f475dca9b278f4 Mon Sep 17 00:00:00 2001 From: Nina Kylstad Date: Tue, 10 Dec 2024 10:29:53 +0100 Subject: [PATCH 24/35] chore: rename and minor updates to user story template (#14255) --- .../{user_story.yaml => user_story.yml} | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) rename .github/ISSUE_TEMPLATE/{user_story.yaml => user_story.yml} (78%) diff --git a/.github/ISSUE_TEMPLATE/user_story.yaml b/.github/ISSUE_TEMPLATE/user_story.yml similarity index 78% rename from .github/ISSUE_TEMPLATE/user_story.yaml rename to .github/ISSUE_TEMPLATE/user_story.yml index a38255cfbda..f7f8086a976 100644 --- a/.github/ISSUE_TEMPLATE/user_story.yaml +++ b/.github/ISSUE_TEMPLATE/user_story.yml @@ -9,15 +9,22 @@ body: * Consider the [INVEST](https://www.pivotaltracker.com/blog/how-to-invest-in-your-user-stories) qualities when writing the story - type: textarea - id: description + id: userstory attributes: - label: Description + label: User story description: Give us a brief WHO, WHAT, and WHY of this user story. - value: | - As a [persona], I want to [do something] so that [I can achieve a goal]. + value: As a [persona], I want to [do something] so that [I can achieve a goal]. + placeholder: As a [persona], I want to [do something] so that [I can achieve a goal]. validations: required: true + - type: textarea + id: description + attributes: + label: User story + description: You can provide a more detailed description of the user story here if needed. + placeholder: Describe the user story in more detail + - type: textarea id: design attributes: From da692b731e4097daf81bd34227135b1677f6c4ab Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 10 Dec 2024 10:53:58 +0100 Subject: [PATCH 25/35] chore(deps): update nuget non-major dependencies (#13828) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Erling Hauan <148075168+ErlingHauan@users.noreply.github.com> --- backend/packagegroups/NuGet.props | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/packagegroups/NuGet.props b/backend/packagegroups/NuGet.props index 8a594aa99bb..f16fda1615c 100644 --- a/backend/packagegroups/NuGet.props +++ b/backend/packagegroups/NuGet.props @@ -2,8 +2,8 @@ - - + + @@ -29,25 +29,25 @@ - + - + - - + + - + - + @@ -62,7 +62,7 @@ - - + + From 37d92b5966a51d7125c834ec919d7b2e4cb4a5b1 Mon Sep 17 00:00:00 2001 From: William Thorenfeldt <48119543+wrt95@users.noreply.github.com> Date: Tue, 10 Dec 2024 12:37:41 +0100 Subject: [PATCH 26/35] feat: adding featureFlag for un-deploy (#14198) --- .../components/DeploymentEnvironment.tsx | 4 ++- .../components/UnDeploy/UnDeploy.test.tsx | 34 +++++++++++++++++++ .../components/UnDeploy/UnDeploy.tsx | 19 +++++++++++ .../appPublish/components/UnDeploy/index.ts | 1 + frontend/language/src/nb.json | 1 + .../shared/src/utils/featureToggleUtils.ts | 1 + 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 frontend/app-development/features/appPublish/components/UnDeploy/UnDeploy.test.tsx create mode 100644 frontend/app-development/features/appPublish/components/UnDeploy/UnDeploy.tsx create mode 100644 frontend/app-development/features/appPublish/components/UnDeploy/index.ts diff --git a/frontend/app-development/features/appPublish/components/DeploymentEnvironment.tsx b/frontend/app-development/features/appPublish/components/DeploymentEnvironment.tsx index a083ada55d4..591fb5f5cdf 100644 --- a/frontend/app-development/features/appPublish/components/DeploymentEnvironment.tsx +++ b/frontend/app-development/features/appPublish/components/DeploymentEnvironment.tsx @@ -1,12 +1,13 @@ import React from 'react'; import classes from './DeploymentEnvironment.module.css'; - import { DeploymentEnvironmentStatus } from './DeploymentEnvironmentStatus'; import { Deploy } from './Deploy'; +import { UnDeploy } from './UnDeploy'; import { DeploymentEnvironmentLogList } from './DeploymentEnvironmentLogList'; import type { PipelineDeployment } from 'app-shared/types/api/PipelineDeployment'; import type { KubernetesDeployment } from 'app-shared/types/api/KubernetesDeployment'; import { BuildResult } from 'app-shared/types/Build'; +import { FeatureFlag, shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; export interface DeploymentEnvironmentProps { pipelineDeploymentList: PipelineDeployment[]; @@ -51,6 +52,7 @@ export const DeploymentEnvironment = ({ isProduction={isProduction} orgName={orgName} /> + {shouldDisplayFeature(FeatureFlag.Undeploy) && } {}); + +describe('UnDeploy', () => { + it('should render the undeploy button with the correct text', () => { + renderUnDeploy(); + + expect( + screen.getByRole('button', { name: textMock('app_deployment.undeploy_button') }), + ).toBeInTheDocument(); + }); + + it('should call console.log when the button is clicked', async () => { + const user = userEvent.setup(); + renderUnDeploy(); + + const button = screen.getByRole('button', { + name: textMock('app_deployment.undeploy_button'), + }); + await user.click(button); + + expect(console.log).toHaveBeenCalledWith('Undeploy feature will be implemented soon...'); + }); +}); + +const renderUnDeploy = () => { + renderWithProviders()(); +}; diff --git a/frontend/app-development/features/appPublish/components/UnDeploy/UnDeploy.tsx b/frontend/app-development/features/appPublish/components/UnDeploy/UnDeploy.tsx new file mode 100644 index 00000000000..23532a2ccd6 --- /dev/null +++ b/frontend/app-development/features/appPublish/components/UnDeploy/UnDeploy.tsx @@ -0,0 +1,19 @@ +import React, { type ReactElement } from 'react'; +import { StudioButton } from '@studio/components'; +import { useTranslation } from 'react-i18next'; + +export const UnDeploy = (): ReactElement => { + const { t } = useTranslation(); + + const handleClick = () => { + console.log('Undeploy feature will be implemented soon...'); + }; + + return ( +
+ + {t('app_deployment.undeploy_button')} + +
+ ); +}; diff --git a/frontend/app-development/features/appPublish/components/UnDeploy/index.ts b/frontend/app-development/features/appPublish/components/UnDeploy/index.ts new file mode 100644 index 00000000000..0e89e932d7b --- /dev/null +++ b/frontend/app-development/features/appPublish/components/UnDeploy/index.ts @@ -0,0 +1 @@ +export { UnDeploy } from './UnDeploy'; diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index f0cd16df0dd..d6357c06149 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -102,6 +102,7 @@ "app_deployment.table.status": "Status", "app_deployment.table.version_col": "Versjon", "app_deployment.technical_error_1": "Vi har en teknisk feil og får ikke publisert appen din. Publiser på nytt nå, eller prøv igjen senere. Hvis problemet fortsetter, ta kontakt med oss.", + "app_deployment.undeploy_button": "Avpubliser nåværende versjon", "app_deployment.version_label": "Versjon {{tagName}} ({{createdDateTime}})", "app_release.earlier_releases": "Tidligere bygg av appen", "app_release.release_build_log": "Bygglogg", diff --git a/frontend/packages/shared/src/utils/featureToggleUtils.ts b/frontend/packages/shared/src/utils/featureToggleUtils.ts index 242a026db79..2911cc19a2a 100644 --- a/frontend/packages/shared/src/utils/featureToggleUtils.ts +++ b/frontend/packages/shared/src/utils/featureToggleUtils.ts @@ -14,6 +14,7 @@ export enum FeatureFlag { ShouldOverrideAppLibCheck = 'shouldOverrideAppLibCheck', Subform = 'subform', Summary2 = 'summary2', + Undeploy = 'undeploy', } /* From fc6bfbb896becf097ad38134d6e2911e17de00a1 Mon Sep 17 00:00:00 2001 From: andreastanderen <71079896+standeren@users.noreply.github.com> Date: Tue, 10 Dec 2024 15:26:56 +0100 Subject: [PATCH 27/35] feat: add endpoint for updating option list id (#14180) --- .../Designer/Controllers/OptionsController.cs | 31 ++++++ .../GitRepository/AltinnAppGitRepository.cs | 21 ++-- .../GitRepository/GitRepository.cs | 13 +++ .../Services/Implementation/OptionsService.cs | 12 +++ .../Services/Interfaces/IOptionsService.cs | 11 +- .../UpdateOptionListIdTests.cs | 101 ++++++++++++++++++ 6 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 backend/tests/Designer.Tests/Controllers/OptionsController/UpdateOptionListIdTests.cs diff --git a/backend/src/Designer/Controllers/OptionsController.cs b/backend/src/Designer/Controllers/OptionsController.cs index 4d6d139e356..1482ad10064 100644 --- a/backend/src/Designer/Controllers/OptionsController.cs +++ b/backend/src/Designer/Controllers/OptionsController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -129,6 +130,36 @@ public async Task>>> CreateOrOverwr return Ok(newOptionsList); } + /// + /// Updates the name of an options list by changing file name in repo. + /// + /// Unique identifier of the organisation responsible for the app. + /// Application identifier which is unique within an organisation. + /// Name of the options list. + /// New name of options list file. + /// A that observes if operation is cancelled. + [HttpPut] + [Produces("application/json")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [Route("change-name/{optionsListId}")] + public ActionResult UpdateOptionsListId(string org, string repo, [FromRoute] string optionsListId, [FromBody] string newOptionsListId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); + var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repo, developer); + try + { + _optionsService.UpdateOptionsListId(editingContext, optionsListId, newOptionsListId, cancellationToken); + } + catch (IOException exception) + { + return BadRequest(exception.Message); + } + + return Ok(); + } + /// /// Create new options list. /// diff --git a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs index 5d3bd1c0fb4..b0c3c5ed527 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/AltinnAppGitRepository.cs @@ -712,13 +712,8 @@ public string[] GetOptionsListIds() throw new NotFoundException("Options folder not found."); } - string[] fileNames = GetFilesByRelativeDirectory(optionsFolder, "*.json"); - List optionsListIds = []; - foreach (string fileName in fileNames.Select(Path.GetFileNameWithoutExtension)) - { - optionsListIds.Add(fileName); - } - + string[] fileNames = GetFilesByRelativeDirectoryAscSorted(optionsFolder, "*.json"); + IEnumerable optionsListIds = fileNames.Select(Path.GetFileNameWithoutExtension); return optionsListIds.ToArray(); } @@ -779,6 +774,18 @@ public void DeleteOptionsList(string optionsListId) DeleteFileByRelativePath(optionsFilePath); } + /// + /// Updates the ID of the option list by updating file name. + /// + /// The file name of the option list to change filename of. + /// The new file name of the option list file. + public void UpdateOptionsListId(string oldOptionsListFileName, string newOptionsListFileName) + { + string currentFilePath = Path.Combine(OptionsFolderPath, oldOptionsListFileName); + string newFilePath = Path.Combine(OptionsFolderPath, newOptionsListFileName); + MoveFileByRelativePath(currentFilePath, newFilePath, newOptionsListFileName); + } + /// /// Saves the process definition file on disk. /// diff --git a/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs b/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs index 4b999c8cc03..cd39f3cd3eb 100644 --- a/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs +++ b/backend/src/Designer/Infrastructure/GitRepository/GitRepository.cs @@ -83,6 +83,19 @@ protected string[] GetFilesByRelativeDirectory(string relativeDirectory, string return Directory.GetFiles(absoluteDirectory, searchPatternMatch, searchOption); } + /// + /// Gets all the files within the specified directory in an alphabetically sorted order. + /// + /// Relative path to a directory within the repository. + /// An optional pattern that the retrieved files must match + /// An optional parameter to also get files in sub directories + protected string[] GetFilesByRelativeDirectoryAscSorted(string relativeDirectory, string patternMatch = null, bool searchInSubdirectories = false) + { + string[] fileNames = GetFilesByRelativeDirectory(relativeDirectory, patternMatch, searchInSubdirectories); + + return fileNames.OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray(); + } + /// /// Gets all the directories within the specified directory. /// diff --git a/backend/src/Designer/Services/Implementation/OptionsService.cs b/backend/src/Designer/Services/Implementation/OptionsService.cs index 137b0cf5c34..5dc4e7a26a4 100644 --- a/backend/src/Designer/Services/Implementation/OptionsService.cs +++ b/backend/src/Designer/Services/Implementation/OptionsService.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Altinn.Studio.Designer.Exceptions.Options; +using Altinn.Studio.Designer.Infrastructure.GitRepository; using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Services.Interfaces; using LibGit2Sharp; @@ -126,4 +127,15 @@ public async Task OptionsListExists(string org, string repo, string develo return false; } } + + /// + public void UpdateOptionsListId(AltinnRepoEditingContext altinnRepoEditingContext, string optionsListId, + string newOptionsListName, CancellationToken cancellationToken = default) + { + AltinnAppGitRepository altinnAppGitRepository = + _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, + altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); + altinnAppGitRepository.UpdateOptionsListId($"{optionsListId}.json", $"{newOptionsListName}.json"); + + } } diff --git a/backend/src/Designer/Services/Interfaces/IOptionsService.cs b/backend/src/Designer/Services/Interfaces/IOptionsService.cs index 12f830bed5f..0a291a45819 100644 --- a/backend/src/Designer/Services/Interfaces/IOptionsService.cs +++ b/backend/src/Designer/Services/Interfaces/IOptionsService.cs @@ -61,7 +61,7 @@ public interface IOptionsService /// Organisation /// Repository /// Username of developer - /// Name of the new options list + /// Name of the options list public void DeleteOptionsList(string org, string repo, string developer, string optionsListId); /// @@ -73,4 +73,13 @@ public interface IOptionsService /// Name of the options list /// A that observes if operation is cancelled. public Task OptionsListExists(string org, string repo, string developer, string optionsListId, CancellationToken cancellationToken = default); + + /// + /// Updates the name of the options list by changing the filename. + /// + /// An . + /// Name of the options list + /// The new name of the options list file. + /// A that observes if operation is cancelled. + public void UpdateOptionsListId(AltinnRepoEditingContext altinnRepoEditingContext, string optionsListId, string newOptionsListId, CancellationToken cancellationToken = default); } diff --git a/backend/tests/Designer.Tests/Controllers/OptionsController/UpdateOptionListIdTests.cs b/backend/tests/Designer.Tests/Controllers/OptionsController/UpdateOptionListIdTests.cs new file mode 100644 index 00000000000..7a8639b4271 --- /dev/null +++ b/backend/tests/Designer.Tests/Controllers/OptionsController/UpdateOptionListIdTests.cs @@ -0,0 +1,101 @@ +using System.Net.Http; +using System.Net.Mime; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using Designer.Tests.Controllers.ApiTests; +using Designer.Tests.Utils; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Testing; +using Xunit; + +namespace Designer.Tests.Controllers.OptionsController; + +public class UpdateOptionListIdTests(WebApplicationFactory factory) + : DesignerEndpointsTestsBase(factory), IClassFixture> +{ + private const string Org = "ttd"; + private const string Developer = "testUser"; + + [Fact] + public async Task Put_Returns_200OK_With_Same_File_Content_When_Updating_Id() + { + // Arrange + const string repo = "app-with-options"; + const string optionsListId = "test-options"; + const string newOptionListId = "new-option-list-id"; + + string targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest(Org, repo, Developer, targetRepository); + var originalOptionList = TestDataHelper.GetFileFromRepo(Org, repo, Developer, $"App/options/{optionsListId}.json"); + + string apiUrl = $"/designer/api/{Org}/{targetRepository}/options/change-name/{optionsListId}"; + using HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, apiUrl); + httpRequestMessage.Content = + new StringContent($"\"{newOptionListId}\"", Encoding.UTF8, MediaTypeNames.Application.Json); + + // Act + using HttpResponseMessage response = await HttpClient.SendAsync(httpRequestMessage); + + // Assert + var optionListWithNewId = TestDataHelper.GetFileFromRepo(Org, targetRepository, Developer, $"App/options/{newOptionListId}.json"); + Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode); + Assert.Equal(originalOptionList, optionListWithNewId); + } + + [Fact] + public async Task Put_Returns_400BadRequest_When_Updating_Id_To_Existing_Option_File_Name() + { + // Arrange + const string repo = "app-with-options"; + const string optionsListId = "test-options"; + const string newOptionListId = "other-options"; + + string targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest(Org, repo, Developer, targetRepository); + var originalOptionList = TestDataHelper.GetFileFromRepo(Org, repo, Developer, $"App/options/{optionsListId}.json"); + + string apiUrl = $"/designer/api/{Org}/{targetRepository}/options/change-name/{optionsListId}"; + using HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, apiUrl); + httpRequestMessage.Content = + new StringContent($"\"{newOptionListId}\"", Encoding.UTF8, MediaTypeNames.Application.Json); + + // Act + using HttpResponseMessage response = await HttpClient.SendAsync(httpRequestMessage); + var responseContent = await response.Content.ReadAsStringAsync(); + string responseString = JsonSerializer.Deserialize(responseContent); + + // Assert + var optionListWithSameId = TestDataHelper.GetFileFromRepo(Org, targetRepository, Developer, $"App/options/{optionsListId}.json"); + Assert.Equal(StatusCodes.Status400BadRequest, (int)response.StatusCode); + Assert.Equal($"Suggested file name {newOptionListId}.json already exists.", responseString); + Assert.Equal(originalOptionList, optionListWithSameId); + } + + [Fact] + public async Task Put_Returns_400BadRequest_When_Updating_Id_Of_NonExistent_Option_File() + { + // Arrange + const string repo = "app-with-options"; + const string optionsListId = "options-that-does-not-exist"; + const string newOptionListId = "new-option-list-id"; + + string targetRepository = TestDataHelper.GenerateTestRepoName(); + await CopyRepositoryForTest(Org, repo, Developer, targetRepository); + string relativePath = $"App/options/{optionsListId}.json"; + + string apiUrl = $"/designer/api/{Org}/{targetRepository}/options/change-name/{optionsListId}"; + using HttpRequestMessage httpRequestMessage = new(HttpMethod.Put, apiUrl); + httpRequestMessage.Content = + new StringContent($"\"{newOptionListId}\"", Encoding.UTF8, MediaTypeNames.Application.Json); + + // Act + using HttpResponseMessage response = await HttpClient.SendAsync(httpRequestMessage); + var responseContent = await response.Content.ReadAsStringAsync(); + string responseString = JsonSerializer.Deserialize(responseContent); + + // Assert + Assert.Equal(StatusCodes.Status400BadRequest, (int)response.StatusCode); + Assert.Equal($"File {relativePath} does not exist.", responseString); + } +} From 94be9b997e9c9afd98fdaeaf33eba92e4dbf9307 Mon Sep 17 00:00:00 2001 From: andreastanderen <71079896+standeren@users.noreply.github.com> Date: Wed, 11 Dec 2024 09:02:21 +0100 Subject: [PATCH 28/35] feat: refactor SchemaModelService to update appMetadata when creating new data model (#14209) Co-authored-by: Erling Hauan <148075168+ErlingHauan@users.noreply.github.com> --- .../Controllers/DatamodelsController.cs | 10 +- .../Implementation/SchemaModelService.cs | 201 ++++++++---------- .../Interfaces/ISchemaModelService.cs | 8 +- .../DataModelsController/PostTests.cs | 31 +++ .../DataModelsController/PutDatamodelTests.cs | 1 - 5 files changed, 133 insertions(+), 118 deletions(-) diff --git a/backend/src/Designer/Controllers/DatamodelsController.cs b/backend/src/Designer/Controllers/DatamodelsController.cs index bb115cc0d57..f7c57a88525 100644 --- a/backend/src/Designer/Controllers/DatamodelsController.cs +++ b/backend/src/Designer/Controllers/DatamodelsController.cs @@ -163,17 +163,17 @@ public async Task AddXsd(string org, string repository, [FromForm Request.EnableBuffering(); Guard.AssertArgumentNotNull(theFile, nameof(theFile)); - string fileName = GetFileNameFromUploadedFile(theFile); - Guard.AssertFileExtensionIsOfType(fileName, ".xsd"); + string fileNameWithExtension = GetFileNameFromUploadedFile(theFile); + Guard.AssertFileExtensionIsOfType(fileNameWithExtension, ".xsd"); string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext); var editingContext = AltinnRepoEditingContext.FromOrgRepoDeveloper(org, repository, developer); var fileStream = theFile.OpenReadStream(); - await _modelNameValidator.ValidateModelNameForNewXsdSchemaAsync(fileStream, fileName, editingContext); - string jsonSchema = await _schemaModelService.BuildSchemaFromXsd(editingContext, fileName, theFile.OpenReadStream(), cancellationToken); + await _modelNameValidator.ValidateModelNameForNewXsdSchemaAsync(fileStream, fileNameWithExtension, editingContext); + string jsonSchema = await _schemaModelService.BuildSchemaFromXsd(editingContext, fileNameWithExtension, theFile.OpenReadStream(), cancellationToken); - return Created(Uri.EscapeDataString(fileName), jsonSchema); + return Created(Uri.EscapeDataString(fileNameWithExtension), jsonSchema); } /// diff --git a/backend/src/Designer/Services/Implementation/SchemaModelService.cs b/backend/src/Designer/Services/Implementation/SchemaModelService.cs index 574bcaa4da2..3067a31d98d 100644 --- a/backend/src/Designer/Services/Implementation/SchemaModelService.cs +++ b/backend/src/Designer/Services/Implementation/SchemaModelService.cs @@ -21,7 +21,7 @@ using Altinn.Studio.Designer.Models; using Altinn.Studio.Designer.Models.App; using Altinn.Studio.Designer.Services.Interfaces; - +using Json.Schema; using Microsoft.Extensions.Logging; namespace Altinn.Studio.Designer.Services.Implementation @@ -97,9 +97,14 @@ public async Task UpdateSchema(AltinnRepoEditingContext altinnRepoEditingContext { cancellationToken.ThrowIfCancellationRequested(); var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - var jsonSchema = Json.Schema.JsonSchema.FromText(jsonContent); + var schemaFileName = altinnAppGitRepository.GetSchemaName(relativeFilePath); + var jsonSchema = JsonSchema.FromText(jsonContent); var serializedJsonContent = SerializeJson(jsonSchema); + + await altinnAppGitRepository.SaveJsonSchema(serializedJsonContent, schemaFileName); + altinnAppGitRepository.DeleteModelMetadata(relativeFilePath.Replace(".schema.json", ".metadata.json")); + if (saveOnly) { // Only save updated JSON schema - no model file generation @@ -111,40 +116,30 @@ public async Task UpdateSchema(AltinnRepoEditingContext altinnRepoEditingContext if (repositoryType == AltinnRepositoryType.Datamodels) { - // Datamodels repository - save JSON and update XSD + // Data models repository - save JSON and update XSD await altinnAppGitRepository.WriteTextByRelativePathAsync(relativeFilePath, serializedJsonContent, true, cancellationToken); - XmlSchema xsd = _jsonSchemaToXmlSchemaConverter.Convert(jsonSchema); - await altinnAppGitRepository.SaveXsd(xsd, relativeFilePath.Replace(".schema.json", ".xsd")); + await UpdateXsdFromJsonSchema(altinnAppGitRepository, jsonSchema, schemaFileName); return; } - await UpdateModelFilesFromJsonSchema(altinnRepoEditingContext, relativeFilePath, serializedJsonContent, cancellationToken); + ModelMetadata modelMetadata = GetModelMetadataForCsharpGeneration(serializedJsonContent, jsonSchema); + string csharpModelName = modelMetadata.GetRootElement().TypeName; + await UpdateCSharpClasses(altinnAppGitRepository, modelMetadata, schemaFileName); + await UpdateApplicationMetadata(altinnAppGitRepository, schemaFileName, csharpModelName); + await UpdateXsdFromJsonSchema(altinnAppGitRepository, jsonSchema, schemaFileName); } - /// - public async Task UpdateModelFilesFromJsonSchema(AltinnRepoEditingContext altinnRepoEditingContext, string relativeFilePath, string jsonContent, CancellationToken cancellationToken = default) + private async Task UpdateXsdFromJsonSchema(AltinnAppGitRepository altinnAppGitRepository, JsonSchema jsonSchema, string schemaFileName) { - cancellationToken.ThrowIfCancellationRequested(); - var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - var schemaName = altinnAppGitRepository.GetSchemaName(relativeFilePath); - - var jsonSchema = Json.Schema.JsonSchema.FromText(jsonContent); - var serializedJsonContent = SerializeJson(jsonSchema); - await altinnAppGitRepository.SaveJsonSchema(serializedJsonContent, schemaName); - var jsonSchemaConverterStrategy = JsonSchemaConverterStrategyFactory.SelectStrategy(jsonSchema); - XmlSchema xsd = _jsonSchemaToXmlSchemaConverter.Convert(jsonSchema); + await altinnAppGitRepository.SaveXsd(xsd, Path.ChangeExtension(schemaFileName, "xsd")); + } - await altinnAppGitRepository.SaveXsd(xsd, Path.ChangeExtension(schemaName, "xsd")); - + private ModelMetadata GetModelMetadataForCsharpGeneration(string jsonContent, JsonSchema jsonSchema) + { + var jsonSchemaConverterStrategy = JsonSchemaConverterStrategyFactory.SelectStrategy(jsonSchema); var metamodelConverter = new JsonSchemaToMetamodelConverter(jsonSchemaConverterStrategy.GetAnalyzer()); - ModelMetadata modelMetadata = metamodelConverter.Convert(jsonContent); - - string fullTypeName = await UpdateCSharpClasses(altinnAppGitRepository, modelMetadata, schemaName); - - await UpdateApplicationMetadata(altinnAppGitRepository, schemaName, fullTypeName); - - return jsonContent; + return metamodelConverter.Convert(jsonContent); } public async Task GenerateModelMetadataFromJsonSchema(AltinnRepoEditingContext altinnRepoEditingContext, string relativeFilePath, CancellationToken cancellationToken = default) @@ -152,10 +147,8 @@ public async Task GenerateModelMetadataFromJsonSchema(AltinnRepoE cancellationToken.ThrowIfCancellationRequested(); var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); var jsonContent = await altinnAppGitRepository.ReadTextByRelativePathAsync(relativeFilePath, cancellationToken); - var jsonSchema = Json.Schema.JsonSchema.FromText(jsonContent); - var jsonSchemaConverterStrategy = JsonSchemaConverterStrategyFactory.SelectStrategy(jsonSchema); - var metamodelConverter = new JsonSchemaToMetamodelConverter(jsonSchemaConverterStrategy.GetAnalyzer()); - return metamodelConverter.Convert(jsonContent); + var jsonSchema = JsonSchema.FromText(jsonContent); + return GetModelMetadataForCsharpGeneration(jsonContent, jsonSchema); } /// @@ -165,40 +158,48 @@ public async Task GenerateModelMetadataFromJsonSchema(AltinnRepoE /// This operation is using the new data modelling library. /// /// An . - /// The name of the file being uploaded. + /// The name of the file being uploaded. /// Stream representing the XSD. /// An that observes if operation is cancelled. - public async Task BuildSchemaFromXsd(AltinnRepoEditingContext altinnRepoEditingContext, string modelName, Stream xsdStream, CancellationToken cancellationToken = default) + public async Task BuildSchemaFromXsd(AltinnRepoEditingContext altinnRepoEditingContext, + string fileNameWithExtension, Stream xsdStream, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - using MemoryStream xsdMemoryStream = new MemoryStream(); - xsdStream.CopyTo(xsdMemoryStream); - string jsonContent; - AltinnRepositoryType altinnRepositoryType = await altinnAppGitRepository.GetRepositoryType(); + MemoryStream xsdMemoryStream = GetXsdMemoryStream(xsdStream); + JsonSchema jsonSchema = GenerateJsonSchemaFromXsd(xsdMemoryStream); + string serializedJsonContent = SerializeJson(jsonSchema); + AltinnRepositoryType altinnRepositoryType = await altinnAppGitRepository.GetRepositoryType(); if (altinnRepositoryType == AltinnRepositoryType.Datamodels) { - xsdMemoryStream.Position = 0; - Json.Schema.JsonSchema jsonSchema = GenerateJsonSchemaFromXsd(xsdMemoryStream); - jsonContent = SerializeJson(jsonSchema); - - await altinnAppGitRepository.WriteTextByRelativePathAsync(Path.ChangeExtension(modelName, "schema.json"), jsonContent, true, cancellationToken); - - return jsonContent; + await altinnAppGitRepository.WriteTextByRelativePathAsync(Path.ChangeExtension(fileNameWithExtension, "schema.json"), serializedJsonContent, true, cancellationToken); + return serializedJsonContent; } /* From here repository is assumed to be for an app. Validate with a Directory.Exist check? */ - await altinnAppGitRepository.SaveXsd(xsdMemoryStream, modelName); - - jsonContent = await ProcessNewXsd(altinnAppGitRepository, xsdMemoryStream, modelName); + var schemaFileName = altinnAppGitRepository.GetSchemaName(fileNameWithExtension); + await altinnAppGitRepository.SaveXsd(xsdMemoryStream, fileNameWithExtension); + await altinnAppGitRepository.SaveJsonSchema(serializedJsonContent, schemaFileName); + ModelMetadata modelMetadata = GetModelMetadataForCsharpGeneration(serializedJsonContent, jsonSchema); + string csharpModelName = modelMetadata.GetRootElement().TypeName; + await UpdateCSharpClasses(altinnAppGitRepository, modelMetadata, schemaFileName); + await UpdateApplicationMetadata(altinnAppGitRepository, schemaFileName, csharpModelName); + + return serializedJsonContent; + } - return jsonContent; + private MemoryStream GetXsdMemoryStream(Stream xsdStream) + { + MemoryStream xsdMemoryStream = new MemoryStream(); + xsdStream.CopyTo(xsdMemoryStream); + xsdMemoryStream.Position = 0; + return xsdMemoryStream; } /// - public async Task<(string RelativePath, string JsonSchema)> CreateSchemaFromTemplate(AltinnRepoEditingContext altinnRepoEditingContext, string schemaName, string relativeDirectory = "", bool altinn2Compatible = false, CancellationToken cancellationToken = default) + public async Task<(string RelativePath, string JsonSchema)> CreateSchemaFromTemplate(AltinnRepoEditingContext altinnRepoEditingContext, string schemaAndModelName, string relativeDirectory = "", bool altinn2Compatible = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var altinnGitRepository = _altinnGitRepositoryFactory.GetAltinnGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); @@ -209,12 +210,12 @@ public async Task BuildSchemaFromXsd(AltinnRepoEditingContext altinnRepo if (await altinnGitRepository.GetRepositoryType() == AltinnRepositoryType.Datamodels) { - var uri = GetSchemaUri(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, schemaName, relativeDirectory); - JsonTemplate jsonTemplate = altinn2Compatible ? new SeresJsonTemplate(uri, schemaName) : new GeneralJsonTemplate(uri, schemaName); + var uri = GetSchemaUri(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, schemaAndModelName, relativeDirectory); + JsonTemplate jsonTemplate = altinn2Compatible ? new SeresJsonTemplate(uri, schemaAndModelName) : new GeneralJsonTemplate(uri, schemaAndModelName); var jsonSchema = jsonTemplate.GetJsonString(); - var relativeFilePath = Path.ChangeExtension(Path.Combine(relativeDirectory, schemaName), ".schema.json"); + var relativeFilePath = Path.ChangeExtension(Path.Combine(relativeDirectory, schemaAndModelName), ".schema.json"); await altinnGitRepository.WriteTextByRelativePathAsync(relativeFilePath, jsonSchema, true, cancellationToken); return (relativeFilePath, jsonSchema); @@ -224,12 +225,14 @@ public async Task BuildSchemaFromXsd(AltinnRepoEditingContext altinnRepo var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); var modelFolder = altinnAppGitRepository.GetRelativeModelFolder(); - var uri = GetSchemaUri(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, schemaName, modelFolder); - JsonTemplate jsonTemplate = altinn2Compatible ? new SeresJsonTemplate(uri, schemaName) : new GeneralJsonTemplate(uri, schemaName); + var uri = GetSchemaUri(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, schemaAndModelName, modelFolder); + JsonTemplate jsonTemplate = altinn2Compatible ? new SeresJsonTemplate(uri, schemaAndModelName) : new GeneralJsonTemplate(uri, schemaAndModelName); var jsonSchema = jsonTemplate.GetJsonString(); - var relativePath = await altinnAppGitRepository.SaveJsonSchema(jsonSchema, schemaName); + var relativePath = await altinnAppGitRepository.SaveJsonSchema(jsonSchema, schemaAndModelName); + + await UpdateApplicationMetadata(altinnAppGitRepository, schemaAndModelName, schemaAndModelName); return (relativePath, jsonSchema); } @@ -244,11 +247,11 @@ public async Task DeleteSchema(AltinnRepoEditingContext altinnRepoEditingContext if (await altinnGitRepository.GetRepositoryType() == AltinnRepositoryType.App) { var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(altinnRepoEditingContext.Org, altinnRepoEditingContext.Repo, altinnRepoEditingContext.Developer); - var altinnCoreFile = altinnGitRepository.GetAltinnCoreFileByRelativePath(relativeFilePath); - var schemaName = altinnGitRepository.GetSchemaName(relativeFilePath); + var altinnCoreFile = altinnAppGitRepository.GetAltinnCoreFileByRelativePath(relativeFilePath); + var schemaFileName = altinnAppGitRepository.GetSchemaName(relativeFilePath); - await DeleteDatatypeFromApplicationMetadataAndLayoutSets(altinnAppGitRepository, schemaName); - DeleteRelatedSchemaFiles(altinnAppGitRepository, schemaName, altinnCoreFile.Directory); + await DeleteDatatypeFromApplicationMetadataAndLayoutSets(altinnAppGitRepository, schemaFileName); + DeleteRelatedSchemaFiles(altinnAppGitRepository, schemaFileName, altinnCoreFile.Directory); } else { @@ -261,10 +264,10 @@ public async Task DeleteSchema(AltinnRepoEditingContext altinnRepoEditingContext /// /// Organization owning the repository identified by it's short name. /// Repository name to search for schema files. - /// The logical name of the schema ie. filename without extension. + /// The logical name of the schema ie. filename without extension. /// The relative path (from repository root) to where the schema should be stored. /// Returns a resolvable uri to the location of the schema. - public Uri GetSchemaUri(string org, string repository, string schemaName, string relativePath = "") + public Uri GetSchemaUri(string org, string repository, string schemaFileName, string relativePath = "") { var baseUrl = _serviceRepositorySettings.RepositoryBaseURL; baseUrl = baseUrl.TrimEnd("/".ToCharArray()); @@ -273,19 +276,19 @@ public Uri GetSchemaUri(string org, string repository, string schemaName, string if (string.IsNullOrEmpty(relativePath)) { - schemaUri = new Uri($"{baseUrl}/{org}/{repository}/{schemaName}.schema.json"); + schemaUri = new Uri($"{baseUrl}/{org}/{repository}/{schemaFileName}.schema.json"); } else { relativePath = relativePath.TrimEnd('/'); relativePath = relativePath.TrimStart('/'); - schemaUri = new Uri($"{baseUrl}/{org}/{repository}/{relativePath}/{schemaName}.schema.json"); + schemaUri = new Uri($"{baseUrl}/{org}/{repository}/{relativePath}/{schemaFileName}.schema.json"); } return schemaUri; } - private Json.Schema.JsonSchema GenerateJsonSchemaFromXsd(Stream xsdStream) + private JsonSchema GenerateJsonSchemaFromXsd(Stream xsdStream) { XmlSchema originalXsd; try @@ -298,28 +301,28 @@ private Json.Schema.JsonSchema GenerateJsonSchemaFromXsd(Stream xsdStream) List customErrorMessages = new() { ex.Message }; throw new InvalidXmlException("Could not read invalid xml", customErrorMessages); } - Json.Schema.JsonSchema convertedJsonSchema = _xmlSchemaToJsonSchemaConverter.Convert(originalXsd); + JsonSchema convertedJsonSchema = _xmlSchemaToJsonSchemaConverter.Convert(originalXsd); return convertedJsonSchema; } - private async Task UpdateCSharpClasses(AltinnAppGitRepository altinnAppGitRepository, ModelMetadata modelMetadata, string schemaName) + private async Task UpdateCSharpClasses(AltinnAppGitRepository altinnAppGitRepository, ModelMetadata modelMetadata, string schemaFileName) { ApplicationMetadata application = await altinnAppGitRepository.GetApplicationMetadata(); - string modelName = modelMetadata.GetRootElement().TypeName; - bool separateNamespace = !application.DataTypes.Any(d => d.AppLogic?.ClassRef == $"Altinn.App.Models.{modelName}"); - - string csharpClasses = _modelMetadataToCsharpConverter.CreateModelFromMetadata(modelMetadata, separateNamespace, useNullableReferenceTypes: false); - await altinnAppGitRepository.SaveCSharpClasses(csharpClasses, schemaName); - return separateNamespace ? $"Altinn.App.Models.{modelName}.{modelName}" : $"Altinn.App.Models.{modelName}"; + string csharpModelName = modelMetadata.GetRootElement().TypeName; + bool separateNamespace = NamespaceNeedsToBeSeparated(application, csharpModelName); + string csharpClasses = _modelMetadataToCsharpConverter.CreateModelFromMetadata(modelMetadata, + separateNamespace, useNullableReferenceTypes: false); + await altinnAppGitRepository.SaveCSharpClasses(csharpClasses, schemaFileName); } - private static async Task UpdateApplicationMetadata(AltinnAppGitRepository altinnAppGitRepository, string schemaName, string fullTypeName) + private async Task UpdateApplicationMetadata(AltinnAppGitRepository altinnAppGitRepository, string schemaFileName, string csharpModelName) { ApplicationMetadata application = await altinnAppGitRepository.GetApplicationMetadata(); - UpdateApplicationWithAppLogicModel(application, schemaName, fullTypeName); + string fullTypeName = GetFullTypeName(application, csharpModelName); + UpdateApplicationWithAppLogicModel(application, schemaFileName, fullTypeName); await altinnAppGitRepository.SaveApplicationMetadata(application); } @@ -365,7 +368,7 @@ private static void UpdateApplicationWithAppLogicModel(ApplicationMetadata appli } } - private static string SerializeJson(Json.Schema.JsonSchema jsonSchema) + private static string SerializeJson(JsonSchema jsonSchema) { return JsonSerializer.Serialize( jsonSchema, @@ -376,34 +379,22 @@ private static string SerializeJson(Json.Schema.JsonSchema jsonSchema) }); } - private static string SerializeModelMetadata(ModelMetadata modelMetadata) + private static void DeleteRelatedSchemaFiles(AltinnAppGitRepository altinnAppGitRepository, string schemaFileName, string directory) { - return JsonSerializer.Serialize( - modelMetadata, - new JsonSerializerOptions - { - Encoder = JavaScriptEncoder.Create(UnicodeRanges.BasicLatin, UnicodeRanges.Latin1Supplement), - WriteIndented = true - }); - } - - private static void DeleteRelatedSchemaFiles(AltinnAppGitRepository altinnAppGitRepository, string schemaName, string directory) - { - var files = GetRelatedSchemaFiles(schemaName, directory); + var files = GetRelatedSchemaFiles(schemaFileName, directory); foreach (var file in files) { altinnAppGitRepository.DeleteFileByAbsolutePath(file); } } - private static IEnumerable GetRelatedSchemaFiles(string schemaName, string directory) + private static IEnumerable GetRelatedSchemaFiles(string schemaFileName, string directory) { - var xsdFile = Path.Combine(directory, $"{schemaName}.xsd"); - var jsonSchemaFile = Path.Combine(directory, $"{schemaName}.schema.json"); - var jsonMetadataFile = Path.Combine(directory, $"{schemaName}.metadata.json"); - var csharpModelFile = Path.Combine(directory, $"{schemaName}.cs"); + var xsdFile = Path.Combine(directory, $"{schemaFileName}.xsd"); + var jsonSchemaFile = Path.Combine(directory, $"{schemaFileName}.schema.json"); + var csharpModelFile = Path.Combine(directory, $"{schemaFileName}.cs"); - return new List() { jsonSchemaFile, xsdFile, jsonMetadataFile, csharpModelFile }; + return new List() { jsonSchemaFile, xsdFile, csharpModelFile }; } private static async Task DeleteDatatypeFromApplicationMetadataAndLayoutSets(AltinnAppGitRepository altinnAppGitRepository, string id) @@ -428,23 +419,17 @@ private static async Task DeleteDatatypeFromApplicationMetadataAndLayoutSets(Alt } } - private async Task ProcessNewXsd(AltinnAppGitRepository altinnAppGitRepository, MemoryStream xsdMemoryStream, string filePath) + private string GetFullTypeName(ApplicationMetadata application, + string csharpModelName) { - var schemaName = altinnAppGitRepository.GetSchemaName(filePath); - - Json.Schema.JsonSchema jsonSchema = GenerateJsonSchemaFromXsd(xsdMemoryStream); - var jsonContent = SerializeJson(jsonSchema); - await altinnAppGitRepository.SaveJsonSchema(jsonContent, schemaName); - - var jsonSchemaConverterStrategy = JsonSchemaConverterStrategyFactory.SelectStrategy(jsonSchema); - var metamodelConverter = new JsonSchemaToMetamodelConverter(jsonSchemaConverterStrategy.GetAnalyzer()); - var modelMetadata = metamodelConverter.Convert(jsonContent); - - string fullTypeName = await UpdateCSharpClasses(altinnAppGitRepository, modelMetadata, schemaName); - - await UpdateApplicationMetadata(altinnAppGitRepository, schemaName, fullTypeName); + bool separateNamespace = NamespaceNeedsToBeSeparated(application, csharpModelName); + return separateNamespace ? $"Altinn.App.Models.{csharpModelName}.{csharpModelName}" : $"Altinn.App.Models.{csharpModelName}"; + } - return jsonContent; + private bool NamespaceNeedsToBeSeparated(ApplicationMetadata application, + string csharpModelName) + { + return application.DataTypes.All(d => d.AppLogic?.ClassRef != $"Altinn.App.Models.{csharpModelName}"); } } } diff --git a/backend/src/Designer/Services/Interfaces/ISchemaModelService.cs b/backend/src/Designer/Services/Interfaces/ISchemaModelService.cs index 6d1bcb77833..c043f7fa8cd 100644 --- a/backend/src/Designer/Services/Interfaces/ISchemaModelService.cs +++ b/backend/src/Designer/Services/Interfaces/ISchemaModelService.cs @@ -48,22 +48,22 @@ public interface ISchemaModelService /// Builds a JSON schema based on the uploaded XSD. /// /// An . - /// The name of the new model. + /// The name of the new file. /// Stream representing the XSD. /// An that observes if operation is cancelled. - Task BuildSchemaFromXsd(AltinnRepoEditingContext altinnRepoEditingContext, string modelName, Stream xsdStream, CancellationToken cancellationToken = default); + Task BuildSchemaFromXsd(AltinnRepoEditingContext altinnRepoEditingContext, string fileNameWithExtension, Stream xsdStream, CancellationToken cancellationToken = default); /// /// Creates a JSON schema based on a template. /// /// An . - /// The name of the schema/model (no extension). + /// The name of the schema/model (no extension). /// The directory where the schema should be created. Applies only for schemas /// created in a data models repository. For app repositories the directory is determined by the app and the parameter is ignored. /// True if the schema should be Altinn 2 compatible when generating XSD. False (default) creates a Altinn 3 schema. /// An that observes if operation is cancelled. /// Returns a tuple where the first string is the relative path to the file and the second is the Json Schema created. - Task<(string RelativePath, string JsonSchema)> CreateSchemaFromTemplate(AltinnRepoEditingContext altinnRepoEditingContext, string schemaName, string relativeDirectory = "", bool altinn2Compatible = false, CancellationToken cancellationToken = default); + Task<(string RelativePath, string JsonSchema)> CreateSchemaFromTemplate(AltinnRepoEditingContext altinnRepoEditingContext, string schemaAndModelName, string relativeDirectory = "", bool altinn2Compatible = false, CancellationToken cancellationToken = default); /// /// Deletes a schema based on the relative path to the JSON Schema within the repository. diff --git a/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs b/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs index 69afecad8f6..2ab1b27af7d 100644 --- a/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs +++ b/backend/tests/Designer.Tests/Controllers/DataModelsController/PostTests.cs @@ -3,6 +3,7 @@ using System.Net.Http.Json; using System.Text.Json; using System.Threading.Tasks; +using Altinn.Studio.Designer.Models.App; using Altinn.Studio.Designer.ViewModels.Request; using Designer.Tests.Controllers.ApiTests; using Designer.Tests.Utils; @@ -15,6 +16,10 @@ namespace Designer.Tests.Controllers.DataModelsController; public class PostTests : DesignerEndpointsTestsBase, IClassFixture> { private static string VersionPrefix(string org, string repository) => $"/designer/api/{org}/{repository}/datamodels"; + private static readonly string Org = "ttd"; + private static readonly string Repo = "empty-app"; + private static readonly string Developer = "testUser"; + public PostTests(WebApplicationFactory factory) : base(factory) { } @@ -58,6 +63,32 @@ public async Task PostDatamodel_FromFormPost_ShouldReturnCreatedFromTemplate(str Assert.Equal(postContent, getContent); } + [Fact] + public async Task PostDatamodel_CreateNew_ShouldAddDataTypeWithModelIdToAppMetadata() + { + string targetRepository = TestDataHelper.GenerateTestRepoName(); + + await CopyRepositoryForTest(Org, Repo, Developer, targetRepository); + string url = $"{VersionPrefix(Org, targetRepository)}/new"; + + string modelAndSchemaName = "modelAndSchemaName"; + var createViewModel = new CreateModelViewModel() + { ModelName = modelAndSchemaName, RelativeDirectory = "", Altinn2Compatible = false }; + + using var postRequestMessage = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = JsonContent.Create(createViewModel, null, new JsonSerializerOptions() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }) + }; + + using var postResponse = await HttpClient.SendAsync(postRequestMessage); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + var applicationMetadata = + TestDataHelper.GetFileFromRepo(Org, targetRepository, Developer, "App/config/applicationmetadata.json"); + ApplicationMetadata deserializedApplicationMetadata = JsonSerializer.Deserialize(applicationMetadata, JsonSerializerOptions); + Assert.True(deserializedApplicationMetadata.DataTypes.Exists(dataType => dataType.Id == modelAndSchemaName)); + } + [Theory] [InlineData("", "ServiceA", true)] [InlineData("test<", "", false)] diff --git a/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs b/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs index 4dce13dce17..0ee22606477 100644 --- a/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs +++ b/backend/tests/Designer.Tests/Controllers/DataModelsController/PutDatamodelTests.cs @@ -149,7 +149,6 @@ private async Task FilesWithCorrectNameAndContentShouldBeCreated(string modelNam var location = Path.GetFullPath(Path.Combine(TestRepoPath, "App", "models")); var jsonSchemaLocation = Path.Combine(location, $"{modelName}.schema.json"); var xsdSchemaLocation = Path.Combine(location, $"{modelName}.xsd"); - var metamodelLocation = Path.Combine(location, $"{modelName}.metadata.json"); Assert.True(File.Exists(xsdSchemaLocation)); Assert.True(File.Exists(jsonSchemaLocation)); From 0f7b868c2eff164b689dfd3efce1148c61027a63 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Thu, 12 Dec 2024 10:23:08 +0100 Subject: [PATCH 29/35] refactor: Code cleanup in code list editor (#14258) --- .../StudioCodeListEditor.test.tsx | 32 +++++++---- .../StudioCodeListEditor.tsx | 55 +++++++++++++++---- .../StudioCodeListEditorRow.tsx | 40 +++++++------- .../pages/CodeList/CodeLists/CodeLists.tsx | 4 +- .../OptionListEditor/OptionListEditor.tsx | 8 +-- 5 files changed, 92 insertions(+), 47 deletions(-) diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx index b78acd0001b..ea8ff99beaa 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.test.tsx @@ -46,11 +46,13 @@ const codeList: CodeList = [ helpText: 'Test 3 help text', }, ]; +const onBlurAny = jest.fn(); const onChange = jest.fn(); const onInvalid = jest.fn(); const defaultProps: StudioCodeListEditorProps = { codeList, texts, + onBlurAny, onChange, onInvalid, }; @@ -119,8 +121,7 @@ describe('StudioCodeListEditor', () => { const labelInput = screen.getByRole('textbox', { name: texts.itemLabel(1) }); const newValue = 'new text'; await user.type(labelInput, newValue); - await user.tab(); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(newValue.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], label: newValue }, codeList[1], @@ -134,8 +135,7 @@ describe('StudioCodeListEditor', () => { const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) }); const newValue = 'new text'; await user.type(valueInput, newValue); - await user.tab(); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(newValue.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], value: newValue }, codeList[1], @@ -149,8 +149,7 @@ describe('StudioCodeListEditor', () => { const descriptionInput = screen.getByRole('textbox', { name: texts.itemDescription(1) }); const newValue = 'new text'; await user.type(descriptionInput, newValue); - await user.tab(); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(newValue.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], description: newValue }, codeList[1], @@ -164,8 +163,7 @@ describe('StudioCodeListEditor', () => { const helpTextInput = screen.getByRole('textbox', { name: texts.itemHelpText(1) }); const newValue = 'new text'; await user.type(helpTextInput, newValue); - await user.tab(); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(newValue.length); expect(onChange).toHaveBeenLastCalledWith([ { ...codeList[0], helpText: newValue }, codeList[1], @@ -196,6 +194,21 @@ describe('StudioCodeListEditor', () => { ]); }); + it('Calls the onBlurAny callback with the current code list when an item in the table is blurred', async () => { + const user = userEvent.setup(); + renderCodeListEditor(); + const valueInput = screen.getByRole('textbox', { name: texts.itemValue(1) }); + const newValue = 'new text'; + await user.type(valueInput, newValue); + await user.tab(); + expect(onBlurAny).toHaveBeenCalledTimes(1); + expect(onBlurAny).toHaveBeenLastCalledWith([ + { ...codeList[0], value: newValue }, + codeList[1], + codeList[2], + ]); + }); + it('Updates itself when the user changes something', async () => { const user = userEvent.setup(); renderCodeListEditor(); @@ -267,8 +280,7 @@ describe('StudioCodeListEditor', () => { const validValueInput = screen.getByRole('textbox', { name: texts.itemValue(3) }); const newValue = 'new value'; await user.type(validValueInput, newValue); - await user.tab(); - expect(onInvalid).toHaveBeenCalledTimes(1); + expect(onInvalid).toHaveBeenCalledTimes(newValue.length); }); it('Does not trigger onInvalid if an invalid code list is changed to a valid state', async () => { diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx index a4cff5e76da..d1da350578a 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditor.tsx @@ -23,23 +23,32 @@ import { areThereCodeListErrors, findCodeListErrors, isCodeListValid } from './v import type { ValueErrorMap } from './types/ValueErrorMap'; import { StudioFieldset } from '../StudioFieldset'; import { StudioErrorMessage } from '../StudioErrorMessage'; +import type { Override } from '../../types/Override'; +import type { StudioInputTableProps } from '../StudioInputTable/StudioInputTable'; export type StudioCodeListEditorProps = { codeList: CodeList; - onChange: (codeList: CodeList) => void; + onBlurAny?: (codeList: CodeList) => void; + onChange?: (codeList: CodeList) => void; onInvalid?: () => void; texts: CodeListEditorTexts; }; export function StudioCodeListEditor({ codeList, + onBlurAny, onChange, onInvalid, texts, }: StudioCodeListEditorProps): ReactElement { return ( - + ); } @@ -48,6 +57,7 @@ type StatefulCodeListEditorProps = Omit; function StatefulCodeListEditor({ codeList: defaultCodeList, + onBlurAny, onChange, onInvalid, }: StatefulCodeListEditorProps): ReactElement { @@ -60,18 +70,32 @@ function StatefulCodeListEditor({ const handleChange = useCallback( (newCodeList: CodeList) => { setCodeList(newCodeList); - isCodeListValid(newCodeList) ? onChange(newCodeList) : onInvalid?.(); + isCodeListValid(newCodeList) ? onChange?.(newCodeList) : onInvalid?.(); }, [onChange, onInvalid], ); - return ; + const handleBlurAny = useCallback(() => { + onBlurAny?.(codeList); + }, [onBlurAny, codeList]); + + return ( + + ); } -type InternalCodeListEditorProps = Omit; +type InternalCodeListEditorProps = Override< + Pick, + Omit +>; function ControlledCodeListEditor({ codeList, + onBlurAny, onChange, }: InternalCodeListEditorProps): ReactElement { const { texts } = useStudioCodeListEditorContext(); @@ -86,12 +110,18 @@ function ControlledCodeListEditor({ return ( - + ); } + type InternalCodeListEditorWithErrorsProps = InternalCodeListEditorProps & ErrorsProps; function CodeListTable(props: InternalCodeListEditorWithErrorsProps): ReactElement { @@ -107,11 +137,14 @@ function EmptyCodeListTable(): ReactElement { return {texts.emptyCodeList}; } -function CodeListTableWithContent(props: InternalCodeListEditorWithErrorsProps): ReactElement { +function CodeListTableWithContent({ + onBlurAny, + ...rest +}: InternalCodeListEditorWithErrorsProps): ReactElement { return ( - + - + ); } @@ -145,7 +178,7 @@ function TableBody({ [codeList, onChange], ); - const handleBlur = useCallback( + const handleChange = useCallback( (index: number, newItem: CodeListItem) => { const updatedCodeList = changeCodeListItem(codeList, index, newItem); onChange(updatedCodeList); @@ -161,7 +194,7 @@ function TableBody({ item={item} key={index} number={index + 1} - onBlur={(newItem) => handleBlur(index, newItem)} + onChange={(newItem) => handleChange(index, newItem)} onDeleteButtonClick={() => handleDeleteButtonClick(index)} /> ))} diff --git a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx index 161b6d04264..501f9f4fcac 100644 --- a/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx +++ b/frontend/libs/studio-components/src/components/StudioCodelistEditor/StudioCodeListEditorRow/StudioCodeListEditorRow.tsx @@ -13,7 +13,7 @@ type StudioCodeListEditorRowProps = { error: ValueError | null; item: CodeListItem; number: number; - onBlur: (newItem: CodeListItem) => void; + onChange: (newItem: CodeListItem) => void; onDeleteButtonClick: () => void; }; @@ -21,7 +21,7 @@ export function StudioCodeListEditorRow({ error, item, number, - onBlur, + onChange, onDeleteButtonClick, }: StudioCodeListEditorRowProps) { const { texts } = useStudioCodeListEditorContext(); @@ -29,33 +29,33 @@ export function StudioCodeListEditorRow({ const handleLabelChange = useCallback( (label: string) => { const updatedItem = changeLabel(item, label); - onBlur(updatedItem); + onChange(updatedItem); }, - [item, onBlur], + [item, onChange], ); const handleDescriptionChange = useCallback( (description: string) => { const updatedItem = changeDescription(item, description); - onBlur(updatedItem); + onChange(updatedItem); }, - [item, onBlur], + [item, onChange], ); const handleValueChange = useCallback( (value: string) => { const updatedItem = changeValue(item, value); - onBlur(updatedItem); + onChange(updatedItem); }, - [item, onBlur], + [item, onChange], ); const handleHelpTextChange = useCallback( (helpText: string) => { const updatedItem = changeHelpText(item, helpText); - onBlur(updatedItem); + onChange(updatedItem); }, - [item, onBlur], + [item, onChange], ); return ( @@ -64,22 +64,22 @@ export function StudioCodeListEditorRow({ autoComplete='off' error={error && texts.valueErrors[error]} label={texts.itemValue(number)} - onBlur={handleValueChange} + onChange={handleValueChange} value={item.value} /> @@ -90,23 +90,23 @@ export function StudioCodeListEditorRow({ type TextfieldCellProps = { error?: string; label: string; - onBlur: (newString: string) => void; + onChange: (newString: string) => void; value: CodeListItemValue; autoComplete?: HTMLInputAutoCompleteAttribute; }; -function TextfieldCell({ error, label, value, onBlur, autoComplete }: TextfieldCellProps) { +function TextfieldCell({ error, label, value, onChange, autoComplete }: TextfieldCellProps) { const ref = useRef(null); useEffect((): void => { ref.current?.setCustomValidity(error || ''); }, [error]); - const handleBlur = useCallback( + const handleChange = useCallback( (event: React.ChangeEvent): void => { - onBlur(event.target.value); + onChange(event.target.value); }, - [onBlur], + [onChange], ); const handleFocus = useCallback((event: FocusEvent): void => { @@ -118,7 +118,7 @@ function TextfieldCell({ error, label, value, onBlur, autoComplete }: TextfieldC aria-label={label} autoComplete={autoComplete} className={classes.textfieldCell} - onBlur={handleBlur} + onChange={handleChange} onFocus={handleFocus} ref={ref} value={(value as string) ?? ''} diff --git a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.tsx b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.tsx index b2a4c57cfb4..c2553e24db5 100644 --- a/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.tsx +++ b/frontend/libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeList/CodeLists/CodeLists.tsx @@ -24,7 +24,7 @@ type CodeListProps = { function CodeList({ codeList, onUpdateCodeList }: CodeListProps) { const editorTexts: CodeListEditorTexts = useOptionListEditorTexts(); - const handleUpdateCodeList = (updatedCodeList: StudioComponentsCodeList): void => { + const handleBlurAny = (updatedCodeList: StudioComponentsCodeList): void => { const updatedCodeListWithMetadata = updateCodeListWithMetadata(codeList, updatedCodeList); onUpdateCodeList(updatedCodeListWithMetadata); }; @@ -36,7 +36,7 @@ function CodeList({ codeList, onUpdateCodeList }: CodeListProps) { diff --git a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx index 2f581ec6407..767d9861a24 100644 --- a/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx +++ b/frontend/packages/ux-editor/src/components/config/editModal/EditOptions/OptionTabs/EditTab/OptionListEditor/OptionListEditor.tsx @@ -90,7 +90,7 @@ function EditLibraryOptionListEditorModal({ const optionListHasChanged = (options: Option[]): boolean => JSON.stringify(options) !== JSON.stringify(localOptionList); - const handleOptionsChange = (options: Option[]) => { + const handleBlurAny = (options: Option[]) => { if (optionListHasChanged(options)) { updateOptionList({ optionListId: optionsId, optionsList: options }); setLocalOptionList(options); @@ -126,7 +126,7 @@ function EditLibraryOptionListEditorModal({ > @@ -147,7 +147,7 @@ function EditManualOptionListEditorModal({ const modalRef = useRef(null); const editorTexts = useOptionListEditorTexts(); - const handleOptionsChange = (options: Option[]) => { + const handleBlurAny = (options: Option[]) => { if (component.optionsId) { delete component.optionsId; } @@ -175,7 +175,7 @@ function EditManualOptionListEditorModal({ > From fd1fcf5d7402c58e1489de336d8e1d62072ffcb5 Mon Sep 17 00:00:00 2001 From: Lars <74791975+lassopicasso@users.noreply.github.com> Date: Thu, 12 Dec 2024 11:32:11 +0100 Subject: [PATCH 30/35] perf: Implement ux feedback for summary2 config (#14225) Co-authored-by: Jonas Dyrlie --- frontend/language/src/nb.json | 11 +++--- .../Summary2/Summary2Component.test.tsx | 35 ++++++++++++------- .../Summary2/Target/Summary2Target.tsx | 28 ++++++++++++--- .../ux-editor/src/data/formItemConfig.ts | 2 +- .../ux-editor/src/testing/componentMocks.ts | 2 +- 5 files changed, 54 insertions(+), 24 deletions(-) diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index d6357c06149..f690f57b198 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1416,11 +1416,14 @@ "ux_editor.component_properties.tableHeadersMobile": "Felter som skal vises i tabellens overskrift (mobil)", "ux_editor.component_properties.tabs": "Faner", "ux_editor.component_properties.tagName": "Tag-navn", - "ux_editor.component_properties.target": "Mål", - "ux_editor.component_properties.target_description": "Mål for oppsummeringskomponenten", + "ux_editor.component_properties.target": "Hva vil du vise i oppsummeringen?", + "ux_editor.component_properties.target_description": "Her kan du velge hva som skal vises på oppsummeringssiden. Du kan for eksempel vise hele sidegrupper, utvalgte sider eller utvalgte komponenter", "ux_editor.component_properties.target_invalid": "Ugyldig mål", - "ux_editor.component_properties.target_taskId": "Oppgave-ID", - "ux_editor.component_properties.target_type": "Type", + "ux_editor.component_properties.target_taskId": "1. Oppsummer fra denne sidegruppen", + "ux_editor.component_properties.target_type": "2. Vis sidegruppe, side eller komponent", + "ux_editor.component_properties.target_unit_component": "3. Komponent", + "ux_editor.component_properties.target_unit_layout_set": "3. Sidegruppe", + "ux_editor.component_properties.target_unit_page": "3. Side", "ux_editor.component_properties.taskId": "Oppgave-ID", "ux_editor.component_properties.timeStamp": "Inkluder tidsstempel i dato (på som standard)", "ux_editor.component_properties.triggers": "Feltet skal utløse:", diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Summary2Component.test.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Summary2Component.test.tsx index 4161539e532..edb9f118615 100644 --- a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Summary2Component.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Summary2Component.test.tsx @@ -31,7 +31,7 @@ describe('Summary2ComponentTargetSelector', () => { expect(addNewOverrideButton()).toBeInTheDocument(); - expect(componentTargetSelect()).toBeInTheDocument(); + expect(disabledLayoutSetTargetSelect()).toBeInTheDocument(); }); it('should select the task id from the current layout when the task id of the target is not defined', async () => { @@ -52,7 +52,9 @@ describe('Summary2ComponentTargetSelector', () => { it('should allow selecting a task id', async () => { const user = userEvent.setup(); - render(); + render({ + component: { ...defaultProps.component, target: { type: 'component', id: component1IdMock } }, + }); await user.selectOptions(targetTaskIdSelect(), 'Task_2'); expect(defaultProps.handleComponentChange).toHaveBeenCalledWith( @@ -62,7 +64,9 @@ describe('Summary2ComponentTargetSelector', () => { it('should remove the task id from the target if the task id is the same as the current layout set', async () => { const user = userEvent.setup(); - render(); + render({ + component: { ...defaultProps.component, target: { type: 'component', id: component1IdMock } }, + }); await user.selectOptions(targetTaskIdSelect(), 'Task_1'); expect(defaultProps.handleComponentChange).toHaveBeenCalledWith( @@ -70,14 +74,11 @@ describe('Summary2ComponentTargetSelector', () => { ); }); - it('should allow selecting page target and defaults to same page', async () => { - const user = userEvent.setup(); + it('should defaults to page target and disabled target select', async () => { render(); - - await user.selectOptions(targetTypeSelect(), 'page'); - expect(defaultProps.handleComponentChange).toHaveBeenCalledWith( - expect.objectContaining({ target: { type: 'page', id: layout1NameMock } }), - ); + expect(targetTypeSelect()).toHaveValue('layoutSet'); + expect(disabledLayoutSetTargetSelect()).toBeDisabled(); + expect(disabledLayoutSetTargetSelect()).toHaveValue(layoutSet1NameMock); }); it('should allow selecting layoutSet target', async () => { @@ -92,8 +93,11 @@ describe('Summary2ComponentTargetSelector', () => { it('should allow selecting component target', async () => { const user = userEvent.setup(); - render(); + render({ + component: { ...defaultProps.component, target: { type: 'component', id: component1IdMock } }, + }); + await user.selectOptions(targetTypeSelect(), 'component'); const componentId = component1IdMock; await user.click(componentTargetSelect()); @@ -170,12 +174,17 @@ const targetTypeSelect = () => const componentTargetSelect = () => screen.getByRole('combobox', { - name: textMock('general.component'), + name: textMock('ux_editor.component_properties.target_unit_component'), }); const pageTargetSelect = () => screen.getByRole('combobox', { - name: textMock('general.page'), + name: textMock('ux_editor.component_properties.target_unit_page'), + }); + +const disabledLayoutSetTargetSelect = () => + screen.getByRole('textbox', { + name: textMock('ux_editor.component_properties.target_unit_layout_set'), }); const addNewOverrideButton = () => diff --git a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Target/Summary2Target.tsx b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Target/Summary2Target.tsx index 170360003b0..6a880a0e226 100644 --- a/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Target/Summary2Target.tsx +++ b/frontend/packages/ux-editor/src/components/config/componentSpecificContent/Summary2/Target/Summary2Target.tsx @@ -51,10 +51,28 @@ export const Summary2Target = ({ target, onChange }: Summary2TargetProps) => { const getComponentTitle = useComponentTitle(); const excludedComponents = [ - ComponentType.Summary2, - ComponentType.NavigationButtons, + ComponentType.ActionButton, + ComponentType.Alert, + ComponentType.AttachmentList, + ComponentType.Button, + ComponentType.ButtonGroup, + ComponentType.CustomButton, + ComponentType.Grid, + ComponentType.Header, + ComponentType.IFrame, + ComponentType.Image, + ComponentType.InstantiationButton, + ComponentType.InstanceInformation, + ComponentType.Link, ComponentType.NavigationBar, + ComponentType.NavigationButtons, + ComponentType.Panel, + ComponentType.Paragraph, + ComponentType.PrintButton, + ComponentType.Summary, + ComponentType.Summary2, ]; + const components = formLayoutsData ? Object.values(formLayoutsData).flatMap((layout) => getAllLayoutComponents(layout, excludedComponents), @@ -134,7 +152,7 @@ export const Summary2Target = ({ target, onChange }: Summary2TargetProps) => { {target.type === 'page' && ( { {target.type === 'component' && ( { diff --git a/frontend/packages/ux-editor/src/data/formItemConfig.ts b/frontend/packages/ux-editor/src/data/formItemConfig.ts index c22711e41e1..698cb06b6d4 100644 --- a/frontend/packages/ux-editor/src/data/formItemConfig.ts +++ b/frontend/packages/ux-editor/src/data/formItemConfig.ts @@ -472,7 +472,7 @@ export const formItemConfigs: FormItemConfigs = { itemType: LayoutItemType.Component, defaultProperties: { target: { - type: 'component', + type: 'layoutSet', id: '', taskId: '', }, diff --git a/frontend/packages/ux-editor/src/testing/componentMocks.ts b/frontend/packages/ux-editor/src/testing/componentMocks.ts index 886371032d6..aee0cba48de 100644 --- a/frontend/packages/ux-editor/src/testing/componentMocks.ts +++ b/frontend/packages/ux-editor/src/testing/componentMocks.ts @@ -180,7 +180,7 @@ const repeatingGroupContainer: FormContainer = { const summary2Component: FormComponent = { ...commonProps(ComponentType.Summary2), target: { - type: 'component', + type: 'layoutSet', }, }; From db0fbcde053d5f3f1f17b49360127c1290d22454 Mon Sep 17 00:00:00 2001 From: JamalAlabdullah <90609090+JamalAlabdullah@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:48:05 +0100 Subject: [PATCH 31/35] feat: added validation when creating new datamodel in subform (#14233) --- .../CreateNewSubformSection.test.tsx | 50 +++++++++++++++++++ .../CreateNewSubformSection.tsx | 41 +++++++++++---- .../SubformDataModel.test.tsx | 9 ++-- .../SubformDataModel.tsx | 23 +++++---- 4 files changed, 100 insertions(+), 23 deletions(-) diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.test.tsx index 98e31569c4c..1f0c5df9a78 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.test.tsx @@ -125,6 +125,31 @@ describe('CreateNewSubformLayoutSet ', () => { expect(saveButton).toBeDisabled(); }); + it('Toggles the save button disabling based on data model input validation', async () => { + const user = userEvent.setup(); + renderCreateNewSubformLayoutSet({}); + + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubform'); + + const saveButton = screen.getByRole('button', { name: textMock('general.save') }); + + const displayDataModelInput = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_new_data_model'), + }); + await user.click(displayDataModelInput); + + const dataModelInput = screen.getByRole('textbox', { + name: textMock('ux_editor.component_properties.subform.create_new_data_model_label'), + }); + await user.type(dataModelInput, 'æøå'); + expect(saveButton).toBeDisabled(); + + await user.clear(dataModelInput); + await user.type(dataModelInput, 'datamodel'); + expect(saveButton).not.toBeDisabled(); + }); + it('enables save button when both input and data model is valid', async () => { const user = userEvent.setup(); renderCreateNewSubformLayoutSet({}); @@ -159,6 +184,31 @@ describe('CreateNewSubformLayoutSet ', () => { await user.type(dataModelInput, 'datamodel'); expect(saveButton).not.toBeDisabled(); }); + + it('Should toggle ErrorMessage visibility based on input validity', async () => { + const user = userEvent.setup(); + renderCreateNewSubformLayoutSet({}); + + const input = screen.getByRole('textbox'); + await user.type(input, 'NewSubform'); + + const displayDataModelInput = screen.getByRole('button', { + name: textMock('ux_editor.component_properties.subform.create_new_data_model'), + }); + await user.click(displayDataModelInput); + + const dataModelInput = screen.getByRole('textbox', { + name: textMock('ux_editor.component_properties.subform.create_new_data_model_label'), + }); + + await user.type(dataModelInput, 'new'); + const errorMessage = screen.getByText(textMock('schema_editor.error_reserved_keyword')); + expect(errorMessage).toBeInTheDocument(); + + await user.clear(dataModelInput); + await user.type(dataModelInput, 'datamodel'); + expect(errorMessage).not.toBeInTheDocument(); + }); }); type RenderCreateNewSubformLayoutSetProps = { diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.tsx index c6efe918455..6a3699740e5 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/CreateNewSubformSection.tsx @@ -12,6 +12,11 @@ import { SubformDataModel } from './SubformDataModel'; import { CreateNewSubformButtons } from './CreateNewSubformButtons'; import { SubformInstructions } from './SubformInstructions'; import { useCreateSubform } from '@altinn/ux-editor/hooks/useCreateSubform'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { useAppMetadataModelIdsQuery } from 'app-shared/hooks/queries/useAppMetadataModelIdsQuery'; +import { useAppMetadataQuery } from 'app-shared/hooks/queries'; +import { extractDataTypeNamesFromAppMetadata } from 'app-development/features/dataModelling/SchemaEditorWithToolbar/TopToolbar/utils/validationUtils'; +import { useValidateSchemaName } from 'app-shared/hooks/useValidateSchemaName'; type CreateNewSubformSectionProps = { layoutSets: LayoutSets; @@ -33,26 +38,36 @@ export const CreateNewSubformSection = ({ }: CreateNewSubformSectionProps): React.ReactElement => { const { t } = useTranslation(); const { validateLayoutSetName } = useValidateLayoutSetName(); - const [nameError, setNameError] = useState(); - const [newDataModel, setNewDataModel] = useState(''); + const [newSubformNameError, setNewSubformNameError] = useState(); const [selectedDataModel, setSelectedDataModel] = useState(''); const [displayDataModelInput, setDisplayDataModelInput] = useState(false); const { createSubform, isPendingNewSubformMutation } = useCreateSubform(); + const [isNewDataModelFieldEmpty, setIsNewDataModelFieldEmpty] = useState(true); + + const { org, app } = useStudioEnvironmentParams(); + const { data: dataModelIds } = useAppMetadataModelIdsQuery(org, app, false); + const { data: appMetadata } = useAppMetadataQuery(org, app); + const dataTypeNames = extractDataTypeNamesFromAppMetadata(appMetadata); + const { + validateName, + nameError: dataModelNameError, + setNameError: setDataModelNameError, + } = useValidateSchemaName(dataModelIds, dataTypeNames); const handleSubformName = (subformName: string) => { const subformNameValidation = validateLayoutSetName(subformName, layoutSets); - setNameError(subformNameValidation); + setNewSubformNameError(subformNameValidation); }; const handleCloseButton = () => { if (displayDataModelInput) { - setNewDataModel(''); + setDataModelNameError(''); + setIsNewDataModelFieldEmpty(true); setDisplayDataModelInput(false); } else { setShowCreateSubformCard(false); } }; - const handleCreateSubformSubmit = (e: React.FormEvent): void => { e.preventDefault(); const formData: FormData = new FormData(e.currentTarget); @@ -68,8 +83,11 @@ export const CreateNewSubformSection = ({ }); }; - const hasInvalidSubformName = nameError === undefined || Boolean(nameError); - const hasInvalidDataModel = displayDataModelInput ? !newDataModel : !selectedDataModel; + const hasInvalidSubformName = newSubformNameError === undefined || Boolean(newSubformNameError); + const hasInvalidDataModel = displayDataModelInput + ? Boolean(dataModelNameError) || isNewDataModelFieldEmpty + : !selectedDataModel; + const disableSaveButton = hasInvalidSubformName || hasInvalidDataModel; return ( handleSubformName(e.target.value)} - error={nameError} + error={newSubformNameError} /> diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.test.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.test.tsx index 6c9807f9bc8..5e9cb0ef9fc 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.test.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.test.tsx @@ -8,8 +8,6 @@ import { useAppMetadataModelIdsQuery } from 'app-shared/hooks/queries/useAppMeta jest.mock('app-shared/hooks/queries/useAppMetadataModelIdsQuery'); -const user = userEvent.setup(); - const mockDataModelIds = ['dataModelId1', 'dataModelId2']; (useAppMetadataModelIdsQuery as jest.Mock).mockReturnValue({ data: mockDataModelIds }); @@ -41,6 +39,7 @@ describe('SubformDataModel', () => { }); it('Calls setDataModel when selecting an option', async () => { + const user = userEvent.setup(); const setSelectedDataModel = jest.fn(); renderSubformDataModelSelect({ setSelectedDataModel }); @@ -53,6 +52,7 @@ describe('SubformDataModel', () => { }); it('Should call setDisplayDataModelInput true when clicking create new data model button', async () => { + const user = userEvent.setup(); const setDisplayDataModelInput = jest.fn(); renderSubformDataModelSelect({ setDisplayDataModelInput }); const displayDataModelInput = screen.getByRole('button', { @@ -75,9 +75,12 @@ describe('SubformDataModel', () => { const defaultProps: SubformDataModelProps = { setDisplayDataModelInput: jest.fn(), - setNewDataModel: jest.fn(), displayDataModelInput: false, setSelectedDataModel: jest.fn(), + dataModelIds: mockDataModelIds, + validateName: jest.fn(), + dataModelNameError: '', + setIsTextfieldEmpty: jest.fn(), }; const renderSubformDataModelSelect = (props: Partial = {}) => { diff --git a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.tsx b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.tsx index 32508217bb1..47d7212440b 100644 --- a/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.tsx +++ b/frontend/packages/ux-editor/src/components/Properties/PropertiesHeader/EditLayoutSetForSubform/EditLayoutSet/CreateNewSubformSection/SubformDataModel.tsx @@ -2,30 +2,32 @@ import React from 'react'; import { StudioTextfield, StudioNativeSelect, StudioProperty } from '@studio/components'; import { LinkIcon } from '@studio/icons'; import { useTranslation } from 'react-i18next'; -import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; -import { useAppMetadataModelIdsQuery } from 'app-shared/hooks/queries/useAppMetadataModelIdsQuery'; import classes from './SubformDataModel.module.css'; export type SubformDataModelProps = { setDisplayDataModelInput: (setDisplayDataModelInput: boolean) => void; - setNewDataModel: (dataModelId: string) => void; displayDataModelInput: boolean; setSelectedDataModel: (dataModelId: string) => void; + dataModelIds?: string[]; + validateName: (name: string) => void; + dataModelNameError: string; + setIsTextfieldEmpty: (isEmpty: boolean) => void; }; export const SubformDataModel = ({ setDisplayDataModelInput, setSelectedDataModel, - setNewDataModel, displayDataModelInput, + dataModelIds, + validateName, + dataModelNameError, + setIsTextfieldEmpty, }: SubformDataModelProps): React.ReactElement => { const { t } = useTranslation(); - const { org, app } = useStudioEnvironmentParams(); - const { data: dataModelIds } = useAppMetadataModelIdsQuery(org, app, false); - const handleDataModel = (dataModelId: string) => { - // TODO: https://github.com/Altinn/altinn-studio/issues/14184 - setNewDataModel(dataModelId); + const handleNewDataModel = (dataModelId: string) => { + validateName(dataModelId); + setIsTextfieldEmpty(dataModelId === ''); }; const handleDisplayInput = () => { @@ -59,7 +61,8 @@ export const SubformDataModel = ({ name='newSubformDataModel' label={t('ux_editor.component_properties.subform.create_new_data_model_label')} size='sm' - onChange={(e) => handleDataModel(e.target.value)} + onChange={(e) => handleNewDataModel(e.target.value)} + error={dataModelNameError} /> ) : ( Date: Thu, 12 Dec 2024 14:25:14 +0100 Subject: [PATCH 32/35] fix: fixed overflow of texts for components in utforming page (#14216) Co-authored-by: Jonas Dyrlie --- .../StudioTreeViewItem/StudioTreeViewItem.module.css | 6 ++++++ .../FormItem/FormItemTitle/FormItemTitle.module.css | 2 ++ 2 files changed, 8 insertions(+) diff --git a/frontend/libs/studio-components/src/components/StudioTreeView/StudioTreeViewItem/StudioTreeViewItem.module.css b/frontend/libs/studio-components/src/components/StudioTreeView/StudioTreeViewItem/StudioTreeViewItem.module.css index 53072361185..ef9e3495f41 100644 --- a/frontend/libs/studio-components/src/components/StudioTreeView/StudioTreeViewItem/StudioTreeViewItem.module.css +++ b/frontend/libs/studio-components/src/components/StudioTreeView/StudioTreeViewItem/StudioTreeViewItem.module.css @@ -13,6 +13,12 @@ width: 100%; } +.button span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + .button[aria-selected='true'] { background-color: var(--studio-treeitem-selected-background-colour); border-color: var(--studio-treeitem-vertical-line-colour-root); diff --git a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css index 2018f7f709c..9ee89c296fc 100644 --- a/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css +++ b/frontend/packages/ux-editor/src/containers/DesignView/FormTree/FormItem/FormItemTitle/FormItemTitle.module.css @@ -5,6 +5,8 @@ .label { flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; } .root:hover .label { From afcae3c1c385fd8c94b36dd474ac881536ff8c1a Mon Sep 17 00:00:00 2001 From: JamalAlabdullah <90609090+JamalAlabdullah@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:50:07 +0100 Subject: [PATCH 33/35] feat: addded show/hide button for switches in config (#14205) Co-authored-by: Lars <74791975+lassopicasso@users.noreply.github.com> --- frontend/language/src/nb.json | 2 + .../config/FormComponentConfig.module.css | 11 ++++ .../config/FormComponentConfig.test.tsx | 62 ++++++++++++++++--- .../components/config/FormComponentConfig.tsx | 41 ++++++++++-- 4 files changed, 104 insertions(+), 12 deletions(-) diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index f690f57b198..8d8379db6b4 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -1191,6 +1191,8 @@ "ux_editor.component_help_text.TextArea": "Du bruker Stort tekstfelt når du vil at brukerne skal skrive litt lengre tekst.", "ux_editor.component_help_text.default": "Ingen informasjon å vise om denne komponenten.", "ux_editor.component_help_text_general_title": "Åpne hjelpetekst for komponenten", + "ux_editor.component_other_properties_hide_many_settings": "Skjul flere innstillinger", + "ux_editor.component_other_properties_show_many_settings": "Vis flere innstillinger", "ux_editor.component_other_properties_title": "Andre innstillinger", "ux_editor.component_properties.action": "Handling", "ux_editor.component_properties.actions": "Handlinger", diff --git a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.module.css b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.module.css index 6825b4ad8c0..2cd7aa5f8af 100644 --- a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.module.css +++ b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.module.css @@ -1,3 +1,14 @@ .objectPropertyContainer { gap: 0.5rem; } + +.button { + margin: var(--fds-spacing-1); + align-items: center; + padding-left: 0; +} + +.upIcon, +.downIcon { + font-size: var(--fds-sizing-6); +} diff --git a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx index eb817551ba3..14d5f81523c 100644 --- a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx +++ b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.test.tsx @@ -31,14 +31,48 @@ jest.mock('react-i18next', () => ({ })); describe('FormComponentConfig', () => { - it('should render expected components', async () => { + it('should render expected default components', async () => { render({}); + const properties = ['readOnly', 'required', 'hidden']; + for (const property of properties) { + expect( + await screen.findByText(textMock(`ux_editor.component_properties.${property}`)), + ).toBeInTheDocument(); + } + }); + it('should render the show-button', async () => { + render({}); + const button = screen.getByRole('button', { + name: textMock('ux_editor.component_other_properties_show_many_settings'), + }); + expect(button).toBeInTheDocument(); + }); + + it('should render the hide-button after clikcing on show-button', async () => { + const user = userEvent.setup(); + render({}); + const button = screen.getByRole('button', { + name: textMock('ux_editor.component_other_properties_show_many_settings'), + }); + expect(button).toBeInTheDocument(); + await user.click(button); + expect( + screen.getByRole('button', { + name: textMock('ux_editor.component_other_properties_hide_many_settings'), + }), + ).toBeInTheDocument(); + }); + + it('Should render the rest of the components when show-button is clicked and show hide-button', async () => { + const use = userEvent.setup(); + render({}); + const button = screen.getByRole('button', { + name: textMock('ux_editor.component_other_properties_show_many_settings'), + }); + expect(button).toBeInTheDocument(); + await use.click(button); const properties = [ - 'grid', - 'readOnly', - 'required', - 'hidden', 'renderAsSummary', 'variant', 'autocomplete', @@ -47,12 +81,15 @@ describe('FormComponentConfig', () => { 'pageBreak', 'formatting', ]; - for (const property of properties) { expect( await screen.findByText(textMock(`ux_editor.component_properties.${property}`)), ).toBeInTheDocument(); } + const hideButton = screen.getByRole('button', { + name: textMock('ux_editor.component_other_properties_hide_many_settings'), + }); + expect(hideButton).toBeInTheDocument(); }); it('should render "RedirectToLayoutSet"', () => { @@ -174,12 +211,18 @@ describe('FormComponentConfig', () => { ).toBeInTheDocument(); }); - it('should render default boolean values if defined', () => { + it('should render default boolean values if defined', async () => { + const user = userEvent.setup(); render({ props: { schema: DatepickerSchema, }, }); + const button = screen.getByRole('button', { + name: textMock('ux_editor.component_other_properties_show_many_settings'), + }); + expect(button).toBeInTheDocument(); + await user.click(button); const timeStampSwitch = screen.getByRole('checkbox', { name: textMock('ux_editor.component_properties.timeStamp'), }); @@ -196,6 +239,11 @@ describe('FormComponentConfig', () => { handleComponentUpdate: handleComponentUpdateMock, }, }); + const button = screen.getByRole('button', { + name: textMock('ux_editor.component_other_properties_show_many_settings'), + }); + expect(button).toBeInTheDocument(); + await user.click(button); const timeStampSwitch = screen.getByRole('checkbox', { name: textMock('ux_editor.component_properties.timeStamp'), }); diff --git a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx index 5ab3cf2935c..3d7f2c9a3d7 100644 --- a/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx +++ b/frontend/packages/ux-editor/src/components/config/FormComponentConfig.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Alert, Card, Heading, Paragraph } from '@digdir/designsystemet-react'; import type { FormComponent } from '../../types/FormComponent'; import { EditBooleanValue } from './editModal/EditBooleanValue'; @@ -16,6 +16,8 @@ import type { UpdateFormMutateOptions } from '../../containers/FormItemContext'; import { useComponentPropertyDescription } from '../../hooks/useComponentPropertyDescription'; import classes from './FormComponentConfig.module.css'; import { RedirectToLayoutSet } from './editModal/RedirectToLayoutSet'; +import { ChevronDownIcon, ChevronUpIcon } from '@studio/icons'; +import { StudioProperty } from '@studio/components'; export interface IEditFormComponentProps { editFormId: string; @@ -38,6 +40,7 @@ export const FormComponentConfig = ({ const t = useText(); const componentPropertyLabel = useComponentPropertyLabel(); const componentPropertyDescription = useComponentPropertyDescription(); + const [showOtherComponents, setShowOtherComponents] = useState(false); if (!schema?.properties) return null; @@ -95,6 +98,18 @@ export const FormComponentConfig = ({ ); }); + const defaultDisplayedBooleanKeys = booleanPropertyKeys.slice(0, 3); + const restOfBooleanKeys = booleanPropertyKeys.slice(3); + + const renderIcon = showOtherComponents ? ( + + ) : ( + + ); + const rendertext = showOtherComponents + ? t('ux_editor.component_other_properties_hide_many_settings') + : t('ux_editor.component_other_properties_show_many_settings'); + return ( <> {layoutSet && component['layoutSet'] && ( @@ -119,8 +134,17 @@ export const FormComponentConfig = ({ )} {/** Boolean fields, incl. expression type */} - {booleanPropertyKeys.map((propertyKey) => { - return ( + {defaultDisplayedBooleanKeys.map((propertyKey) => ( + + ))} + {showOtherComponents && + restOfBooleanKeys.map((propertyKey) => ( - ); - })} + ))} + {restOfBooleanKeys.length > 0 && ( + setShowOtherComponents((prev) => !prev)} + property={rendertext} + /> + )} {/** Custom logic for custom file endings */} {hasCustomFileEndings && ( From c6690b732026724900d39fce49a76ac80a0e0c62 Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 13 Dec 2024 07:58:51 +0100 Subject: [PATCH 34/35] fix: Update layout set list when adding a new subform (#14254) Co-authored-by: Lars <74791975+lassopicasso@users.noreply.github.com> --- .../WebSocketSyncWrapper.test.tsx | 4 +- .../WebSocketSyncWrapper.tsx | 4 +- .../mutations/useAddLayoutSetMutation.ts | 1 + .../mutations/useDeleteLayoutSetMutation.ts | 1 + .../mutations/useUpdateLayoutSetIdMutation.ts | 1 + .../useUpdateProcessDataTypesMutation.test.ts | 2 +- .../useUpdateProcessDataTypesMutation.ts | 1 + .../SyncSuccessQueriesInvalidator.test.ts | 18 +++---- .../SyncSuccessQueriesInvalidator.ts | 51 +++++++++++-------- 9 files changed, 47 insertions(+), 36 deletions(-) diff --git a/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.test.tsx b/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.test.tsx index 735ff043fdf..ef733897a07 100644 --- a/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.test.tsx +++ b/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.test.tsx @@ -68,7 +68,7 @@ describe('WebSocketSyncWrapper', () => { const queryClientMock = createQueryClientMock(); const invalidator = SyncSuccessQueriesInvalidator.getInstance(queryClientMock, org, app); - invalidator.invalidateQueryByFileLocation = jest.fn(); + invalidator.invalidateQueriesByFileLocation = jest.fn(); const mockOnWSMessageReceived = jest .fn() .mockImplementation((callback: Function) => callback(syncSuccessMock)); @@ -80,7 +80,7 @@ describe('WebSocketSyncWrapper', () => { renderWebSocketSyncWrapper(); await waitFor(() => { - expect(invalidator.invalidateQueryByFileLocation).toHaveBeenCalledWith( + expect(invalidator.invalidateQueriesByFileLocation).toHaveBeenCalledWith( syncSuccessMock.source.name, ); }); diff --git a/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.tsx b/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.tsx index 733e8e5219a..53284a5a7db 100644 --- a/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.tsx +++ b/frontend/app-development/components/WebSocketSyncWrapper/WebSocketSyncWrapper.tsx @@ -48,8 +48,8 @@ export const WebSocketSyncWrapper = ({ const isSuccessMessage = 'source' in message; if (isSuccessMessage) { - // Please extend the "fileNameCacheKeyMap" inside the "SyncSuccessQueriesInvalidator" class. Do not add query-client invalidation directly here. - invalidator.invalidateQueryByFileLocation(message.source.name); + // Please extend the "fileNameCacheKeysMap" inside the "SyncSuccessQueriesInvalidator" class. Do not add query-client invalidation directly here. + invalidator.invalidateQueriesByFileLocation(message.source.name); } }); diff --git a/frontend/app-development/hooks/mutations/useAddLayoutSetMutation.ts b/frontend/app-development/hooks/mutations/useAddLayoutSetMutation.ts index 68a2d3ff174..cedff5138b8 100644 --- a/frontend/app-development/hooks/mutations/useAddLayoutSetMutation.ts +++ b/frontend/app-development/hooks/mutations/useAddLayoutSetMutation.ts @@ -46,6 +46,7 @@ export const useAddLayoutSetMutation = (org: string, app: string) => { // when process-editor renders the tasks and 'adds' them on first mount, when they already exists. if (isLayoutSets(layoutSets)) { queryClient.setQueryData([QueryKey.LayoutSets, org, app], layoutSets); + queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSetsExtended, org, app] }); } }, }); diff --git a/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.ts b/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.ts index 57cfa3b9a10..1b439b227e0 100644 --- a/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.ts +++ b/frontend/app-development/hooks/mutations/useDeleteLayoutSetMutation.ts @@ -11,6 +11,7 @@ export const useDeleteLayoutSetMutation = (org: string, app: string) => { deleteLayoutSet(org, app, layoutSetIdToUpdate), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSets, org, app] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSetsExtended, org, app] }); queryClient.invalidateQueries({ queryKey: [QueryKey.AppMetadataModelIds, org, app] }); }, }); diff --git a/frontend/app-development/hooks/mutations/useUpdateLayoutSetIdMutation.ts b/frontend/app-development/hooks/mutations/useUpdateLayoutSetIdMutation.ts index 87baa945ea7..40a218cac0c 100644 --- a/frontend/app-development/hooks/mutations/useUpdateLayoutSetIdMutation.ts +++ b/frontend/app-development/hooks/mutations/useUpdateLayoutSetIdMutation.ts @@ -16,6 +16,7 @@ export const useUpdateLayoutSetIdMutation = (org: string, app: string) => { }) => updateLayoutSetId(org, app, layoutSetIdToUpdate, newLayoutSetId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSets, org, app] }); + queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSetsExtended, org, app] }); }, }); }; diff --git a/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.test.ts b/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.test.ts index fb3380ea275..7de62a67c34 100644 --- a/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.test.ts +++ b/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.test.ts @@ -43,7 +43,7 @@ describe('useUpdateProcessDataTypeMutation', () => { await renderHook({ queryClient }); - expect(invalidateQueriesSpy).toHaveBeenCalledTimes(2); + expect(invalidateQueriesSpy).toHaveBeenCalledTimes(3); expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: [QueryKey.AppMetadataModelIds, org, app], }); diff --git a/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.ts b/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.ts index 8e2dea1b706..5aae6913df6 100644 --- a/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.ts +++ b/frontend/app-development/hooks/mutations/useUpdateProcessDataTypesMutation.ts @@ -11,6 +11,7 @@ export const useUpdateProcessDataTypesMutation = (org: string, app: string) => { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: [QueryKey.AppMetadataModelIds, org, app] }); await queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSets, org, app] }); + await queryClient.invalidateQueries({ queryKey: [QueryKey.LayoutSetsExtended, org, app] }); }, }); }; diff --git a/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.test.ts b/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.test.ts index 87de0c60acd..1703079e69f 100644 --- a/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.test.ts +++ b/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.test.ts @@ -19,12 +19,12 @@ describe('SyncSuccessQueriesInvalidator', () => { jest.clearAllMocks(); }); - it('should invalidate query cache only once when invalidateQueryByFileLocation is called', async () => { + it('should invalidate query cache only once when invalidateQueriesByFileLocation is called', async () => { const queriesInvalidator = SyncSuccessQueriesInvalidator.getInstance(queryClientMock, org, app); const fileName = 'applicationmetadata.json'; - queriesInvalidator.invalidateQueryByFileLocation(fileName); - queriesInvalidator.invalidateQueryByFileLocation(fileName); + queriesInvalidator.invalidateQueriesByFileLocation(fileName); + queriesInvalidator.invalidateQueriesByFileLocation(fileName); await waitFor(() => expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKey.AppMetadata, org, app], @@ -33,22 +33,22 @@ describe('SyncSuccessQueriesInvalidator', () => { expect(queryClientMock.invalidateQueries).toHaveBeenCalledTimes(1); }); - it('should not invalidate query cache when invalidateQueryByFileLocation is called with an unknown file name', async () => { + it('should not invalidate query cache when invalidateQueriesByFileLocation is called with an unknown file name', async () => { const queriesInvalidator = SyncSuccessQueriesInvalidator.getInstance(queryClientMock, org, app); const fileName = 'unknown.json'; - queriesInvalidator.invalidateQueryByFileLocation(fileName); + queriesInvalidator.invalidateQueriesByFileLocation(fileName); await new Promise((resolve) => setTimeout(resolve, 501)); expect(queryClientMock.invalidateQueries).not.toHaveBeenCalled(); }); - it('should invalidate query cache with layoutSetName identifier when invalidateQueryByFileLocation is called and layoutSetName has been set', async () => { + it('should invalidate query cache with layoutSetName identifier when invalidateQueriesByFileLocation is called and layoutSetName has been set', async () => { const queriesInvalidator = SyncSuccessQueriesInvalidator.getInstance(queryClientMock, org, app); queriesInvalidator.layoutSetName = selectedLayoutSet; const fileName = 'Settings.json'; - queriesInvalidator.invalidateQueryByFileLocation(fileName); + queriesInvalidator.invalidateQueriesByFileLocation(fileName); await waitFor(() => { expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ @@ -58,12 +58,12 @@ describe('SyncSuccessQueriesInvalidator', () => { expect(queryClientMock.invalidateQueries).toHaveBeenCalledTimes(1); }); - it('should invalidate layouts query cache with layoutSetName identifier when invalidateQueryByFileLocation is called and layoutSetName has been set', async () => { + it('should invalidate layouts query cache with layoutSetName identifier when invalidateQueriesByFileLocation is called and layoutSetName has been set', async () => { const queriesInvalidator = SyncSuccessQueriesInvalidator.getInstance(queryClientMock, org, app); queriesInvalidator.layoutSetName = selectedLayoutSet; const folderName = 'layouts'; - queriesInvalidator.invalidateQueryByFileLocation(folderName); + queriesInvalidator.invalidateQueriesByFileLocation(folderName); await waitFor(() => expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({ diff --git a/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.ts b/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.ts index 0e240ae8590..3b88d565b4a 100644 --- a/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.ts +++ b/frontend/packages/shared/src/queryInvalidator/SyncSuccessQueriesInvalidator.ts @@ -21,16 +21,19 @@ export class SyncSuccessQueriesInvalidator extends Queue { private _queryClient: QueryClient; // Maps file names to their cache keys for invalidation upon sync success - can be extended to include more files - private readonly fileNameCacheKeyMap: Record> = { - 'applicationmetadata.json': [QueryKey.AppMetadata, '[org]', '[app]'], - 'layout-sets.json': [QueryKey.LayoutSets, '[org]', '[app]'], - 'policy.xml': [QueryKey.AppPolicy, '[org]', '[app]'], - 'Settings.json': [QueryKey.FormLayoutSettings, '[org]', '[app]', '[layoutSetName]'], + private readonly fileNameCacheKeysMap: Record>> = { + 'applicationmetadata.json': [[QueryKey.AppMetadata, '[org]', '[app]']], + 'layout-sets.json': [ + [QueryKey.LayoutSets, '[org]', '[app]'], + [QueryKey.LayoutSetsExtended, '[org]', '[app]'], + ], + 'policy.xml': [[QueryKey.AppPolicy, '[org]', '[app]']], + 'Settings.json': [[QueryKey.FormLayoutSettings, '[org]', '[app]', '[layoutSetName]']], }; // Maps folder names to their cache keys for invalidation upon sync success - can be extended to include more folders - private readonly folderNameCacheKeyMap: Record> = { - layouts: [QueryKey.FormLayouts, '[org]', '[app]'], + private readonly folderNameCacheKeysMap: Record>> = { + layouts: [[QueryKey.FormLayouts, '[org]', '[app]']], }; public set layoutSetName(layoutSetName: string) { @@ -67,32 +70,36 @@ export class SyncSuccessQueriesInvalidator extends Queue { SyncSuccessQueriesInvalidator.instance = null; } - public invalidateQueryByFileLocation(fileOrFolderName: string): void { - const cacheKey = this.getCacheKeyByFileLocation(fileOrFolderName); - if (!cacheKey) return; + public invalidateQueriesByFileLocation(fileOrFolderName: string): void { + const cacheKeys = this.getCacheKeysByFileLocation(fileOrFolderName); + if (!cacheKeys) return; this.addTaskToQueue({ id: fileOrFolderName, callback: () => { - this._queryClient.invalidateQueries({ queryKey: cacheKey }); + cacheKeys.forEach((cacheKey) => { + this._queryClient.invalidateQueries({ queryKey: cacheKey }); + }); }, }); } - private getCacheKeyByFileLocation(fileOrFolderName: string): string[] { - const cacheKey = - this.fileNameCacheKeyMap[fileOrFolderName] || this.folderNameCacheKeyMap[fileOrFolderName]; - if (!cacheKey) return undefined; + private getCacheKeysByFileLocation(fileOrFolderName: string): Array { + const cacheKeys = + this.fileNameCacheKeysMap[fileOrFolderName] || this.folderNameCacheKeysMap[fileOrFolderName]; + if (!cacheKeys) return undefined; - return this.replaceCacheKeyPlaceholders(cacheKey); + return this.replaceCacheKeysPlaceholders(cacheKeys); } - private replaceCacheKeyPlaceholders(cacheKey: string[]): string[] { - return cacheKey.map((key) => - key - .replace('[org]', this._org) - .replace('[app]', this._app) - .replace('[layoutSetName]', this._layoutSetName), + private replaceCacheKeysPlaceholders(cacheKeys: Array): Array { + return cacheKeys.map((cacheKey) => + cacheKey.map((key) => + key + .replace('[org]', this._org) + .replace('[app]', this._app) + .replace('[layoutSetName]', this._layoutSetName), + ), ); } } From 2345ba1aab382f05237ad85a9355171553f5f944 Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Fri, 13 Dec 2024 11:47:26 +0100 Subject: [PATCH 35/35] fix: Simplify autocomplete input for V3 (#14262) Co-authored-by: andreastanderen <71079896+standeren@users.noreply.github.com> --- .../StudioNativeSelect/StudioNativeSelect.tsx | 4 +- .../components/StudioNativeSelect/index.ts | 2 +- .../config/EditFormComponent.test.tsx | 7 +- .../src/components/config/componentConfig.tsx | 4 +- .../editModal/EditAutoComplete.test.tsx | 107 ------------- .../config/editModal/EditAutoComplete.tsx | 148 ------------------ .../EditAutocomplete.test.tsx | 59 +++++++ .../EditAutocomplete/EditAutocomplete.tsx | 78 +++++++++ .../EditAutocomplete/autocompleteOptions.ts | 55 +++++++ .../editModal/EditAutocomplete/index.ts | 1 + .../updateAutocomplete.test.ts | 38 +++++ .../EditAutocomplete/updateAutocomplete.ts | 21 +++ 12 files changed, 263 insertions(+), 261 deletions(-) delete mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.test.tsx delete mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx create mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.test.tsx create mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.tsx create mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/autocompleteOptions.ts create mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/index.ts create mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.test.ts create mode 100644 frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.ts diff --git a/frontend/libs/studio-components/src/components/StudioNativeSelect/StudioNativeSelect.tsx b/frontend/libs/studio-components/src/components/StudioNativeSelect/StudioNativeSelect.tsx index cda916ac136..7ad55b34669 100644 --- a/frontend/libs/studio-components/src/components/StudioNativeSelect/StudioNativeSelect.tsx +++ b/frontend/libs/studio-components/src/components/StudioNativeSelect/StudioNativeSelect.tsx @@ -1,7 +1,9 @@ import React, { forwardRef, useId } from 'react'; import { NativeSelect, type NativeSelectProps } from '@digdir/designsystemet-react'; -export const StudioNativeSelect = forwardRef( +export type StudioNativeSelectProps = NativeSelectProps; + +export const StudioNativeSelect = forwardRef( ({ children, description, label, id, size, ...rest }, ref): React.JSX.Element => { const defaultId = useId(); id = id ?? defaultId; diff --git a/frontend/libs/studio-components/src/components/StudioNativeSelect/index.ts b/frontend/libs/studio-components/src/components/StudioNativeSelect/index.ts index d7cbd872cfa..0457a032a88 100644 --- a/frontend/libs/studio-components/src/components/StudioNativeSelect/index.ts +++ b/frontend/libs/studio-components/src/components/StudioNativeSelect/index.ts @@ -1 +1 @@ -export { StudioNativeSelect } from './StudioNativeSelect'; +export * from './StudioNativeSelect'; diff --git a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx index 4157a123ddb..9e3d80a1b48 100644 --- a/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx +++ b/frontend/packages/ux-editor-v3/src/components/config/EditFormComponent.test.tsx @@ -95,8 +95,11 @@ describe('EditFormComponent', () => { Object.keys(labels).map(async (label) => expect(await screen.findByRole(labels[label], { name: textMock(label) })), ); - expect(screen.getByRole('combobox')); - expect(screen.getByLabelText('Autocomplete (WCAG)')); + expect( + screen.getByRole('combobox', { + name: textMock('ux_editor.component_properties.autocomplete'), + }), + ).toBeInTheDocument(); }); it('should return header specific content when type header', async () => { diff --git a/frontend/packages/ux-editor-v3/src/components/config/componentConfig.tsx b/frontend/packages/ux-editor-v3/src/components/config/componentConfig.tsx index bcd4c9887f2..46dff32215d 100644 --- a/frontend/packages/ux-editor-v3/src/components/config/componentConfig.tsx +++ b/frontend/packages/ux-editor-v3/src/components/config/componentConfig.tsx @@ -7,7 +7,7 @@ import { EditOptions } from './editModal/EditOptions'; import { EditPreselectedIndex } from './editModal/EditPreselectedIndex'; import { EditReadOnly } from './editModal/EditReadOnly'; import { EditRequired } from './editModal/EditRequired'; -import { EditAutoComplete } from './editModal/EditAutoComplete'; +import { EditAutocomplete } from './editModal/EditAutocomplete'; import { EditTextResourceBinding } from './editModal/EditTextResourceBinding'; import type { FormComponent } from '../../types/FormComponent'; @@ -121,7 +121,7 @@ export const configComponents: IConfigComponents = { [EditSettings.Options]: EditOptions, [EditSettings.CodeList]: EditCodeList, [EditSettings.PreselectedIndex]: EditPreselectedIndex, - [EditSettings.AutoComplete]: EditAutoComplete, + [EditSettings.AutoComplete]: EditAutocomplete, [EditSettings.Help]: ({ component, handleComponentChange }: IGenericEditComponent) => ( { - const layoutSchemaResult = renderHookWithMockStore()(() => useLayoutSchemaQuery()) - .renderHookResult.result; - await waitFor(() => expect(layoutSchemaResult.current[0].isSuccess).toBe(true)); -}; - -export const render = async ( - handleComponentChangeMock: any = jest.fn(), - component: FormComponent = componentMock, -) => { - await waitForData(); - return renderWithMockStore()( - , - ); -}; - -test('should render first 6 suggestions on search field focused', async () => { - await render(); - const user = userEvent.setup(); - - const inputField = screen.getByRole('textbox'); - expect(inputField).toBeInTheDocument(); - - await user.click(inputField); - - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - expect(screen.getAllByRole('option')).toHaveLength(6); -}); - -test('should filter options while typing in search field', async () => { - await render(); - const user = userEvent.setup(); - - await user.type(screen.getByRole('textbox'), 'of'); - - await waitFor(() => expect(screen.getByRole('textbox')).toHaveValue('of')); - - expect(screen.getByRole('option', { name: 'off' })).toBeInTheDocument(); - expect(screen.queryByRole('option', { name: 'given-name' })).not.toBeInTheDocument(); -}); - -test('should set the chosen options within the search field', async () => { - await render(); - const user = userEvent.setup(); - - const searchField = screen.getByRole('textbox'); - - await user.type(searchField, 'of'); - await waitFor(() => expect(searchField).toHaveValue('of')); - await user.click(screen.getByRole('option', { name: 'off' })); - - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - await waitFor(() => expect(searchField).toHaveValue('off')); -}); - -test('should toggle autocomplete-popup based onFocus and onBlur', async () => { - await render(); - const user = userEvent.setup(); - await user.click(screen.getByRole('textbox')); - - expect(await screen.findByRole('dialog')).toBeInTheDocument(); - - await user.tab(); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); -}); - -test('should call handleComponentChangeMock callback ', async () => { - const handleComponentChangeMock = jest.fn(); - await render(handleComponentChangeMock); - - const user = userEvent.setup(); - - const inputField = screen.getByRole('textbox'); - expect(inputField).toBeInTheDocument(); - - await user.click(inputField); - await screen.findByRole('dialog'); - - await user.click(screen.getByRole('option', { name: 'on' })); - expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); - expect(handleComponentChangeMock).toHaveBeenCalledWith({ - autocomplete: 'on', - dataModelBindings: {}, - id: 'random-id', - itemType: 'COMPONENT', - propertyPath: 'definitions/inputComponent', - type: 'Input', - }); -}); diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx deleted file mode 100644 index 6fc0dd4d643..00000000000 --- a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutoComplete.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import type { ChangeEvent } from 'react'; -import React, { useEffect, useMemo, useState } from 'react'; -import type { IGenericEditComponent } from '../componentConfig'; -import { stringToArray, arrayToString } from '../../../utils/stringUtils'; -import { FormField } from '../../FormField'; -import { StudioButton, StudioPopover, StudioTextfield } from '@studio/components'; -import { ArrayUtils } from '@studio/pure-functions'; - -const getLastWord = (value: string) => value.split(' ').pop(); -const stdAutocompleteOpts = [ - 'on', - 'off', - 'name', - 'honorific-prefix', - 'given-name', - 'additional-name', - 'family-name', - 'honorific-suffix', - 'nickname', - 'email', - 'username', - 'new-password', - 'current-password', - 'one-time-code', - 'organization-title', - 'organization', - 'street-address', - 'address-line1', - 'address-line2', - 'address-line3', - 'address-level4', - 'address-level3', - 'address-level2', - 'address-level1', - 'country', - 'country-name', - 'postal-code', - 'cc-name', - 'cc-given-name', - 'cc-additional-name', - 'cc-family-name', - 'cc-number', - 'cc-exp', - 'cc-exp-month', - 'cc-exp-year', - 'cc-csc', - 'cc-type', - 'transaction-currency', - 'transaction-amount', - 'language', - 'bday', - 'bday-day', - 'bday-month', - 'bday-year', - 'sex', - 'tel', - 'tel-country-code', - 'tel-national', - 'tel-area-code', - 'tel-local', - 'tel-extension', - 'url', - 'photo', -]; - -export const EditAutoComplete = ({ component, handleComponentChange }: IGenericEditComponent) => { - const [searchFieldFocused, setSearchFieldFocused] = useState(false); - const initialAutocompleteText = component?.autocomplete || ''; - const [autocompleteText, setAutocompleteText] = useState(initialAutocompleteText); - - useEffect(() => { - setAutocompleteText(initialAutocompleteText); - }, [initialAutocompleteText, component.id]); - - const autoCompleteOptions = useMemo((): string[] => { - const lastWord = getLastWord(autocompleteText); - return stdAutocompleteOpts.filter((alternative) => alternative.includes(lastWord))?.slice(0, 6); - }, [autocompleteText]); - - const buildNewText = (word: string): string => { - const wordParts = stringToArray(autocompleteText, ' '); - const newWordParts = ArrayUtils.replaceLastItem(wordParts, word); - return arrayToString(newWordParts); - }; - - const handleWordClick = (word: string): void => { - const autocomplete = buildNewText(word); - setAutocompleteText(autocomplete); - handleComponentChange({ - ...component, - autocomplete, - }); - setSearchFieldFocused(false); - }; - - const handleChange = (value: string): void => { - if (!searchFieldFocused) setSearchFieldFocused(true); - setAutocompleteText(value); - }; - - return ( -
- ( - setSearchFieldFocused(true)} - onBlur={(): void => { - if (searchFieldFocused) setSearchFieldFocused(false); - }} - onChange={(event: ChangeEvent) => { - const value = event.target.value; - handleChange(value); - fieldProps.onChange(value); - }} - /> - )} - /> - 0} - placement='bottom-start' - > - {
} - - {autoCompleteOptions.map( - (option): JSX.Element => ( - handleWordClick(option)} - > - {option} - - ), - )} - - -
- ); -}; diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.test.tsx b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.test.tsx new file mode 100644 index 00000000000..ebc4e7b2d8b --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import type { EditAutocompleteProps } from './'; +import { EditAutocomplete } from './'; +import type { RenderResult } from '@testing-library/react'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { ComponentTypeV3 } from 'app-shared/types/ComponentTypeV3'; +import type { FormComponent } from '../../../../types/FormComponent'; +import { renderWithProviders } from '../../../../testing/mocks'; + +// Test data: +const component: FormComponent = { + id: 'random-id', + autocomplete: '', + type: ComponentTypeV3.Input, + itemType: 'COMPONENT', + propertyPath: 'definitions/inputComponent', + dataModelBindings: {}, +}; +const handleComponentChange = jest.fn(); +const defaultProps: EditAutocompleteProps = { + handleComponentChange, + component, +}; + +describe('EditAutocomplete', () => { + it('Calls handleComponentChange with the updated component when the value is changed ', async () => { + const user = userEvent.setup(); + const optionToChoose = 'on'; + renderEditAutocomplete(); + + const combobox = screen.getByRole('combobox'); + const option = screen.getByRole('option', { name: optionToChoose }); + await user.selectOptions(combobox, option); + + expect(handleComponentChange).toHaveBeenCalledWith({ + autocomplete: optionToChoose, + dataModelBindings: {}, + id: 'random-id', + itemType: 'COMPONENT', + propertyPath: 'definitions/inputComponent', + type: 'Input', + }); + }); + + it('Renders with the given autocomplete value as selected', () => { + const selectedValue = 'on'; + const componentWithAutocomplete: FormComponent = { + ...component, + autocomplete: selectedValue, + }; + renderEditAutocomplete({ component: componentWithAutocomplete }); + expect(screen.getByRole('combobox')).toHaveValue(selectedValue); + }); +}); + +function renderEditAutocomplete(props: Partial = {}): RenderResult { + return renderWithProviders(); +} diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.tsx b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.tsx new file mode 100644 index 00000000000..bfc169597a4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/EditAutocomplete.tsx @@ -0,0 +1,78 @@ +import type { ChangeEvent, ReactElement } from 'react'; +import React, { useCallback } from 'react'; +import type { IGenericEditComponent } from '../../componentConfig'; +import { FormField } from '../../../FormField'; +import type { StudioNativeSelectProps } from '@studio/components'; +import { StudioNativeSelect } from '@studio/components'; +import { useTranslation } from 'react-i18next'; +import { updateAutocomplete } from './updateAutocomplete'; +import { autocompleteOptions } from './autocompleteOptions'; +import type { ComponentTypeV3 } from 'app-shared/types/ComponentTypeV3'; +import type { FormComponent } from '../../../../types/FormComponent'; + +export type EditAutocompleteProps = IGenericEditComponent>; + +export function EditAutocomplete({ + component, + handleComponentChange, +}: EditAutocompleteProps): ReactElement { + const { t } = useTranslation(); + + const handleChange = useCallback( + (value: string): void => { + const updatedComponent = updateAutocomplete(component, value); + handleComponentChange(updatedComponent); + }, + [component, handleComponentChange], + ); + + return ( +
+ } + /> +
+ ); +} + +type AutocompleteFieldProps = { + onChange: (value: string) => void; +} & Omit; + +function AutocompleteField({ onChange, ...rest }: AutocompleteFieldProps): ReactElement { + const handleChange = useCallback( + (e: ChangeEvent) => { + onChange(e.target.value); + }, + [onChange], + ); + + return ( + + + + + ); +} + +function EmptyOption(): ReactElement { + const { t } = useTranslation(); + return ; +} + +function AutocompleteOptions(): ReactElement { + return ( + <> + {autocompleteOptions.map((option: string) => ( + + ))} + + ); +} diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/autocompleteOptions.ts b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/autocompleteOptions.ts new file mode 100644 index 00000000000..12024a8732e --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/autocompleteOptions.ts @@ -0,0 +1,55 @@ +export const autocompleteOptions: string[] = [ + 'on', + 'off', + 'name', + 'honorific-prefix', + 'given-name', + 'additional-name', + 'family-name', + 'honorific-suffix', + 'nickname', + 'email', + 'username', + 'new-password', + 'current-password', + 'one-time-code', + 'organization-title', + 'organization', + 'street-address', + 'address-line1', + 'address-line2', + 'address-line3', + 'address-level4', + 'address-level3', + 'address-level2', + 'address-level1', + 'country', + 'country-name', + 'postal-code', + 'cc-name', + 'cc-given-name', + 'cc-additional-name', + 'cc-family-name', + 'cc-number', + 'cc-exp', + 'cc-exp-month', + 'cc-exp-year', + 'cc-csc', + 'cc-type', + 'transaction-currency', + 'transaction-amount', + 'language', + 'bday', + 'bday-day', + 'bday-month', + 'bday-year', + 'sex', + 'tel', + 'tel-country-code', + 'tel-national', + 'tel-area-code', + 'tel-local', + 'tel-extension', + 'url', + 'photo', +]; diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/index.ts b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/index.ts new file mode 100644 index 00000000000..a12d2b32d5e --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/index.ts @@ -0,0 +1 @@ +export * from './EditAutocomplete'; diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.test.ts b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.test.ts new file mode 100644 index 00000000000..f49d8ab67c4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.test.ts @@ -0,0 +1,38 @@ +import { ComponentTypeV3 } from 'app-shared/types/ComponentTypeV3'; +import type { FormComponent } from '../../../../types/FormComponent'; +import { updateAutocomplete } from './updateAutocomplete'; + +describe('updateAutocomplete', () => { + it('Updates the autocomplete value of the given component', () => { + const component: FormComponent = { + id: 'test', + type: ComponentTypeV3.Input, + autocomplete: 'off', + itemType: 'COMPONENT', + dataModelBindings: {}, + }; + const newAutocomplete = 'on'; + const result = updateAutocomplete(component, newAutocomplete); + expect(result).toEqual({ + ...component, + autocomplete: newAutocomplete, + }); + }); + + it('Removes the autocomplete property from the object when it receives an empty string', () => { + const component: FormComponent = { + id: 'test', + type: ComponentTypeV3.Input, + autocomplete: 'on', + itemType: 'COMPONENT', + dataModelBindings: {}, + }; + const result = updateAutocomplete(component, ''); + expect(result).toEqual({ + id: component.id, + type: component.type, + itemType: component.itemType, + dataModelBindings: component.dataModelBindings, + }); + }); +}); diff --git a/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.ts b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.ts new file mode 100644 index 00000000000..1a86b0a2c46 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/config/editModal/EditAutocomplete/updateAutocomplete.ts @@ -0,0 +1,21 @@ +import type { FormComponent } from '../../../../types/FormComponent'; +import type { ComponentTypeV3 } from 'app-shared/types/ComponentTypeV3'; + +export function updateAutocomplete( + component: FormComponent, + autocomplete: string, +): FormComponent { + if (autocomplete === '') return removeAutocomplete(component); + else + return { + ...component, + autocomplete, + }; +} + +function removeAutocomplete( + component: FormComponent, +): FormComponent { + const { autocomplete, ...rest } = component; + return rest; +}