From 59e28430acf79b39ed9c5e8749503fff3e547a5e Mon Sep 17 00:00:00 2001 From: William Thorenfeldt <48119543+wrt95@users.noreply.github.com> Date: Fri, 4 Oct 2024 15:53:02 +0200 Subject: [PATCH] feat: add unique header to app preview (#13626) Co-authored-by: Michael --- frontend/app-preview/App.css | 3 + frontend/app-preview/index.tsx | 1 + .../app-preview/src/PreviewApp.module.css | 4 - .../AppBarConfig/AppPreviewBarConfig.test.tsx | 14 -- .../AppBarConfig/AppPreviewBarConfig.tsx | 85 ---------- .../src/components/AppPreviewSubMenu.test.tsx | 60 -------- .../src/components/AppPreviewSubMenu.tsx | 39 ----- .../AppPreviewSubMenu.module.css | 10 ++ .../AppPreviewSubMenu.test.tsx | 43 ++++++ .../AppPreviewSubMenu/AppPreviewSubMenu.tsx | 24 +++ .../src/components/AppPreviewSubMenu/index.ts | 1 + .../PreviewControlHeader.module.css} | 23 +-- .../PreviewControlHeader.test.tsx | 145 ++++++++++++++++++ .../PreviewControlHeader.tsx | 55 +++++++ .../components/PreviewControlHeader/index.ts | 1 + .../UserProfileMenu/UserProfileMenu.test.tsx | 79 ++++++++++ .../UserProfileMenu/UserProfileMenu.tsx | 59 +++++++ .../src/components/UserProfileMenu/index.ts | 1 + .../src/hooks/useBackToEditingHref/index.ts | 1 + .../useBackToEditingHref.test.tsx | 31 ++++ .../useBackToEditingHref.tsx | 14 ++ .../src/views/LandingPage.module.css | 11 -- .../src/views/LandingPage.test.tsx | 103 +++++++++---- .../app-preview/src/views/LandingPage.tsx | 58 ++++--- frontend/app-preview/test/mocks.tsx | 66 ++++++++ frontend/app-preview/test/user.ts | 10 ++ frontend/language/src/nb.json | 4 +- .../src/hooks/useLocalStorage.test.ts | 2 +- .../src/hooks/webStorage.test.ts | 4 +- .../studio-components/src/hooks/webStorage.ts | 2 +- .../src/utils/featureToggleUtils.test.ts | 2 +- .../ux-editor/src/utils/localStorage.test.ts | 2 +- 32 files changed, 657 insertions(+), 300 deletions(-) create mode 100644 frontend/app-preview/App.css delete mode 100644 frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.test.tsx delete mode 100644 frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx delete mode 100644 frontend/app-preview/src/components/AppPreviewSubMenu.test.tsx delete mode 100644 frontend/app-preview/src/components/AppPreviewSubMenu.tsx create mode 100644 frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.module.css create mode 100644 frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.test.tsx create mode 100644 frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.tsx create mode 100644 frontend/app-preview/src/components/AppPreviewSubMenu/index.ts rename frontend/app-preview/src/components/{AppPreviewSubMenu.module.css => PreviewControlHeader/PreviewControlHeader.module.css} (80%) create mode 100644 frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.test.tsx create mode 100644 frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.tsx create mode 100644 frontend/app-preview/src/components/PreviewControlHeader/index.ts create mode 100644 frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.test.tsx create mode 100644 frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.tsx create mode 100644 frontend/app-preview/src/components/UserProfileMenu/index.ts create mode 100644 frontend/app-preview/src/hooks/useBackToEditingHref/index.ts create mode 100644 frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.test.tsx create mode 100644 frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.tsx create mode 100644 frontend/app-preview/test/mocks.tsx create mode 100644 frontend/app-preview/test/user.ts diff --git a/frontend/app-preview/App.css b/frontend/app-preview/App.css new file mode 100644 index 00000000000..b894a9b85f7 --- /dev/null +++ b/frontend/app-preview/App.css @@ -0,0 +1,3 @@ +:root { + font-family: Roboto, 'San Fransisco', 'Helvetica Neue', Helvetica, Arial, sans-serif; +} diff --git a/frontend/app-preview/index.tsx b/frontend/app-preview/index.tsx index 7a8ebe2bd70..6886a91ffa6 100644 --- a/frontend/app-preview/index.tsx +++ b/frontend/app-preview/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import './App.css'; import { createRoot } from 'react-dom/client'; import { PreviewApp } from './src/PreviewApp'; import { BrowserRouter } from 'react-router-dom'; diff --git a/frontend/app-preview/src/PreviewApp.module.css b/frontend/app-preview/src/PreviewApp.module.css index 8b55867df82..225e9cb3e4f 100644 --- a/frontend/app-preview/src/PreviewApp.module.css +++ b/frontend/app-preview/src/PreviewApp.module.css @@ -1,7 +1,3 @@ -:root { - --font-family: 'Inter', sans-serif; -} - .previewContainer { --toolbar-height: 80px; --subtoolbar-height: 60px; diff --git a/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.test.tsx b/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.test.tsx deleted file mode 100644 index 5dda4c456c4..00000000000 --- a/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.test.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { SubPreviewMenuRightContent } from './AppPreviewBarConfig'; -import React from 'react'; -import { render, screen } from '@testing-library/react'; - -describe('AppPreviewBarConfig', () => { - it('should render all buttons on right side', () => { - render(); - - expect(screen.getByRole('button', { name: textMock('preview.subheader.restart') })); - expect(screen.getByRole('button', { name: textMock('preview.subheader.showas') })); - expect(screen.getByRole('button', { name: textMock('preview.subheader.sharelink') })); - }); -}); diff --git a/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx b/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx deleted file mode 100644 index 2c8d7af2165..00000000000 --- a/frontend/app-preview/src/components/AppBarConfig/AppPreviewBarConfig.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import type { AltinnButtonActionItem } from 'app-shared/components/altinnHeader/types'; -import classes from '../AppPreviewSubMenu.module.css'; -import { ArrowCirclepathIcon, EyeIcon, LinkIcon } from '@studio/icons'; -import { useTranslation } from 'react-i18next'; -import type { AppPreviewSubMenuProps } from '../AppPreviewSubMenu'; -import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; -import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; -import { TopBarMenu } from 'app-shared/enums/TopBarMenu'; -import { PackagesRouter } from 'app-shared/navigation/PackagesRouter'; -import { StudioButton, StudioNativeSelect } from '@studio/components'; -import { ToggleGroup } from '@digdir/designsystemet-react'; - -export const SubPreviewMenuLeftContent = ({ - viewSize, - setViewSize, - selectedLayoutSet, - handleChangeLayoutSet, -}: AppPreviewSubMenuProps) => { - const { t } = useTranslation(); - const { org, app } = useStudioEnvironmentParams(); - const { data: layoutSets } = useLayoutSetsQuery(org, app); - - return ( -
-
- - {t('preview.view_size_desktop')} - {t('preview.view_size_mobile')} - -
- {layoutSets && ( -
- handleChangeLayoutSet(layoutSet)} - value={selectedLayoutSet} - > - {layoutSets.sets.map((layoutSet) => ( - - ))} - -
- )} -
- ); -}; - -export const SubPreviewMenuRightContent = () => { - const { t } = useTranslation(); - return ( -
- } variant='tertiary' color='inverted'> - {t('preview.subheader.restart')} - - } variant='tertiary' color='inverted'> - {t('preview.subheader.showas')} - - } variant='tertiary' color='inverted'> - {t('preview.subheader.sharelink')} - -
- ); -}; - -export const appPreviewButtonActions = ( - org: string, - app: string, - instanceId: string, -): AltinnButtonActionItem[] => { - const packagesRouter = new PackagesRouter({ org, app }); - const queryParams = `?layout=${window.localStorage.getItem(instanceId)}`; - - return [ - { - menuKey: TopBarMenu.PreviewBackToEditing, - to: `${packagesRouter.getPackageNavigationUrl('editorUiEditor')}${queryParams}`, - }, - ]; -}; diff --git a/frontend/app-preview/src/components/AppPreviewSubMenu.test.tsx b/frontend/app-preview/src/components/AppPreviewSubMenu.test.tsx deleted file mode 100644 index 4d9a38496f7..00000000000 --- a/frontend/app-preview/src/components/AppPreviewSubMenu.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { queryClientMock } from 'app-shared/mocks/queryClientMock'; -import { renderWithProviders } from '@altinn/ux-editor/testing/mocks'; -import { layoutSet1NameMock, layoutSetsMock } from '@altinn/ux-editor/testing/layoutSetsMock'; -import type { AppPreviewSubMenuProps } from './AppPreviewSubMenu'; -import { AppPreviewSubMenu } from './AppPreviewSubMenu'; -import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; -import { screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { QueryKey } from 'app-shared/types/QueryKey'; -import { app, org } from '@studio/testing/testids'; -import { textMock } from '@studio/testing/mocks/i18nMock'; - -const user = userEvent.setup(); - -describe('AppPreviewSubMenu', () => { - afterEach(jest.clearAllMocks); - - const props: AppPreviewSubMenuProps = { - viewSize: 'desktop', - setViewSize: jest.fn(), - selectedLayoutSet: layoutSet1NameMock, - handleChangeLayoutSet: jest.fn(), - }; - - it('renders the component with desktop viewSize', () => { - setQueryData(null); - renderWithProviders(); - const desktopButton = screen.getByRole('radio', { - name: textMock('preview.view_size_desktop'), - }); - const mobileButton = screen.getByRole('radio', { name: textMock('preview.view_size_mobile') }); - expect(desktopButton).toHaveAttribute('aria-checked', 'true'); - expect(mobileButton).toHaveAttribute('aria-checked', 'false'); - }); - - it('renders the component with mobile viewSize', () => { - setQueryData(null); - renderWithProviders(); - const desktopButton = screen.getByRole('radio', { - name: textMock('preview.view_size_desktop'), - }); - const mobileButton = screen.getByRole('radio', { name: textMock('preview.view_size_mobile') }); - expect(mobileButton).toHaveAttribute('aria-checked', 'true'); - expect(desktopButton).toHaveAttribute('aria-checked', 'false'); - }); - - it('renders the component with layout sets in select list', async () => { - setQueryData(layoutSetsMock); - renderWithProviders(); - const layoutSetSelector = screen.getByRole('combobox'); - await user.click(layoutSetSelector); - const options = screen.getAllByRole('option'); - expect(options.length).toBe(layoutSetsMock.sets.length); - }); -}); - -const setQueryData = (layoutSets: LayoutSets | null) => { - queryClientMock.setQueryData([QueryKey.LayoutSets, org, app], layoutSets); -}; diff --git a/frontend/app-preview/src/components/AppPreviewSubMenu.tsx b/frontend/app-preview/src/components/AppPreviewSubMenu.tsx deleted file mode 100644 index d769914db5f..00000000000 --- a/frontend/app-preview/src/components/AppPreviewSubMenu.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { - SubPreviewMenuLeftContent, - SubPreviewMenuRightContent, -} from './AppBarConfig/AppPreviewBarConfig'; -import classes from './AppPreviewSubMenu.module.css'; -import { _useIsProdHack } from 'app-shared/utils/_useIsProdHack'; - -export interface AppPreviewSubMenuProps { - viewSize: 'desktop' | 'mobile'; - setViewSize: (value: any) => void; - selectedLayoutSet: string | null; - handleChangeLayoutSet: (value: any) => void; -} - -export const AppPreviewSubMenu = ({ - viewSize, - setViewSize, - selectedLayoutSet, - handleChangeLayoutSet, -}: AppPreviewSubMenuProps) => { - return ( - <> -
- -
- {!_useIsProdHack() && ( -
- -
- )} - - ); -}; diff --git a/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.module.css b/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.module.css new file mode 100644 index 00000000000..7fb00e2c753 --- /dev/null +++ b/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.module.css @@ -0,0 +1,10 @@ +.subHeader { + display: flex; + align-items: center; + height: var(--subtoolbar-height); + padding-inline: var(--fds-spacing-4); +} + +.icon { + font-size: var(--fds-sizing-6); +} diff --git a/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.test.tsx b/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.test.tsx new file mode 100644 index 00000000000..463a28ed202 --- /dev/null +++ b/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { AppPreviewSubMenu } from './AppPreviewSubMenu'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { useMediaQuery } from '@studio/components/src/hooks/useMediaQuery'; +import { renderWithProviders } from 'app-preview/test/mocks'; + +jest.mock('@studio/components/src/hooks/useMediaQuery'); + +describe('AppPreviewSubMenu', () => { + afterEach(() => jest.clearAllMocks()); + + it('should render the back-to-editing link with text on large screens', () => { + (useMediaQuery as jest.Mock).mockReturnValue(false); + + renderAppPreviewSubMenu(); + + expect(screen.getByText(textMock('top_menu.preview_back_to_editing'))).toBeInTheDocument(); + }); + + it('should render the back-to-editing link without text on small screens', () => { + (useMediaQuery as jest.Mock).mockReturnValue(true); + + renderAppPreviewSubMenu(); + + expect( + screen.queryByText(textMock('top_menu.preview_back_to_editing')), + ).not.toBeInTheDocument(); + }); + + it('should have the correct aria-label set on the link', () => { + (useMediaQuery as jest.Mock).mockReturnValue(true); + + renderAppPreviewSubMenu(); + + const link = screen.getByRole('link', { name: textMock('top_menu.preview_back_to_editing') }); + expect(link).toHaveAttribute('aria-label', textMock('top_menu.preview_back_to_editing')); + }); +}); + +const renderAppPreviewSubMenu = () => { + return renderWithProviders()(); +}; diff --git a/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.tsx b/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.tsx new file mode 100644 index 00000000000..6d3fb5218e6 --- /dev/null +++ b/frontend/app-preview/src/components/AppPreviewSubMenu/AppPreviewSubMenu.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import classes from './AppPreviewSubMenu.module.css'; +import { useTranslation } from 'react-i18next'; +import { StudioPageHeader, useMediaQuery } from '@studio/components'; +import { ArrowLeftIcon } from '@studio/icons'; +import { MEDIA_QUERY_MAX_WIDTH } from 'app-shared/constants'; +import { useBackToEditingHref } from 'app-preview/src/hooks/useBackToEditingHref'; + +export const AppPreviewSubMenu = () => { + const { t } = useTranslation(); + const shouldDisplayText = !useMediaQuery(MEDIA_QUERY_MAX_WIDTH); + const backToEditingHref: string = useBackToEditingHref(); + + return ( + + ); +}; diff --git a/frontend/app-preview/src/components/AppPreviewSubMenu/index.ts b/frontend/app-preview/src/components/AppPreviewSubMenu/index.ts new file mode 100644 index 00000000000..864dda249ce --- /dev/null +++ b/frontend/app-preview/src/components/AppPreviewSubMenu/index.ts @@ -0,0 +1 @@ +export { AppPreviewSubMenu } from './AppPreviewSubMenu'; diff --git a/frontend/app-preview/src/components/AppPreviewSubMenu.module.css b/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.module.css similarity index 80% rename from frontend/app-preview/src/components/AppPreviewSubMenu.module.css rename to frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.module.css index 8d8fe88c780..f8ed00c9fbe 100644 --- a/frontend/app-preview/src/components/AppPreviewSubMenu.module.css +++ b/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.module.css @@ -1,27 +1,8 @@ -.elements svg { - color: #fff; -} - -.properties { - display: flex; - align-items: center; - gap: 1rem; -} - -.subHeader { - color: white; - border-bottom: 1px solid gray; - background-color: #0c6536; - display: flex; - align-items: center; - height: var(--subtoolbar-height); - margin-top: 80px; -} - -.leftSubHeaderComponents { +.wrapper { display: flex; justify-content: center; align-items: center; + padding-top: var(--fds-spacing-4); } .viewSizeButtons { diff --git a/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.test.tsx b/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.test.tsx new file mode 100644 index 00000000000..1ffd699e473 --- /dev/null +++ b/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.test.tsx @@ -0,0 +1,145 @@ +import React from 'react'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; +import { PreviewControlHeader, type PreviewControlHeaderProps } from './PreviewControlHeader'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { renderWithProviders } from 'app-preview/test/mocks'; +import { app, org } from '@studio/testing/testids'; +import userEvent from '@testing-library/user-event'; +import { type LayoutSets } from 'app-shared/types/api/LayoutSetsResponse'; +import { type ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { type QueryClient } from '@tanstack/react-query'; +import { QueryKey } from 'app-shared/types/QueryKey'; +import { useInstanceIdQuery } from 'app-shared/hooks/queries'; + +// Move +jest.mock('app-shared/hooks/queries'); + +// Move +export const mockLayoutId: string = 'layout1'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + org, + app, + }), +})); + +const mockSetViewSize = jest.fn(); +const mockHandleChangeLayoutSet = jest.fn(); + +export const layoutSet1NameMock = 'test-layout-set'; +export const layoutSet2NameMock = 'test-layout-set-2'; + +export const layoutSetsMock: LayoutSets = { + sets: [ + { + id: layoutSet1NameMock, + dataType: 'data-model', + tasks: ['Task_1'], + }, + { + id: layoutSet2NameMock, + dataType: 'data-model-2', + tasks: ['Task_2'], + }, + ], +}; + +const defaultProps: PreviewControlHeaderProps = { + viewSize: 'desktop', + setViewSize: mockSetViewSize, + selectedLayoutSet: 'layoutSet1', + handleChangeLayoutSet: mockHandleChangeLayoutSet, +}; + +describe('PreviewControlHeader', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the spinner initially loading the component', () => { + renderPreviewControlHeader(); + expect(screen.getByTitle(textMock('preview.loading_preview_controller'))).toBeInTheDocument(); + }); + + it('should render the toggle buttons with the correct initial state', async () => { + renderPreviewControlHeader(); + + await waitForElementToBeRemoved( + screen.queryByTitle(textMock('preview.loading_preview_controller')), + ); + + expect( + screen.getByRole('radio', { name: textMock('preview.view_size_desktop') }), + ).toBeChecked(); + expect( + screen.getByRole('radio', { name: textMock('preview.view_size_mobile') }), + ).not.toBeChecked(); + }); + + it('should call setViewSize with "mobile" when the mobile button is clicked', async () => { + const user = userEvent.setup(); + renderPreviewControlHeader(); + + await waitForElementToBeRemoved( + screen.queryByTitle(textMock('preview.loading_preview_controller')), + ); + + const mobileButton = screen.getByRole('radio', { name: textMock('preview.view_size_mobile') }); + await user.click(mobileButton); + + expect(mockSetViewSize).toHaveBeenCalledWith('mobile'); + }); + + it('should render the layout sets in the select dropdown', () => { + const queryClient: QueryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock); + renderPreviewControlHeader({ queryClient }); + + const select = screen.getByRole('combobox'); + const options = screen.getAllByRole('option'); + + expect(select).toHaveValue(layoutSetsMock.sets[0].id); + expect(options).toHaveLength(2); + expect(options[0]).toHaveTextContent(layoutSetsMock.sets[0].id); + expect(options[1]).toHaveTextContent(layoutSetsMock.sets[1].id); + }); + + it('should call handleChangeLayoutSet when a new layout set is selected', async () => { + const user = userEvent.setup(); + + const queryClient: QueryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock); + renderPreviewControlHeader({ queryClient }); + + const select = screen.getByRole('combobox'); + await user.selectOptions(select, layoutSetsMock.sets[1].id); + + expect(mockHandleChangeLayoutSet).toHaveBeenCalledWith(layoutSetsMock.sets[1].id); + expect(mockHandleChangeLayoutSet).toHaveBeenCalledTimes(1); + }); + + it('should not render the layout sets dropdown if layoutSets is not available', () => { + (useInstanceIdQuery as jest.Mock).mockReturnValue(mockLayoutId); + renderPreviewControlHeader(); + + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); +}); + +type Props = { + componentProps: Partial; + queries?: Partial; + queryClient?: QueryClient; +}; + +const renderPreviewControlHeader = (props: Partial = {}) => { + const { componentProps, queries, queryClient } = props; + + return renderWithProviders( + queries, + queryClient, + )(); +}; diff --git a/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.tsx b/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.tsx new file mode 100644 index 00000000000..93f709d63a9 --- /dev/null +++ b/frontend/app-preview/src/components/PreviewControlHeader/PreviewControlHeader.tsx @@ -0,0 +1,55 @@ +import React, { type ReactElement, type ChangeEvent } from 'react'; +import classes from './PreviewControlHeader.module.css'; +import { useTranslation } from 'react-i18next'; +import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { StudioNativeSelect, StudioSpinner } from '@studio/components'; +import { ToggleGroup } from '@digdir/designsystemet-react'; + +export type PreviewControlHeaderProps = { + viewSize: 'desktop' | 'mobile'; + setViewSize: (value: any) => void; + selectedLayoutSet: string | null; + handleChangeLayoutSet: (value: string) => void; +}; + +export const PreviewControlHeader = ({ + viewSize, + setViewSize, + selectedLayoutSet, + handleChangeLayoutSet, +}: PreviewControlHeaderProps): ReactElement => { + const { t } = useTranslation(); + const { org, app } = useStudioEnvironmentParams(); + const { data: layoutSets, isPending: loadingLayoutSets } = useLayoutSetsQuery(org, app); + + const handleLayoutSetChange = (event: ChangeEvent) => { + handleChangeLayoutSet(event.target.value); + }; + + if (loadingLayoutSets) { + return ; + } + + return ( +
+
+ + {t('preview.view_size_desktop')} + {t('preview.view_size_mobile')} + +
+ {layoutSets && ( +
+ + {layoutSets.sets.map((layoutSet) => ( + + ))} + +
+ )} +
+ ); +}; diff --git a/frontend/app-preview/src/components/PreviewControlHeader/index.ts b/frontend/app-preview/src/components/PreviewControlHeader/index.ts new file mode 100644 index 00000000000..8d7e3db95cb --- /dev/null +++ b/frontend/app-preview/src/components/PreviewControlHeader/index.ts @@ -0,0 +1 @@ +export { PreviewControlHeader } from './PreviewControlHeader'; diff --git a/frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.test.tsx b/frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.test.tsx new file mode 100644 index 00000000000..df95beb6964 --- /dev/null +++ b/frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.test.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { screen } from '@testing-library/react'; +import { UserProfileMenu, type UserProfileMenuProps } from './UserProfileMenu'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { useMediaQuery } from '@studio/components'; +import { type Repository, type User } from 'app-shared/types/Repository'; +import { app, org } from '@studio/testing/testids'; +import { repository } from 'app-shared/mocks/mocks'; +import { renderWithProviders } from 'app-preview/test/mocks'; + +jest.mock('@studio/components/src/hooks/useMediaQuery'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + org, + app, + }), +})); + +const userMock: User = { + id: 1, + avatar_url: 'url', + email: 'tester@tester.test', + full_name: 'Tester Testersen', + login: 'tester', + userType: 0, +}; + +const repositoryMock: Repository = { + ...repository, + name: 'test-repo', + full_name: 'org/test-repo', +}; + +const defaultProps: UserProfileMenuProps = { + user: userMock, + repository: repositoryMock, +}; + +describe('UserProfileMenu', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render trigger button text when on a large screen', () => { + (useMediaQuery as jest.Mock).mockReturnValue(false); + + renderUserProfileMenu(); + + expect( + screen.getByTitle( + textMock('shared.header_user_for_org', { user: userMock.full_name, org: '' }), + ), + ).toBeInTheDocument(); + }); + + it('should not render trigger button text when on a small screen', () => { + (useMediaQuery as jest.Mock).mockReturnValue(true); + + renderUserProfileMenu(); + + expect( + screen.queryByTitle( + textMock('shared.header_user_for_org', { user: userMock.full_name, org: '' }), + ), + ).not.toBeInTheDocument(); + }); + + it('should render the user avatar with correct alt text', () => { + renderUserProfileMenu(); + + expect(screen.getByAltText(textMock('general.profile_icon'))).toBeInTheDocument(); + }); +}); + +const renderUserProfileMenu = (props?: Partial) => { + return renderWithProviders()(); +}; diff --git a/frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.tsx b/frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.tsx new file mode 100644 index 00000000000..fac14ca2dec --- /dev/null +++ b/frontend/app-preview/src/components/UserProfileMenu/UserProfileMenu.tsx @@ -0,0 +1,59 @@ +import React, { type ReactElement } from 'react'; +import { type Repository, type User } from 'app-shared/types/Repository'; +import { useTranslation } from 'react-i18next'; +import { useUserNameAndOrg } from 'app-shared/components/AltinnHeaderProfile/hooks/useUserNameAndOrg'; +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { + useMediaQuery, + StudioAvatar, + StudioPageHeader, + type StudioProfileMenuItem, + type StudioProfileMenuGroup, +} from '@studio/components'; +import { MEDIA_QUERY_MAX_WIDTH } from 'app-shared/constants'; +import { useLogoutMutation } from 'app-shared/hooks/mutations/useLogoutMutation'; +import { altinnDocsUrl } from 'app-shared/ext-urls'; + +export type UserProfileMenuProps = { + user: User; + repository: Repository; +}; + +export const UserProfileMenu = ({ user, repository }: UserProfileMenuProps): ReactElement => { + const { t } = useTranslation(); + const { org } = useStudioEnvironmentParams(); + const userNameAndOrg = useUserNameAndOrg(user, org, repository); + const shouldDisplayText = !useMediaQuery(MEDIA_QUERY_MAX_WIDTH); + const { mutate: logout } = useLogoutMutation(); + + const docsMenuItem: StudioProfileMenuItem = { + action: { type: 'link', href: altinnDocsUrl('') }, + itemName: t('sync_header.documentation'), + }; + const logOutMenuItem: StudioProfileMenuItem = { + action: { type: 'button', onClick: logout }, + itemName: t('shared.header_logout'), + }; + + const profileMenuGroups: StudioProfileMenuGroup[] = [ + { items: [docsMenuItem] }, + { items: [logOutMenuItem] }, + ]; + + return ( + + } + profileMenuGroups={profileMenuGroups} + color='light' + variant='preview' + /> + ); +}; diff --git a/frontend/app-preview/src/components/UserProfileMenu/index.ts b/frontend/app-preview/src/components/UserProfileMenu/index.ts new file mode 100644 index 00000000000..9076b98609a --- /dev/null +++ b/frontend/app-preview/src/components/UserProfileMenu/index.ts @@ -0,0 +1 @@ +export { UserProfileMenu } from './UserProfileMenu'; diff --git a/frontend/app-preview/src/hooks/useBackToEditingHref/index.ts b/frontend/app-preview/src/hooks/useBackToEditingHref/index.ts new file mode 100644 index 00000000000..734bef279bd --- /dev/null +++ b/frontend/app-preview/src/hooks/useBackToEditingHref/index.ts @@ -0,0 +1 @@ +export { useBackToEditingHref } from './useBackToEditingHref'; diff --git a/frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.test.tsx b/frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.test.tsx new file mode 100644 index 00000000000..751271bbe02 --- /dev/null +++ b/frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.test.tsx @@ -0,0 +1,31 @@ +import { useBackToEditingHref } from './useBackToEditingHref'; +import { typedLocalStorage } from '@studio/components'; +import { renderHookWithProviders } from 'app-preview/test/mocks'; +import { app, org } from '@studio/testing/testids'; +import { RoutePaths } from 'app-development/enums/RoutePaths'; + +const mockLayoutId: string = 'layout1'; +const mockUiEditorPath: string = `/editor/${org}/${app}/${RoutePaths.UIEditor}?layout=${mockLayoutId}`; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + org, + app, + }), +})); + +const renderUseBackToEditingHrefHook = () => renderHookWithProviders(useBackToEditingHref); + +describe('useBackToEditingHref', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the correct URL with instanceId in the query parameters', () => { + jest.spyOn(typedLocalStorage, 'getItem').mockReturnValue(mockLayoutId); + const { result } = renderUseBackToEditingHrefHook(); + + expect(result.current).toBe(mockUiEditorPath); + }); +}); diff --git a/frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.tsx b/frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.tsx new file mode 100644 index 00000000000..88ea853af60 --- /dev/null +++ b/frontend/app-preview/src/hooks/useBackToEditingHref/useBackToEditingHref.tsx @@ -0,0 +1,14 @@ +import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; +import { PackagesRouter } from 'app-shared/navigation/PackagesRouter'; +import { typedLocalStorage } from '@studio/components'; +import { useInstanceIdQuery } from 'app-shared/hooks/queries'; + +export const useBackToEditingHref = () => { + const { org, app } = useStudioEnvironmentParams(); + const { data: instanceId } = useInstanceIdQuery(org, app); + + const packagesRouter = new PackagesRouter({ org, app }); + const queryParams: string = `?layout=${typedLocalStorage.getItem(instanceId)}`; + + return `${packagesRouter.getPackageNavigationUrl('editorUiEditor')}${queryParams}`; +}; diff --git a/frontend/app-preview/src/views/LandingPage.module.css b/frontend/app-preview/src/views/LandingPage.module.css index cc892c76d34..908f6291ac0 100644 --- a/frontend/app-preview/src/views/LandingPage.module.css +++ b/frontend/app-preview/src/views/LandingPage.module.css @@ -1,14 +1,3 @@ -.header { - font-family: var(--font-family); - align-items: center; - height: var(--header-height); - width: 100vw; - --component-button-outline-primary-color-background-default: #efefef; - --component-button-outline-primary-color-border-default: #1e2b3c; - --component-button-outline-primary-color-border-hover: none; - --component-button-outline-primary-color-text-default: #1e2b3c !important; -} - .iframeDesktop { height: 100%; width: 100%; diff --git a/frontend/app-preview/src/views/LandingPage.test.tsx b/frontend/app-preview/src/views/LandingPage.test.tsx index 09075fbb839..dca0563d168 100644 --- a/frontend/app-preview/src/views/LandingPage.test.tsx +++ b/frontend/app-preview/src/views/LandingPage.test.tsx @@ -1,46 +1,93 @@ import React from 'react'; -import { screen, queryByAttribute, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { screen, waitForElementToBeRemoved } from '@testing-library/react'; import { LandingPage } from './LandingPage'; -import { renderWithProviders } from '@altinn/ux-editor/testing/mocks'; import { textMock } from '@studio/testing/mocks/i18nMock'; +import { userEvent } from '@testing-library/user-event'; +import { useMediaQuery } from '@studio/components/src/hooks/useMediaQuery'; +import { renderWithProviders } from 'app-development/test/mocks'; +import { app, org } from '@studio/testing/testids'; +import { type ServicesContextProps } from 'app-shared/contexts/ServicesContext'; +import { userMock } from 'app-development/test/userMock'; + +jest.mock('@studio/components/src/hooks/useMediaQuery'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + org, + app, + }), +})); + +const mockGetItem = jest.fn(); + +Object.defineProperty(window, 'localStorage', { + value: { + getItem: (...args: string[]) => mockGetItem(...args), + }, +}); describe('LandingPage', () => { - it('should render an iframe', () => { - const { container } = renderWithProviders(); + afterEach(() => jest.clearAllMocks()); - const getById = queryByAttribute.bind(null, 'id'); + it('should display a spinner initially when loading user', () => { + renderLandingPage(); - const iframe = getById(container, 'app-frontend-react-iframe'); - expect(iframe).toBeInTheDocument(); + expect(screen.getByTitle(textMock('preview.loading_page'))).toBeInTheDocument(); }); - // Fix this test when mock data is fixed, due to issue: #11692 - it.skip('should render the information alert with preview being limited', () => { - renderWithProviders(); + it('should render the app title if on a large screen', async () => { + (useMediaQuery as jest.Mock).mockReturnValue(false); + renderLandingPage(); - const previewLimitationsAlert = screen.getByText(textMock('preview.limitations_info')); - expect(previewLimitationsAlert).toBeInTheDocument(); + await waitForElementToBeRemoved(screen.queryByTitle(textMock('preview.loading_page'))); + + expect(screen.getByText('testApp')).toBeInTheDocument(); }); - it('should render a popover with options for remembering closing-choice in session or not when clicking cross-button in alert', async () => { - renderWithProviders(); + it('should not render the app title if on a small screen', async () => { + (useMediaQuery as jest.Mock).mockReturnValue(true); + renderLandingPage(); + + await waitForElementToBeRemoved(screen.queryByTitle(textMock('preview.loading_page'))); + + expect(screen.queryByText('testApp')).not.toBeInTheDocument(); + }); + it('should display the user profile menu', async () => { const user = userEvent.setup(); - const previewLimitationsAlert = screen.getByText(textMock('preview.limitations_info')); - const alert = within(previewLimitationsAlert); - const hidePreviewLimitationsAlertButton = alert.getByRole('button'); - await user.click(hidePreviewLimitationsAlertButton); - const hidePreviewLimitationsPopover = screen.getByText(textMock('session.reminder')); - expect(hidePreviewLimitationsPopover).toBeInTheDocument(); - const hidePreviewLimitationsTemporaryButton = screen.getByRole('button', { - name: textMock('session.do_show_again'), + (useMediaQuery as jest.Mock).mockReturnValue(false); + renderLandingPage({ + getUser: jest.fn().mockImplementation(() => Promise.resolve(userMock)), }); - const hidePreviewLimitationsForSessionButton = screen.getByRole('button', { - name: textMock('session.dont_show_again'), - }); - expect(hidePreviewLimitationsTemporaryButton).toBeInTheDocument(); - expect(hidePreviewLimitationsForSessionButton).toBeInTheDocument(); + + await waitForElementToBeRemoved(screen.queryByTitle(textMock('preview.loading_page'))); + + await user.click( + screen.getByRole('button', { + name: textMock('shared.header_user_for_org', { user: userMock.full_name, org: '' }), + }), + ); + + expect( + screen.getByRole('menuitemradio', { name: textMock('shared.header_logout') }), + ).toBeInTheDocument(); + expect( + screen.getByRole('menuitem', { name: textMock('sync_header.documentation') }), + ).toBeInTheDocument(); + }); + + it('should display the iframe with the correct src', async () => { + renderLandingPage(); + + await waitForElementToBeRemoved(screen.queryByTitle(textMock('preview.loading_page'))); + + const iframe = screen.getByTitle(textMock('preview.title')); + expect(iframe).toHaveAttribute('src', `/app-specific-preview/${org}/${app}?`); }); }); + +const renderLandingPage = async (queries: Partial = {}) => { + return renderWithProviders(queries)(); +}; diff --git a/frontend/app-preview/src/views/LandingPage.tsx b/frontend/app-preview/src/views/LandingPage.tsx index 3dc7f73c79d..c0ac925f5bf 100644 --- a/frontend/app-preview/src/views/LandingPage.tsx +++ b/frontend/app-preview/src/views/LandingPage.tsx @@ -2,32 +2,29 @@ import React from 'react'; import classes from './LandingPage.module.css'; import { useTranslation } from 'react-i18next'; import { usePreviewConnection } from 'app-shared/providers/PreviewConnectionContext'; -import { useInstanceIdQuery, useRepoMetadataQuery, useUserQuery } from 'app-shared/hooks/queries'; +import { useRepoMetadataQuery, useUserQuery } from 'app-shared/hooks/queries'; import { useLocalStorage } from '@studio/components/src/hooks/useLocalStorage'; -import { AltinnHeader } from 'app-shared/components/altinnHeader'; -import type { AltinnHeaderVariant } from 'app-shared/components/altinnHeader/types'; -import { appPreviewButtonActions } from '../components/AppBarConfig/AppPreviewBarConfig'; import { AppPreviewSubMenu } from '../components/AppPreviewSubMenu'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { previewPage } from 'app-shared/api/paths'; import { PreviewLimitationsInfo } from 'app-shared/components/PreviewLimitationsInfo/PreviewLimitationsInfo'; +import { StudioPageHeader, StudioPageSpinner, useMediaQuery } from '@studio/components'; +import { UserProfileMenu } from '../components/UserProfileMenu'; +import { PreviewControlHeader } from '../components/PreviewControlHeader'; +import { MEDIA_QUERY_MAX_WIDTH } from 'app-shared/constants'; import { useSelectedFormLayoutName } from 'app-shared/hooks/useSelectedFormLayoutName'; import { useSelectedFormLayoutSetName } from 'app-shared/hooks/useSelectedFormLayoutSetName'; import { useSelectedTaskId } from 'app-shared/hooks/useSelectedTaskId'; -export interface LandingPageProps { - variant?: AltinnHeaderVariant; -} - export type PreviewAsViewSize = 'desktop' | 'mobile'; -export const LandingPage = ({ variant = 'preview' }: LandingPageProps) => { +export const LandingPage = () => { const { org, app } = useStudioEnvironmentParams(); const { t } = useTranslation(); + const shouldDisplayText = !useMediaQuery(MEDIA_QUERY_MAX_WIDTH); const previewConnection = usePreviewConnection(); - const { data: user } = useUserQuery(); + const { data: user, isPending: isPendingUser } = useUserQuery(); const { data: repository } = useRepoMetadataQuery(org, app); - const { data: instanceId } = useInstanceIdQuery(org, app); const { selectedFormLayoutSetName, setSelectedFormLayoutSetName } = useSelectedFormLayoutSetName(); const { selectedFormLayoutName } = useSelectedFormLayoutName(selectedFormLayoutSetName); @@ -58,29 +55,28 @@ export const LandingPage = ({ variant = 'preview' }: LandingPageProps) => { }); } + if (isPendingUser) return ; + return ( <> -
- - } - /> -
+ + + + + + + + + + +
+