diff --git a/frontend/app-development/router/routes.test.tsx b/frontend/app-development/router/routes.test.tsx new file mode 100644 index 00000000000..55e28f2ef9c --- /dev/null +++ b/frontend/app-development/router/routes.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react'; +import { routerRoutes } from './routes'; +import { RoutePaths } from '../enums/RoutePaths'; +import React from 'react'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import { AppVersion } from 'app-shared/types/AppVersion'; +import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; + +// Mocks: +jest.mock('../../packages/ux-editor-v3/src/SubApp', () => ({ + SubApp: () =>
, +})); +jest.mock('../../packages/ux-editor/src/SubApp', () => ({ + SubApp: () =>
, +})); + +// Test data +const org = 'org'; +const app = 'app'; + +describe('routes', () => { + describe(RoutePaths.UIEditor, () => { + type FrontendVersion = null | '3.0.0' | '4.0.0'; + type PackageVersion = 'version 3' | 'latest version'; + type TestCase = [PackageVersion, FrontendVersion]; + + const testCases: TestCase[] = [ + ['version 3', null], + ['version 3', '3.0.0'], + ['latest version', '4.0.0'], + ]; + + it.each(testCases)( + 'Renders the %s schema editor page when the app frontend version is %s', + (expectedPackage, frontendVersion) => { + renderUiEditor(frontendVersion); + expect(screen.getByTestId(expectedPackage)).toBeInTheDocument(); + }, + ); + + const renderUiEditor = (frontendVersion: string | null) => + renderSubapp(RoutePaths.UIEditor, frontendVersion); + }); +}); + +const renderSubapp = (path: RoutePathsm, frontendVersion: string = null) => { + const Subapp = routerRoutes.find((route) => route.path === path)!.subapp; + const appVersion: AppVersion = { + frontendVersion, + backendVersion: '7.0.0', + }; + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.AppVersion, org, app], appVersion); + return render( + + + , + ); +}; diff --git a/frontend/app-development/router/routes.tsx b/frontend/app-development/router/routes.tsx index e81b02c29e7..a53f0659f67 100644 --- a/frontend/app-development/router/routes.tsx +++ b/frontend/app-development/router/routes.tsx @@ -1,10 +1,15 @@ -import { SubApp } from '../../packages/ux-editor/src/SubApp'; +import { SubApp as UiEditorLatest } from '../../packages/ux-editor/src/SubApp'; +import { SubApp as UiEditorV3 } from '../../packages/ux-editor-v3/src/SubApp'; import { Overview } from '../features/overview/components/Overview'; import { TextEditor } from '../features/textEditor/TextEditor'; import DataModellingContainer from '../features/dataModelling/containers/DataModellingContainer'; import { DeployPage } from '../features/appPublish/pages/deployPage'; import { ProcessEditor } from 'app-development/features/processEditor'; import { RoutePaths } from 'app-development/enums/RoutePaths'; +import type { AppVersion } from 'app-shared/types/AppVersion'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useAppVersionQuery } from 'app-shared/hooks/queries'; +import React from 'react'; interface IRouteProps { headerTextKey?: string; @@ -25,10 +30,20 @@ interface RouterRoute { props?: IRouteProps; } +const latestFrontendVersion = '4'; +const isLatestFrontendVersion = (version: AppVersion): boolean => + version?.frontendVersion?.startsWith(latestFrontendVersion); + +const UiEditor = () => { + const { org, app } = useStudioUrlParams(); + const { data } = useAppVersionQuery(org, app); + return isLatestFrontendVersion(data) ? : ; +}; + export const routerRoutes: RouterRoute[] = [ { path: RoutePaths.UIEditor, - subapp: SubApp, + subapp: UiEditor, }, { path: RoutePaths.Overview, diff --git a/frontend/jest.config.js b/frontend/jest.config.js index b39fda1700d..916d95ac603 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -33,6 +33,8 @@ const config = { transformIgnorePatterns: [ `node_modules(\\\\|/)(?!${packagesToTransform})`, '\\.schema\\.v1\\.json$', + 'nb.json$', + 'en.json$', ], reporters: ['default', 'jest-junit'], moduleNameMapper: { @@ -44,6 +46,7 @@ const config = { '^@altinn/schema-editor/(.*)': path.join(__dirname, 'packages/schema-editor/src/$1'), '^@altinn/schema-model/(.*)': path.join(__dirname, 'packages/schema-model/src/$1'), '^@altinn/ux-editor/(.*)': path.join(__dirname, 'packages/ux-editor/src/$1'), + '^@altinn/ux-editor-v3/(.*)': path.join(__dirname, 'packages/ux-editor-v3/src/$1'), '^@altinn/process-editor/(.*)': path.join(__dirname, 'packages/process-editor/src/$1'), '^@altinn/policy-editor/(.*)': path.join(__dirname, 'packages/policy-editor/src/$1'), '^@studio/icons': path.join(__dirname, 'libs/studio-icons/src/$1'), @@ -60,6 +63,9 @@ if (process.env.CI) { config.reporters.push('github-actions'); config.collectCoverage = true; config.coverageReporters = ['lcov']; - config.coveragePathIgnorePatterns = ['frontend/packages/ux-editor/src/testing/']; + config.coveragePathIgnorePatterns = [ + 'frontend/packages/ux-editor/src/testing/', + 'frontend/packages/ux-editor-v3/src/testing/', + ]; } module.exports = config; diff --git a/frontend/packages/ux-editor-v3/README.md b/frontend/packages/ux-editor-v3/README.md new file mode 100644 index 00000000000..7ef2881c2cb --- /dev/null +++ b/frontend/packages/ux-editor-v3/README.md @@ -0,0 +1 @@ +# Tjeneste 3.0 react POC diff --git a/frontend/packages/ux-editor-v3/jest.config.js b/frontend/packages/ux-editor-v3/jest.config.js new file mode 100644 index 00000000000..990bd442804 --- /dev/null +++ b/frontend/packages/ux-editor-v3/jest.config.js @@ -0,0 +1 @@ +module.exports = require('../../jest.config'); diff --git a/frontend/packages/ux-editor-v3/package.json b/frontend/packages/ux-editor-v3/package.json new file mode 100644 index 00000000000..1d667865ce5 --- /dev/null +++ b/frontend/packages/ux-editor-v3/package.json @@ -0,0 +1,36 @@ +{ + "name": "ux-editor-v3", + "description": "", + "version": "1.0.1", + "author": "Altinn", + "dependencies": { + "@mui/material": "5.15.5", + "@reduxjs/toolkit": "1.9.7", + "@studio/icons": "workspace:^", + "axios": "1.6.5", + "classnames": "2.5.1", + "react": "18.2.0", + "react-dnd": "16.0.1", + "react-dnd-html5-backend": "16.0.1", + "react-dom": "18.2.0", + "react-modal": "3.16.1", + "react-redux": "8.1.3", + "react-select": "5.8.0", + "redux": "4.2.1", + "reselect": "4.1.8", + "typescript": "5.3.3", + "uuid": "9.0.1" + }, + "devDependencies": { + "@redux-devtools/extension": "3.0.0", + "jest": "29.7.0" + }, + "license": "3-Clause BSD", + "main": "index.js", + "peerDependencies": { + "webpack": "5.89.0" + }, + "scripts": { + "test": "jest --maxWorkers=50%" + } +} diff --git a/frontend/packages/ux-editor-v3/src/App.test.tsx b/frontend/packages/ux-editor-v3/src/App.test.tsx new file mode 100644 index 00000000000..12c127639a0 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/App.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { formLayoutSettingsMock, renderWithProviders } from './testing/mocks'; +import { App } from './App'; +import { textMock } from '../../../testing/mocks/i18nMock'; +import { typedLocalStorage } from 'app-shared/utils/webStorage'; +import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { appStateMock } from './testing/stateMocks'; +import type { AppContextProps } from './AppContext'; +import ruleHandlerMock from './testing/ruleHandlerMock'; +import { layoutSetsMock } from './testing/layoutMock'; + +const { selectedLayoutSet } = appStateMock.formDesigner.layout; + +const renderApp = ( + queries: Partial = {}, + appContextProps: Partial = {}, +) => { + return renderWithProviders(, { + queries, + appContextProps, + }); +}; + +describe('App', () => { + it('should render the spinner', () => { + renderApp({}, { selectedLayoutSet }); + expect(screen.getByText(textMock('general.loading'))).toBeInTheDocument(); + }); + + it('should render the component', async () => { + const mockQueries: Partial = { + getInstanceIdForPreview: jest.fn().mockImplementation(() => Promise.resolve('test')), + getRuleModel: jest.fn().mockImplementation(() => Promise.resolve(ruleHandlerMock)), + getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve(layoutSetsMock)), + getFormLayoutSettings: jest + .fn() + .mockImplementation(() => Promise.resolve(formLayoutSettingsMock)), + }; + renderApp(mockQueries, { selectedLayoutSet }); + await waitFor(() => + expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument(), + ); + }); + + it('Removes the preview layout set from local storage if it does not exist', async () => { + const removeSelectedLayoutSetMock = jest.fn(); + const layoutSetThatDoesNotExist = 'layout-set-that-does-not-exist'; + const mockQueries: Partial = { + getInstanceIdForPreview: jest.fn().mockImplementation(() => Promise.resolve('test')), + getRuleModel: jest.fn().mockImplementation(() => Promise.resolve(ruleHandlerMock)), + getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve(layoutSetsMock)), + getFormLayoutSettings: jest + .fn() + .mockImplementation(() => Promise.resolve(formLayoutSettingsMock)), + }; + renderApp(mockQueries, { + selectedLayoutSet: layoutSetThatDoesNotExist, + removeSelectedLayoutSet: removeSelectedLayoutSetMock, + }); + await waitFor(() => + expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument(), + ); + expect(removeSelectedLayoutSetMock).toHaveBeenCalledTimes(1); + }); + + it('Does not remove the preview layout set from local storage if it exists', async () => { + const removeSelectedLayoutSetMock = jest.fn(); + const mockQueries: Partial = { + getInstanceIdForPreview: jest.fn().mockImplementation(() => Promise.resolve('test')), + getRuleModel: jest.fn().mockImplementation(() => Promise.resolve(ruleHandlerMock)), + getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve(layoutSetsMock)), + getFormLayoutSettings: jest + .fn() + .mockImplementation(() => Promise.resolve(formLayoutSettingsMock)), + }; + jest.spyOn(typedLocalStorage, 'getItem').mockReturnValue(selectedLayoutSet); + renderApp(mockQueries, { + selectedLayoutSet, + removeSelectedLayoutSet: removeSelectedLayoutSetMock, + }); + await waitFor(() => + expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument(), + ); + expect(removeSelectedLayoutSetMock).not.toHaveBeenCalled(); + }); + + it('Renders the unsupported version message if the version is not supported', async () => { + renderApp( + { + getAppVersion: jest + .fn() + .mockImplementation(() => + Promise.resolve({ backendVersion: '7.15.1', frontendVersion: '4.0.0-rc1' }), + ), + }, + { selectedLayoutSet }, + ); + + expect( + await screen.findByText( + textMock('ux_editor.unsupported_version_message_title', { version: 'V4' }), + ), + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor-v3/src/App.tsx b/frontend/packages/ux-editor-v3/src/App.tsx new file mode 100644 index 00000000000..dbf33f9da21 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/App.tsx @@ -0,0 +1,104 @@ +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { FormDesigner } from './containers/FormDesigner'; +import { useText } from './hooks'; +import { StudioPageSpinner } from '@studio/components'; +import { ErrorPage } from './components/ErrorPage'; +import { useDatamodelMetadataQuery } from './hooks/queries/useDatamodelMetadataQuery'; +import { selectedLayoutNameSelector } from './selectors/formLayoutSelectors'; +import { useWidgetsQuery } from './hooks/queries/useWidgetsQuery'; +import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery'; +import { useLayoutSetsQuery } from './hooks/queries/useLayoutSetsQuery'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useAppContext } from './hooks/useAppContext'; +import { FormContextProvider } from './containers/FormContext'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import { UnsupportedVersionMessage } from './components/UnsupportedVersionMessage'; +import { useAppVersionQuery } from 'app-shared/hooks/queries/useAppVersionQuery'; + +/** + * This is the main React component responsible for controlling + * the mode of the application and loading initial data for the + * application + */ + +export function App() { + const t = useText(); + const { org, app } = useStudioUrlParams(); + const selectedLayout = useSelector(selectedLayoutNameSelector); + const { selectedLayoutSet, setSelectedLayoutSet, removeSelectedLayoutSet } = useAppContext(); + const { data: layoutSets, isSuccess: areLayoutSetsFetched } = useLayoutSetsQuery(org, app); + const { isSuccess: areWidgetsFetched, isError: widgetFetchedError } = useWidgetsQuery(org, app); + const { isSuccess: isDatamodelFetched, isError: dataModelFetchedError } = + useDatamodelMetadataQuery(org, app); + const { isSuccess: areTextResourcesFetched } = useTextResourcesQuery(org, app); + const { data: appVersion } = useAppVersionQuery(org, app); + + useEffect(() => { + if ( + areLayoutSetsFetched && + selectedLayoutSet && + (!layoutSets || !layoutSets.sets.map((set) => set.id).includes(selectedLayoutSet)) + ) + removeSelectedLayoutSet(); + }, [ + areLayoutSetsFetched, + layoutSets, + selectedLayoutSet, + setSelectedLayoutSet, + removeSelectedLayoutSet, + ]); + + const componentIsReady = areWidgetsFetched && isDatamodelFetched && areTextResourcesFetched; + + const componentHasError = dataModelFetchedError || widgetFetchedError; + + const mapErrorToDisplayError = (): { title: string; message: string } => { + const defaultTitle = t('general.fetch_error_title'); + const defaultMessage = t('general.fetch_error_message'); + + const createErrorMessage = (resource: string): { title: string; message: string } => ({ + title: `${defaultTitle} ${resource}`, + message: defaultMessage, + }); + + if (dataModelFetchedError) { + return createErrorMessage(t('general.dataModel')); + } + if (widgetFetchedError) { + return createErrorMessage(t('general.widget')); + } + + return createErrorMessage(t('general.unknown_error')); + }; + + useEffect(() => { + if (selectedLayoutSet === null && layoutSets) { + // Only set layout set if layout sets exists and there is no layout set selected yet + setSelectedLayoutSet(layoutSets.sets[0].id); + } + }, [setSelectedLayoutSet, selectedLayoutSet, layoutSets, app]); + + if ( + appVersion?.frontendVersion?.startsWith('4') && + !shouldDisplayFeature('shouldOverrideAppFrontendCheck') + ) { + return ( + + ); + } + + if (componentHasError) { + const mappedError = mapErrorToDisplayError(); + return ; + } + + if (componentIsReady) { + return ( + + + + ); + } + return ; +} diff --git a/frontend/packages/ux-editor-v3/src/AppContext.ts b/frontend/packages/ux-editor-v3/src/AppContext.ts new file mode 100644 index 00000000000..eaf0fedb330 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/AppContext.ts @@ -0,0 +1,11 @@ +import type { RefObject } from 'react'; +import { createContext } from 'react'; + +export interface AppContextProps { + previewIframeRef: RefObject; + selectedLayoutSet: string; + setSelectedLayoutSet: (layoutSet: string) => void; + removeSelectedLayoutSet: () => void; +} + +export const AppContext = createContext(null); diff --git a/frontend/packages/ux-editor-v3/src/SubApp.test.tsx b/frontend/packages/ux-editor-v3/src/SubApp.test.tsx new file mode 100644 index 00000000000..628baeff97e --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/SubApp.test.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import { SubApp } from './SubApp'; +import { render, screen, within } from '@testing-library/react'; + +const providerTestId = 'provider'; +const appTestId = 'app'; +jest.mock('./AppContext', () => ({ + AppContext: { + Provider: ({ children }: { children: ReactNode }) => { + return
{children}
; + }, + }, +})); +jest.mock('./App', () => ({ + App: () => { + return
App
; + }, +})); + +describe('SubApp', () => { + it('Renders the app within the AppContext provider', () => { + render(); + const provider = screen.getByTestId(providerTestId); + expect(provider).toBeInTheDocument(); + expect(within(provider).getByTestId(appTestId)).toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor-v3/src/SubApp.tsx b/frontend/packages/ux-editor-v3/src/SubApp.tsx new file mode 100644 index 00000000000..d2288698d6b --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/SubApp.tsx @@ -0,0 +1,32 @@ +import React, { useRef } from 'react'; +import { Provider } from 'react-redux'; +import { App } from './App'; +import { setupStore } from './store'; +import './styles/index.css'; +import { AppContext } from './AppContext'; +import { useReactiveLocalStorage } from 'app-shared/hooks/useReactiveLocalStorage'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; + +const store = setupStore(); + +export const SubApp = () => { + const previewIframeRef = useRef(null); + const { app } = useStudioUrlParams(); + const [selectedLayoutSet, setSelectedLayoutSet, removeSelectedLayoutSet] = + useReactiveLocalStorage('layoutSet/' + app, null); + + return ( + + + + + + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ConfPageToolbar.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/ConfPageToolbar.tsx new file mode 100644 index 00000000000..b161961e62d --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ConfPageToolbar.tsx @@ -0,0 +1,44 @@ +import React, { useState } from 'react'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import type { IToolbarElement } from '../../types/global'; +import { InformationPanelComponent } from '../toolbar/InformationPanelComponent'; +import { ToolbarItem } from './ToolbarItem'; +import { confOnScreenComponents } from '../../data/formItemConfig'; +import { getComponentTitleByComponentType } from '../../utils/language'; +import { mapComponentToToolbarElement } from '../../utils/formLayoutUtils'; +import { useTranslation } from 'react-i18next'; + +export const ConfPageToolbar = () => { + const [anchorElement, setAnchorElement] = useState(null); + const [compSelForInfoPanel, setCompSelForInfoPanel] = useState(null); + const { t } = useTranslation(); + const componentList: IToolbarElement[] = confOnScreenComponents.map(mapComponentToToolbarElement); + const handleComponentInformationOpen = (component: ComponentType, event: any) => { + setCompSelForInfoPanel(component); + setAnchorElement(event.currentTarget); + }; + + const handleComponentInformationClose = () => { + setCompSelForInfoPanel(null); + setAnchorElement(null); + }; + return ( + <> + {componentList.map((component: IToolbarElement) => ( + + ))} + + + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.module.css new file mode 100644 index 00000000000..7ed59ccfaa2 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.module.css @@ -0,0 +1,25 @@ +.configureLayoutSetButton { + text-align: left; +} + +.configureLayoutSet { + display: flex; + margin: 10px; +} + +.configureLayoutSetInfo { + max-width: 500px; + border-radius: 20px; + padding: 10px; +} + +.informationButton { + width: 25px; + height: 25px; + margin-top: 5px; +} + +.label { + display: block; + margin-bottom: 8px; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.tsx new file mode 100644 index 00000000000..d37706a75d9 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ConfigureLayoutSetPanel.tsx @@ -0,0 +1,167 @@ +import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useId } from 'react'; +import { useTranslation, Trans } from 'react-i18next'; +import classes from './ConfigureLayoutSetPanel.module.css'; +import { useConfigureLayoutSetMutation } from '../../hooks/mutations/useConfigureLayoutSetMutation'; +import { Paragraph, Textfield } from '@digdir/design-system-react'; +import { Popover } from '@mui/material'; +import { InformationIcon } from '@navikt/aksel-icons'; +import { altinnDocsUrl } from 'app-shared/ext-urls'; +import { validateLayoutNameAndLayoutSetName } from '../../utils/validationUtils/validateLayoutNameAndLayoutSetName'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { StudioButton } from '@studio/components'; + +export const ConfigureLayoutSetPanel = () => { + const inputLayoutSetNameId = useId(); + const { org, app } = useStudioUrlParams(); + const { t } = useTranslation(); + const configureLayoutSetMutation = useConfigureLayoutSetMutation(org, app); + const [anchorEl, setAnchorEl] = useState(null); + const [popoverOpen, setPopoverOpen] = useState(false); + const [layoutSetName, setLayoutSetName] = useState(''); + const [editLayoutSetName, setEditLayoutSetName] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const configPanelRef = useRef(null); + + const handleConfigureLayoutSet = async (): Promise => { + if (layoutSetName === '') { + setErrorMessage(t('left_menu.pages_error_empty')); + } else { + await configureLayoutSetMutation.mutateAsync({ layoutSetName }); + } + }; + + const handleTogglePopOver = (event?: MouseEvent): void => { + setAnchorEl(event ? event.currentTarget : null); + setPopoverOpen(!!event); + }; + + const handleKeyPress = (event: KeyboardEvent) => { + const shouldSave = event.key === 'Enter'; + if (shouldSave) { + handleConfigureLayoutSet(); + setEditLayoutSetName(false); + return; + } + + const shouldCancel = event.key === 'Escape'; + if (shouldCancel) { + closePanelAndResetLayoutSetName(); + } + }; + + const handleClickOutside = useCallback((event: Event): void => { + const target = event.target as HTMLElement; + + // If the click is outside the configPanelRef, close the panel and reset the layoutSetName + if (!configPanelRef.current?.contains(target)) { + closePanelAndResetLayoutSetName(); + } + }, []); + + const closePanelAndResetLayoutSetName = (): void => { + setEditLayoutSetName(false); + setLayoutSetName(''); + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [handleClickOutside]); + + const toggleConfigureLayoutSetName = (): void => { + setEditLayoutSetName((prevEditLayoutSetName) => !prevEditLayoutSetName); + }; + + const handleOnNameChange = (event: ChangeEvent): void => { + // The Regex below replaces all illegal characters with a dash + const newNameCandidate = event.target.value.replace(/[/\\?%*:|"<>]/g, '-').trim(); + + const error = validateLayoutSetName(newNameCandidate); + + if (error) { + setErrorMessage(error); + return; + } + + setErrorMessage(''); + setLayoutSetName(newNameCandidate); + }; + + const validateLayoutSetName = (newLayoutSetName?: string): string | null => { + if (!newLayoutSetName) { + return t('left_menu.pages_error_empty'); + } + + if (newLayoutSetName.length >= 30) { + return t('left_menu.pages_error_length'); + } + + if (!validateLayoutNameAndLayoutSetName(newLayoutSetName)) { + return t('left_menu.pages_error_format'); + } + return null; + }; + + return ( +
+ {editLayoutSetName ? ( +
+ + + {errorMessage && ( + + {errorMessage} + + )} +
+ ) : ( + + {t('left_menu.configure_layout_sets')} + + )} +
+ +
+ {popoverOpen && ( + + handleTogglePopOver()} + > + + + + + + )} +
+ ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.css b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.css new file mode 100644 index 00000000000..9b853eee1d2 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.css @@ -0,0 +1,16 @@ +.a-item { + display: flex; + user-select: none; + padding: 0.5rem !important; + margin: 0 0 0.5rem 0 !important; + align-items: flex-start; + align-content: flex-start; + line-height: 1.5; + border-radius: 3px; + background: #fff; + border: 1px solid #ddd; +} + +.a-item + .a-item-clone { + display: none !important; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.module.css new file mode 100644 index 00000000000..40b5307cef6 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.module.css @@ -0,0 +1,12 @@ +.accordionItem > div { + border-bottom: none !important; +} + +.accordionHeader > button { + border: none; + padding: var(--fds-spacing-3) var(--fds-spacing-5); +} + +.accordionContent { + padding: 0; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.tsx new file mode 100644 index 00000000000..d0ca40d4079 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/DefaultToolbar.tsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import type { IToolbarElement } from '../../types/global'; +import { CollapsableMenus } from '../../types/global'; +import { InformationPanelComponent } from '../toolbar/InformationPanelComponent'; +import { mapComponentToToolbarElement } from '../../utils/formLayoutUtils'; +import './DefaultToolbar.css'; +import classes from './DefaultToolbar.module.css'; +import { useTranslation } from 'react-i18next'; +import { schemaComponents, textComponents, advancedItems } from '../../data/formItemConfig'; +import type { KeyValuePairs } from 'app-shared/types/KeyValuePairs'; +import { Accordion } from '@digdir/design-system-react'; +import { getCollapsableMenuTitleByType } from '../../utils/language'; +import { ToolbarItem } from './ToolbarItem'; +import { getComponentTitleByComponentType } from '../../utils/language'; + +export function DefaultToolbar() { + const [compInfoPanelOpen, setCompInfoPanelOpen] = useState(false); + const [compSelForInfoPanel, setCompSelForInfoPanel] = useState(null); + const [anchorElement, setAnchorElement] = useState(null); + + const { t } = useTranslation(); + // TODO: Uncomment when widgets are implemented + // const { org, app } = useParams(); + // const { data: widgetsList } = useWidgetsQuery(org, app); + + const componentList: IToolbarElement[] = schemaComponents.map(mapComponentToToolbarElement); + const textComponentList: IToolbarElement[] = textComponents.map(mapComponentToToolbarElement); + const advancedComponentsList: IToolbarElement[] = advancedItems.map(mapComponentToToolbarElement); + // TODO: Uncomment when widgets are implemented + // const widgetComponentsList: IToolbarElement[] = widgetsList.map( + // (widget) => mapWidgetToToolbarElement(widget, t) + // ); + + const allComponentLists: KeyValuePairs = { + [CollapsableMenus.Components]: componentList, + [CollapsableMenus.Texts]: textComponentList, + [CollapsableMenus.AdvancedComponents]: advancedComponentsList, + // TODO: Uncomment when widgets are implemented + // [CollapsableMenus.Widgets]: widgetComponentsList, + // [CollapsableMenus.ThirdParty]: thirdPartyComponentList, + }; + + const handleComponentInformationOpen = (component: ComponentType, event: any) => { + setCompInfoPanelOpen(true); + setCompSelForInfoPanel(component); + setAnchorElement(event.currentTarget); + }; + + const handleComponentInformationClose = () => { + setCompInfoPanelOpen(false); + setCompSelForInfoPanel(null); + setAnchorElement(null); + }; + + return ( + <> + {Object.values(CollapsableMenus).map((key: CollapsableMenus) => { + return ( + + + + {getCollapsableMenuTitleByType(key, t)} + + + {allComponentLists[key].map((component: IToolbarElement) => ( + + ))} + + + + ); + })} + + + ); +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/Elements.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.module.css new file mode 100644 index 00000000000..c51c270534a --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.module.css @@ -0,0 +1,23 @@ +.root { + background: var(--fds-semantic-surface-neutral-subtle); + flex: var(--elements-width-fraction); +} + +.pagesContent { + padding: var(--fds-spacing-2); +} + +.pagesContent .addButton { + padding-bottom: var(--fds-spacing-3); + padding-top: var(--fds-spacing-2); +} + +.componentsHeader { + margin: var(--fds-spacing-3); + padding-bottom: 10px; + border-bottom: 2px solid var(--semantic-surface-neutral-subtle-hover); +} + +.noPageSelected { + padding-inline: var(--fds-spacing-3); +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx new file mode 100644 index 00000000000..47cccec0cd0 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/Elements.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import { ConfPageToolbar } from './ConfPageToolbar'; +import { DefaultToolbar } from './DefaultToolbar'; +import { Heading, Paragraph } from '@digdir/design-system-react'; +import { useText } from '../../hooks'; +import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors'; +import { useFormLayoutSettingsQuery } from '../../hooks/queries/useFormLayoutSettingsQuery'; +import { useLayoutSetsQuery } from '../../hooks/queries/useLayoutSetsQuery'; +import { LayoutSetsContainer } from './LayoutSetsContainer'; +import { ConfigureLayoutSetPanel } from './ConfigureLayoutSetPanel'; +import { Accordion } from '@digdir/design-system-react'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { shouldDisplayFeature } from 'app-shared/utils/featureToggleUtils'; +import classes from './Elements.module.css'; +import { useAppContext } from '../../hooks/useAppContext'; + +export const Elements = () => { + const { org, app } = useStudioUrlParams(); + const selectedLayout: string = useSelector(selectedLayoutNameSelector); + const { selectedLayoutSet } = useAppContext(); + const layoutSetsQuery = useLayoutSetsQuery(org, app); + const { data: formLayoutSettings } = useFormLayoutSettingsQuery(org, app, selectedLayoutSet); + const receiptName = formLayoutSettings?.receiptLayoutName; + const layoutSetNames = layoutSetsQuery?.data?.sets; + + const hideComponents = selectedLayout === 'default' || selectedLayout === undefined; + + const t = useText(); + + return ( +
+ {shouldDisplayFeature('configureLayoutSet') && layoutSetNames ? ( + + ) : ( + + )} + + {shouldDisplayFeature('configureLayoutSet') && ( + 0}> + {t('left_menu.layout_sets')} + + {layoutSetNames ? : } + + + )} + +
+ + {t('left_menu.components')} + + {hideComponents ? ( + + {t('left_menu.no_components_selected')} + + ) : receiptName === selectedLayout ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.module.css new file mode 100644 index 00000000000..ebe13d456a7 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.module.css @@ -0,0 +1,4 @@ +.dropDownContainer { + margin: var(--fds-spacing-5); + margin-bottom: 5px; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.test.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.test.tsx new file mode 100644 index 00000000000..6bb626a81b4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.test.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { act, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { LayoutSetsContainer } from './LayoutSetsContainer'; +import { queryClientMock } from 'app-shared/mocks/queryClientMock'; +import { renderWithMockStore } from '../../testing/mocks'; +import { layoutSetsMock } from '../../testing/layoutMock'; +import type { AppContextProps } from '../../AppContext'; +import { appStateMock } from '../../testing/stateMocks'; +import { QueryKey } from 'app-shared/types/QueryKey'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); +// Test data +const org = 'org'; +const app = 'app'; +const layoutSetName1 = layoutSetsMock.sets[0].id; +const layoutSetName2 = layoutSetsMock.sets[1].id; +const { selectedLayoutSet } = appStateMock.formDesigner.layout; +const setSelectedLayoutSetMock = jest.fn(); + +describe('LayoutSetsContainer', () => { + it('renders component', async () => { + render(); + + expect(await screen.findByRole('option', { name: layoutSetName1 })).toBeInTheDocument(); + expect(await screen.findByRole('option', { name: layoutSetName2 })).toBeInTheDocument(); + }); + + it('NativeSelect should be rendered', async () => { + render(); + expect(screen.getByRole('combobox')).toBeInTheDocument(); + }); + + it('Should update selected layout set when set is clicked in native select', async () => { + render(); + const user = userEvent.setup(); + await act(() => user.selectOptions(screen.getByRole('combobox'), layoutSetName2)); + expect(setSelectedLayoutSetMock).toHaveBeenCalledTimes(1); + }); +}); + +const render = () => { + queryClientMock.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock); + const appContextProps: Partial = { + selectedLayoutSet: selectedLayoutSet, + setSelectedLayoutSet: setSelectedLayoutSetMock, + }; + return renderWithMockStore({}, {}, undefined, appContextProps)(); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx new file mode 100644 index 00000000000..9883442b1a4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/LayoutSetsContainer.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useLayoutSetsQuery } from '../../hooks/queries/useLayoutSetsQuery'; +import { NativeSelect } from '@digdir/design-system-react'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useText } from '../../hooks'; +import classes from './LayoutSetsContainer.module.css'; +import { useAppContext } from '../../hooks/useAppContext'; + +export function LayoutSetsContainer() { + const { org, app } = useStudioUrlParams(); + const layoutSetsQuery = useLayoutSetsQuery(org, app); + const layoutSetNames = layoutSetsQuery.data?.sets?.map((set) => set.id); + const t = useText(); + const { selectedLayoutSet, setSelectedLayoutSet } = useAppContext(); + + const onLayoutSetClick = (set: string) => { + if (selectedLayoutSet !== set) { + setSelectedLayoutSet(set); + } + }; + + if (!layoutSetNames) return null; + + return ( +
+ onLayoutSetClick(event.target.value)} + value={selectedLayoutSet} + > + {layoutSetNames.map((set: string) => { + return ( + + ); + })} + +
+ ); +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/PageElement.module.css b/frontend/packages/ux-editor-v3/src/components/Elements/PageElement.module.css new file mode 100644 index 00000000000..e4743b774f0 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/PageElement.module.css @@ -0,0 +1,44 @@ +.elementContainer { + display: flex; + align-items: center; +} + +.selected .elementContainer { + font-weight: 700; +} + +.pageContainer { + flex: 1; +} + +.pageButton { + cursor: pointer; + padding: var(--fds-spacing-3); +} + +.selected .pageButton { + font-weight: 700; +} + +.pageField { + padding: var(--fds-spacing-1); +} + +.ellipsisButton { + margin-left: 1.2rem; + visibility: hidden; +} + +.elementContainer:hover .ellipsisButton { + visibility: visible; +} + +.errorMessage { + font-size: 13px; + font-weight: 400; + padding-top: 6px; +} + +.invalid { + color: red; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/ToolbarItem.tsx b/frontend/packages/ux-editor-v3/src/components/Elements/ToolbarItem.tsx new file mode 100644 index 00000000000..083fa43d458 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/ToolbarItem.tsx @@ -0,0 +1,34 @@ +import type { MouseEvent } from 'react'; +import React from 'react'; +import { ToolbarItemComponent } from '../toolbar/ToolbarItemComponent'; +import type { ComponentType } from 'app-shared/types/ComponentType'; +import { DragAndDropTree } from 'app-shared/components/DragAndDropTree'; + +interface IToolbarItemProps { + text: string; + notDraggable?: boolean; + onClick: (type: ComponentType, event: MouseEvent) => void; + componentType: ComponentType; + icon?: React.ComponentType; +} + +export const ToolbarItem = ({ + notDraggable, + componentType, + onClick, + text, + icon, +}: IToolbarItemProps) => { + return ( +
+ notDraggable={notDraggable} payload={componentType}> + + +
+ ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Elements/index.ts b/frontend/packages/ux-editor-v3/src/components/Elements/index.ts new file mode 100644 index 00000000000..447fae37844 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Elements/index.ts @@ -0,0 +1 @@ +export { Elements } from './Elements'; diff --git a/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.module.css b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.module.css new file mode 100644 index 00000000000..672039db6f4 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.module.css @@ -0,0 +1,3 @@ +.container { + padding: 18px; +} diff --git a/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.tsx b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.tsx new file mode 100644 index 00000000000..2d29c0a2eb6 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/ErrorPage/ErrorPage.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import classes from './ErrorPage.module.css'; + +type ErrorPageProps = { + title: string; + message: string; +}; +export const ErrorPage = ({ title, message }: ErrorPageProps): JSX.Element => { + return ( +
+

{title}

+

{message}

+
+ ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/ErrorPage/index.ts b/frontend/packages/ux-editor-v3/src/components/ErrorPage/index.ts new file mode 100644 index 00000000000..6e8d01e81e3 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/ErrorPage/index.ts @@ -0,0 +1 @@ +export { ErrorPage } from './ErrorPage'; diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.module.css b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.module.css new file mode 100644 index 00000000000..648a18eec0a --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.module.css @@ -0,0 +1,24 @@ +.handle { + --point-size: 3px; + border-width: 0; + width: var(--drag-handle-inner-width, var(--drag-handle-width, 25px)); + display: flex; + align-items: center; + justify-content: center; + cursor: move; + height: 100%; +} + +.points { + display: grid; + grid-template: var(--point-size) / var(--point-size) var(--point-size); + gap: var(--point-size); + margin: auto; +} + +.points span { + background: #00000040; + width: var(--point-size); + height: var(--point-size); + border-radius: 50%; +} diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.tsx b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.tsx new file mode 100644 index 00000000000..c38683faafa --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/DragHandle.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import classes from './DragHandle.module.css'; + +export const DragHandle = () => ( +
+ + + + + + + + +
+); diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.module.css b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.module.css new file mode 100644 index 00000000000..889178b3a8c --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.module.css @@ -0,0 +1,97 @@ +.wrapper { + align-items: stretch; + display: flex; +} + +.formComponentWithHandle { + align-items: stretch; + border-bottom-left-radius: 5px; + border-top-left-radius: 5px; + display: flex; + flex-direction: row; + flex: 1; +} + +.editMode .formComponentWithHandle, +.previewMode .formComponentWithHandle { + border: 1px dashed transparent; +} + +.editMode .formComponentWithHandle { + border-color: #008fd6; + box-shadow: 0 0 4px #1eadf740; + border-radius: 5px; +} + +.dragHandle { + background-color: #00000010; + border-bottom-left-radius: 5px; + border-top-left-radius: 5px; + width: var(--drag-handle-width); +} + +.dragHandle, +.buttons { + visibility: hidden; +} + +.editMode .dragHandle, +.editMode .buttons, +.wrapper:hover .dragHandle, +.wrapper:hover .buttons { + visibility: visible; +} + +.buttons:has(button[aria-expanded='true']) { + visibility: visible; +} + +.editMode .dragHandle { + --drag-handle-border-left-width: 6px; + --drag-handle-inner-width: calc(var(--drag-handle-width) - var(--drag-handle-border-left-width)); + border-left: var(--drag-handle-border-left-width) solid #008fd6; + box-sizing: border-box; +} + +.formComponentWithHandle:has(.dragHandle:hover) { + box-shadow: 0 0 0.4rem rgba(0, 0, 0, 0.25); +} + +.formComponent { + background-color: #fff; + border: 1px solid #6a6a6a; + color: #022f51; + flex: 1; + padding: 1rem; + cursor: pointer; +} + +.editMode .formComponent, +.previewMode .formComponent { + border: 0; +} + +.previewMode:not(.editMode):hover .formComponent { + background-color: #00000010; + border-radius: 5px; +} + +.buttons { + display: flex; + flex-direction: column; + margin-left: var(--buttons-distance); + gap: var(--buttons-distance); +} + +.formComponentTitle { + margin-top: 0.6rem; + color: #022f51; + align-items: center; + display: flex; + gap: 0.5rem; +} + +.formComponentTitle .icon { + font-size: 2rem; + display: inline-flex; +} diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.test.tsx b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.test.tsx new file mode 100644 index 00000000000..49989bf86ed --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.test.tsx @@ -0,0 +1,235 @@ +import React from 'react'; +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { HTML5Backend } from 'react-dnd-html5-backend'; +import { DndProvider } from 'react-dnd'; +import type { IFormComponentProps } from './FormComponent'; +import { FormComponent } from './FormComponent'; +import { + renderHookWithMockStore, + renderWithMockStore, + textLanguagesMock, +} from '../../testing/mocks'; +import { component1IdMock, component1Mock } from '../../testing/layoutMock'; +import { textMock } from '../../../../../testing/mocks/i18nMock'; +import { useTextResourcesQuery } from 'app-shared/hooks/queries/useTextResourcesQuery'; +import type { ITextResource } from 'app-shared/types/global'; +import { useDeleteFormComponentMutation } from '../../hooks/mutations/useDeleteFormComponentMutation'; +import type { UseMutationResult } from '@tanstack/react-query'; +import type { IInternalLayout } from '../../types/global'; + +const user = userEvent.setup(); + +// Test data: +const org = 'org'; +const app = 'app'; +const testTextResourceKey = 'test-key'; +const testTextResourceValue = 'test-value'; +const emptyTextResourceKey = 'empty-key'; +const testTextResource: ITextResource = { id: testTextResourceKey, value: testTextResourceValue }; +const emptyTextResource: ITextResource = { id: emptyTextResourceKey, value: '' }; +const nbTextResources: ITextResource[] = [testTextResource, emptyTextResource]; +const handleEditMock = jest.fn().mockImplementation(() => Promise.resolve()); +const handleSaveMock = jest.fn(); +const debounceSaveMock = jest.fn(); +const handleDiscardMock = jest.fn(); + +jest.mock('../../hooks/mutations/useDeleteFormComponentMutation'); +const mockDeleteFormComponent = jest.fn(); +const mockUseDeleteFormComponentMutation = useDeleteFormComponentMutation as jest.MockedFunction< + typeof useDeleteFormComponentMutation +>; +mockUseDeleteFormComponentMutation.mockReturnValue({ + mutate: mockDeleteFormComponent, +} as unknown as UseMutationResult); + +describe('FormComponent', () => { + it('should render the component', async () => { + await render(); + + expect(screen.getByRole('button', { name: textMock('general.delete') })).toBeInTheDocument(); + }); + + it('should edit the component when clicking on the component', async () => { + await render(); + + const component = screen.getByRole('listitem'); + await act(() => user.click(component)); + + expect(handleSaveMock).toHaveBeenCalledTimes(1); + expect(handleEditMock).toHaveBeenCalledTimes(1); + }); + + describe('Delete confirmation dialog', () => { + afterEach(jest.clearAllMocks); + + it('should open the confirmation dialog when clicking the delete button', async () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + + const text = await screen.findByText(textMock('ux_editor.component_deletion_text')); + expect(text).toBeInTheDocument(); + + const confirmButton = screen.getByRole('button', { + name: textMock('ux_editor.component_deletion_confirm'), + }); + 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 () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const confirmButton = screen.getByRole('button', { + name: textMock('ux_editor.component_deletion_confirm'), + }); + await act(() => user.click(confirmButton)); + + expect(mockDeleteFormComponent).toHaveBeenCalledWith(component1IdMock); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('should close the confirmation dialog when clicking the cancel button', async () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const cancelButton = screen.getByRole('button', { name: textMock('general.cancel') }); + await act(() => user.click(cancelButton)); + + expect(mockDeleteFormComponent).toHaveBeenCalledTimes(0); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('should call "handleDiscard" when "isEditMode: true"', async () => { + await render({ isEditMode: true, handleDiscard: handleDiscardMock }); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + const confirmButton = screen.getByRole('button', { + name: textMock('ux_editor.component_deletion_confirm'), + }); + await act(() => user.click(confirmButton)); + + expect(mockDeleteFormComponent).toHaveBeenCalledTimes(1); + expect(handleDiscardMock).toHaveBeenCalledTimes(1); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + + it('should close when clicking outside the popover', async () => { + await render(); + + const deleteButton = screen.getByRole('button', { name: textMock('general.delete') }); + await act(() => user.click(deleteButton)); + + await act(() => user.click(document.body)); + + expect(mockDeleteFormComponent).toHaveBeenCalledTimes(0); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); + }); + + describe('title', () => { + it('should display the title', async () => { + await render({ + component: { + ...component1Mock, + textResourceBindings: { + title: testTextResourceKey, + }, + }, + }); + + expect(screen.getByText(testTextResourceValue)).toBeInTheDocument(); + }); + + it('should display the component type when the title is empty', async () => { + await render({ + component: { + ...component1Mock, + textResourceBindings: { + title: emptyTextResourceKey, + }, + }, + }); + + expect(screen.getByRole('listitem')).toHaveTextContent( + textMock('ux_editor.component_title.Input'), + ); + }); + + it('should display the component type when the title is undefined', async () => { + await render({ + component: { + ...component1Mock, + textResourceBindings: { + title: undefined, + }, + }, + }); + + expect(screen.getByRole('listitem')).toHaveTextContent( + textMock('ux_editor.component_title.Input'), + ); + }); + }); + + describe('icon', () => { + it('should display the icon', async () => { + await render({ + component: { + ...component1Mock, + icon: 'Icon', + }, + }); + + expect(screen.getByTitle(textMock('ux_editor.component_title.Input'))).toBeInTheDocument(); + }); + }); +}); + +const waitForData = async () => { + const { result: texts } = renderHookWithMockStore( + {}, + { + getTextResources: jest + .fn() + .mockImplementation(() => Promise.resolve({ language: 'nb', resources: nbTextResources })), + getTextLanguages: jest.fn().mockImplementation(() => Promise.resolve(textLanguagesMock)), + }, + )(() => useTextResourcesQuery(org, app)).renderHookResult; + await waitFor(() => expect(texts.current.isSuccess).toBe(true)); +}; + +const render = async (props: Partial = {}) => { + const allProps: IFormComponentProps = { + id: component1IdMock, + isEditMode: false, + component: component1Mock, + handleEdit: handleEditMock, + handleSave: handleSaveMock, + debounceSave: debounceSaveMock, + handleDiscard: handleDiscardMock, + ...props, + }; + + await waitForData(); + + return renderWithMockStore()( + + + , + ); +}; diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.tsx b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.tsx new file mode 100644 index 00000000000..1a0e2049a7d --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/FormComponent.tsx @@ -0,0 +1,123 @@ +import React, { memo, useState } from 'react'; +import '../../styles/index.css'; +import classes from './FormComponent.module.css'; +import cn from 'classnames'; +import type { FormComponent as IFormComponent } from '../../types/FormComponent'; +import { StudioButton } from '@studio/components'; +import type { ConnectDragSource } from 'react-dnd'; +import { DEFAULT_LANGUAGE } from 'app-shared/constants'; +import { DragHandle } from './DragHandle'; +import type { ITextResource } from 'app-shared/types/global'; +import { TrashIcon } from '@navikt/aksel-icons'; +import { formItemConfigs } from '../../data/formItemConfig'; +import { getComponentTitleByComponentType, getTextResource, truncate } from '../../utils/language'; +import { textResourcesByLanguageSelector } from '../../selectors/textResourceSelectors'; +import { useDeleteFormComponentMutation } from '../../hooks/mutations/useDeleteFormComponentMutation'; +import { useTextResourcesSelector } from '../../hooks'; +import { useTranslation } from 'react-i18next'; +import { AltinnConfirmDialog } from 'app-shared/components'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useAppContext } from '../../hooks/useAppContext'; + +export interface IFormComponentProps { + component: IFormComponent; + dragHandleRef?: ConnectDragSource; + handleDiscard: () => void; + handleEdit: (component: IFormComponent) => void; + handleSave: () => Promise; + debounceSave: (id?: string, updatedForm?: IFormComponent) => Promise; + id: string; + isEditMode: boolean; +} + +export const FormComponent = memo(function FormComponent({ + component, + dragHandleRef, + handleDiscard, + handleEdit, + handleSave, + id, + isEditMode, +}: IFormComponentProps) { + const { t } = useTranslation(); + const { org, app } = useStudioUrlParams(); + + const textResources: ITextResource[] = useTextResourcesSelector( + textResourcesByLanguageSelector(DEFAULT_LANGUAGE), + ); + const { selectedLayoutSet } = useAppContext(); + const [isConfirmDeleteDialogOpen, setIsConfirmDeleteDialogOpen] = useState(); + const Icon = formItemConfigs[component.type]?.icon; + + const { mutate: deleteFormComponent } = useDeleteFormComponentMutation( + org, + app, + selectedLayoutSet, + ); + + const handleDelete = (): void => { + deleteFormComponent(id); + if (isEditMode) handleDiscard(); + }; + + const textResource = getTextResource(component.textResourceBindings?.title, textResources); + + return ( +
) => { + event.stopPropagation(); + if (isEditMode) return; + await handleSave(); + handleEdit(component); + }} + aria-labelledby={`${id}-title`} + > +
+
+ +
+
+
+ + {Icon && ( + + )} + + + {textResource + ? truncate(textResource, 80) + : getComponentTitleByComponentType(component.type, t) || + t('ux_editor.component_unknown')} + +
+
+
+
+ setIsConfirmDeleteDialogOpen(false)} + trigger={ + } + onClick={(event: React.MouseEvent) => { + event.stopPropagation(); + setIsConfirmDeleteDialogOpen((prevState) => !prevState); + }} + tabIndex={0} + title={t('general.delete')} + variant='tertiary' + size='small' + /> + } + > +

{t('ux_editor.component_deletion_text')}

+
+
+
+ ); +}); diff --git a/frontend/packages/ux-editor-v3/src/components/FormComponent/index.ts b/frontend/packages/ux-editor-v3/src/components/FormComponent/index.ts new file mode 100644 index 00000000000..5d5f04a9c68 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormComponent/index.ts @@ -0,0 +1,2 @@ +export { FormComponent } from './FormComponent'; +export type { IFormComponentProps } from './FormComponent'; diff --git a/frontend/packages/ux-editor-v3/src/components/FormField.tsx b/frontend/packages/ux-editor-v3/src/components/FormField.tsx new file mode 100644 index 00000000000..2b04fb062e8 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/FormField.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import type { FormFieldProps } from 'app-shared/components/FormField'; +import { FormField as FF } from 'app-shared/components/FormField'; +import { useLayoutSchemaQuery } from '../hooks/queries/useLayoutSchemaQuery'; + +export const FormField = ( + props: FormFieldProps, +): JSX.Element => { + const [{ data: layoutSchema }] = useLayoutSchemaQuery(); + return ; +}; diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/Preview.module.css b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.module.css new file mode 100644 index 00000000000..8f7156dbccd --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.module.css @@ -0,0 +1,43 @@ +.root { + display: flex; + flex-direction: column; + flex: var(--preview-width-fraction); +} + +.openPreviewButton { + writing-mode: vertical-lr; + text-transform: uppercase; + border-radius: 0; +} + +.closePreviewButton { + position: absolute; +} + +.iframeContainer { + display: flex; + justify-content: center; + flex: 1; + background-color: var(--fds-semantic-surface-neutral-dark); +} + +.iframe { + border: 0; + margin: 0 auto; +} + +.previewArea { + display: flex; + flex-direction: column; + height: 100%; +} + +.iframe.mobile { + --phone-width: 390px; + width: var(--phone-width); +} + +.iframe.desktop { + width: 100%; + height: 100%; +} diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/Preview.test.tsx b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.test.tsx new file mode 100644 index 00000000000..4479b818a97 --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.test.tsx @@ -0,0 +1,74 @@ +import React, { createRef } from 'react'; +import { Preview } from './Preview'; +import { act, screen } from '@testing-library/react'; +import { queryClientMock } from 'app-shared/mocks/queryClientMock'; +import { renderWithMockStore } from '../../testing/mocks'; +import type { IAppState } from '../../types/global'; +import { textMock } from '../../../../../testing/mocks/i18nMock'; +import userEvent from '@testing-library/user-event'; + +describe('Preview', () => { + it('Renders an iframe with the ref from AppContext', () => { + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + expect(screen.getByTitle(textMock('ux_editor.preview'))).toBe(previewIframeRef.current); + }); + + it('should be able to toggle between mobile and desktop view', async () => { + const user = userEvent.setup(); + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const switchButton = screen.getByRole('checkbox', { + name: textMock('ux_editor.mobilePreview'), + }); + + expect(switchButton).not.toBeChecked(); + + await act(() => user.click(switchButton)); + expect(switchButton).toBeChecked(); + }); + + it('should render a message when no page is selected', () => { + const mockedLayout = { layout: { selectedLayout: undefined } } as IAppState['formDesigner']; + renderWithMockStore({ formDesigner: mockedLayout }, {}, queryClientMock)(); + expect(screen.getByText(textMock('ux_editor.no_components_selected'))).toBeInTheDocument(); + }); + + it('Renders the information alert with preview being limited', () => { + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const previewLimitationsAlert = screen.getByText(textMock('preview.limitations_info')); + expect(previewLimitationsAlert).toBeInTheDocument(); + }); + + it('should not display open preview button if preview is open', () => { + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const showPreviewButton = screen.queryByRole('button', { + name: textMock('ux_editor.open_preview'), + }); + + expect(showPreviewButton).not.toBeInTheDocument(); + }); + + it('should be possible to toggle preview window', async () => { + const user = userEvent.setup(); + const previewIframeRef = createRef(); + renderWithMockStore({}, {}, queryClientMock, { previewIframeRef })(); + + const hidePreviewButton = screen.getByRole('button', { + name: textMock('ux_editor.close_preview'), + }); + await act(() => user.click(hidePreviewButton)); + expect(hidePreviewButton).not.toBeInTheDocument(); + + const showPreviewButton = screen.getByRole('button', { + name: textMock('ux_editor.open_preview'), + }); + await act(() => user.click(showPreviewButton)); + expect(showPreviewButton).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/packages/ux-editor-v3/src/components/Preview/Preview.tsx b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.tsx new file mode 100644 index 00000000000..00037c9671d --- /dev/null +++ b/frontend/packages/ux-editor-v3/src/components/Preview/Preview.tsx @@ -0,0 +1,90 @@ +import React, { useState } from 'react'; +import classes from './Preview.module.css'; +import { useStudioUrlParams } from 'app-shared/hooks/useStudioUrlParams'; +import { useSelector } from 'react-redux'; +import cn from 'classnames'; +import { selectedLayoutNameSelector } from '../../selectors/formLayoutSelectors'; +import { useTranslation } from 'react-i18next'; +import { useAppContext } from '../../hooks/useAppContext'; +import { useUpdate } from 'app-shared/hooks/useUpdate'; +import { previewPage } from 'app-shared/api/paths'; +import { Paragraph } from '@digdir/design-system-react'; +import { StudioButton, StudioCenter } from '@studio/components'; +import type { SupportedView } from './ViewToggler/ViewToggler'; +import { ViewToggler } from './ViewToggler/ViewToggler'; +import { ArrowRightIcon } from '@studio/icons'; +import { PreviewLimitationsInfo } from 'app-shared/components/PreviewLimitationsInfo/PreviewLimitationsInfo'; + +export const Preview = () => { + const { t } = useTranslation(); + const [isPreviewHidden, setIsPreviewHidden] = useState(false); + const layoutName = useSelector(selectedLayoutNameSelector); + const noPageSelected = layoutName === 'default' || layoutName === undefined; + + const togglePreview = (): void => { + setIsPreviewHidden((prev: boolean) => !prev); + }; + + return isPreviewHidden ? ( + + {t('ux_editor.open_preview')} + + ) : ( +
+ } + title={t('ux_editor.close_preview')} + className={classes.closePreviewButton} + onClick={togglePreview} + /> + {noPageSelected ? : } +
+ ); +}; + +// Message to display when no page is selected +const NoSelectedPageMessage = () => { + const { t } = useTranslation(); + return ( + + {t('ux_editor.no_components_selected')} + + ); +}; + +// The actual preview frame that displays the selected page +const PreviewFrame = () => { + const { org, app } = useStudioUrlParams(); + const [viewportToSimulate, setViewportToSimulate] = useState('desktop'); + const { selectedLayoutSet } = useAppContext(); + const { t } = useTranslation(); + const { previewIframeRef } = useAppContext(); + const layoutName = useSelector(selectedLayoutNameSelector); + + useUpdate(() => { + previewIframeRef.current?.contentWindow?.location.reload(); + }, [layoutName, previewIframeRef]); + + return ( +
+ +
+
+