From c3f6f5edcd0cc5e573ded01187c9aeafbf715fbe Mon Sep 17 00:00:00 2001 From: Martin Gunnerud Date: Fri, 4 Oct 2024 15:34:26 +0200 Subject: [PATCH] refactor(resource-adm): use new StudioPageHeader component in resourceadm (#13699) --- .../ResourceAdmHeader.test.tsx | 84 ++++++++++++++++ .../ResourceAdmHeader/ResourceAdmHeader.tsx | 98 +++++++++++++++++++ .../components/ResourceAdmHeader/index.ts | 1 + .../hooks/useUrlParams/useUrlParams.ts | 3 +- .../pages/PageLayout/PageLayout.tsx | 27 ++--- .../pages/RedirectPage/RedirectPage.tsx | 3 +- .../resourceadm/utils/stringUtils/index.ts | 1 + .../utils/stringUtils/stringUtils.ts | 4 + frontend/resourceadm/utils/userUtils/index.ts | 2 +- .../resourceadm/utils/userUtils/userUtils.ts | 5 + 10 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.test.tsx create mode 100644 frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.tsx create mode 100644 frontend/resourceadm/components/ResourceAdmHeader/index.ts diff --git a/frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.test.tsx b/frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.test.tsx new file mode 100644 index 00000000000..69989ead373 --- /dev/null +++ b/frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { render, screen } from '@testing-library/react'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import userEvent from '@testing-library/user-event'; +import { queriesMock } from 'app-shared/mocks/queriesMock'; +import { createQueryClientMock } from 'app-shared/mocks/queryClientMock'; +import { ServicesContextProvider } from 'app-shared/contexts/ServicesContext'; +import { ResourceAdmHeader } from './ResourceAdmHeader'; + +const mainOrganization = { + avatar_url: '', + id: 1, + username: 'ttd', + full_name: 'Testdepartementet', +}; +const otherOrganization = { + avatar_url: '', + id: 2, + username: 'skd', + full_name: 'Skatteetaten', +}; +const organizations = [mainOrganization, otherOrganization]; + +const testUser = { + avatar_url: '', + email: 'test@test.no', + full_name: 'Test Testersen', + id: 11, + login: 'test', + userType: 1, +}; + +const resourceId = 'res-id'; + +const navigateMock = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => navigateMock, + useParams: () => ({ + org: mainOrganization.username, + resourceId: resourceId, + }), +})); + +describe('ResourceAdmHeader', () => { + afterEach(jest.clearAllMocks); + + it('should show org name and resource id in header', () => { + renderResourceAdmHeader(); + + expect(screen.getByText(`${mainOrganization.full_name} / ${resourceId}`)).toBeInTheDocument(); + }); + + it('should navigate to new org when another org is chosen in menu', async () => { + const user = userEvent.setup(); + renderResourceAdmHeader(); + + const menuTrigger = screen.getByRole('button', { + name: textMock('shared.header_user_for_org', { + user: testUser.full_name, + org: mainOrganization.full_name, + }), + }); + await user.click(menuTrigger); + + const otherOrgButton = screen.getByRole('menuitemradio', { + name: otherOrganization.full_name, + }); + await user.click(otherOrgButton); + + expect(navigateMock).toHaveBeenCalled(); + }); +}); + +const renderResourceAdmHeader = () => { + return render( + + + + + , + ); +}; diff --git a/frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.tsx b/frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.tsx new file mode 100644 index 00000000000..ff7c2ae54e6 --- /dev/null +++ b/frontend/resourceadm/components/ResourceAdmHeader/ResourceAdmHeader.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + StudioAvatar, + StudioPageHeader, + type StudioProfileMenuGroup, + useMediaQuery, + type StudioProfileMenuItem, +} from '@studio/components'; +import { getOrgNameByUsername } from '../../utils/userUtils'; +import { type Organization } from 'app-shared/types/Organization'; +import { MEDIA_QUERY_MAX_WIDTH } from 'app-shared/constants'; +import { useLogoutMutation } from 'app-shared/hooks/mutations/useLogoutMutation'; +import type { User } from 'app-shared/types/Repository'; +import { useUrlParams } from '../../hooks/useUrlParams'; +import { getAppName } from '../../utils/stringUtils'; + +interface ResourceAdmHeaderProps { + organizations: Organization[]; + user: User; +} + +export const ResourceAdmHeader = ({ organizations, user }: ResourceAdmHeaderProps) => { + const { org, resourceId } = useUrlParams(); + const resourcePath = resourceId ? ` / ${resourceId}` : ''; + const pageHeaderTitle: string = `${getOrgNameByUsername(org, organizations)}${resourcePath}`; + + return ( + + + + + + + + + ); +}; + +const DashboardHeaderMenu = ({ organizations, user }: ResourceAdmHeaderProps) => { + const { t } = useTranslation(); + const showButtonText = !useMediaQuery(MEDIA_QUERY_MAX_WIDTH); + const { org, app } = useUrlParams(); + const { mutate: logout } = useLogoutMutation(); + const navigate = useNavigate(); + const selectableOrgs = organizations; + + const triggerButtonText = t('shared.header_user_for_org', { + user: user?.full_name || user?.login, + org: getOrgNameByUsername(org, selectableOrgs), + }); + const repoPath = `/repos/${org}/${app}`; + + const handleSetSelectedContext = (context: string) => { + navigate(`/${context}/${getAppName(context)}${location.search}`); + }; + + const selectableOrgMenuItems: StudioProfileMenuItem[] = selectableOrgs.map( + (selectableOrg: Organization) => ({ + action: { type: 'button', onClick: () => handleSetSelectedContext(selectableOrg.username) }, + itemName: selectableOrg?.full_name || selectableOrg.username, + isActive: org === selectableOrg.username, + }), + ); + + const giteaMenuItem: StudioProfileMenuItem = { + action: { type: 'link', href: repoPath }, + itemName: t('shared.header_go_to_gitea'), + }; + + const logOutMenuItem: StudioProfileMenuItem = { + action: { type: 'button', onClick: logout }, + itemName: t('shared.header_logout'), + }; + + const profileMenuGroups: StudioProfileMenuGroup[] = [ + { items: selectableOrgMenuItems }, + { items: [giteaMenuItem, logOutMenuItem] }, + ]; + + return ( + + } + profileMenuGroups={profileMenuGroups} + /> + ); +}; diff --git a/frontend/resourceadm/components/ResourceAdmHeader/index.ts b/frontend/resourceadm/components/ResourceAdmHeader/index.ts new file mode 100644 index 00000000000..c10ac33b2c8 --- /dev/null +++ b/frontend/resourceadm/components/ResourceAdmHeader/index.ts @@ -0,0 +1 @@ +export { ResourceAdmHeader } from './ResourceAdmHeader'; diff --git a/frontend/resourceadm/hooks/useUrlParams/useUrlParams.ts b/frontend/resourceadm/hooks/useUrlParams/useUrlParams.ts index 70a3147557e..2170785d108 100644 --- a/frontend/resourceadm/hooks/useUrlParams/useUrlParams.ts +++ b/frontend/resourceadm/hooks/useUrlParams/useUrlParams.ts @@ -1,4 +1,5 @@ import { useParams } from 'react-router-dom'; +import { getAppName } from '../../utils/stringUtils'; interface ResourceAdminUrlParams { org: string; @@ -14,7 +15,7 @@ export const useUrlParams = (): Readonly => { return { org: params.org, - app: `${params.org}-resources`, + app: getAppName(params.org), env: params.env, resourceId: params.resourceId, accessListId: params.accessListId, diff --git a/frontend/resourceadm/pages/PageLayout/PageLayout.tsx b/frontend/resourceadm/pages/PageLayout/PageLayout.tsx index 2751be58d65..76600ed2b19 100644 --- a/frontend/resourceadm/pages/PageLayout/PageLayout.tsx +++ b/frontend/resourceadm/pages/PageLayout/PageLayout.tsx @@ -1,11 +1,6 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import classes from './PageLayout.module.css'; import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import AppHeader, { - HeaderContext, - SelectedContextType, -} from 'app-shared/navigation/main-header/Header'; -import type { IHeaderContext } from 'app-shared/navigation/main-header/Header'; import { userHasAccessToOrganization } from '../../utils/userUtils'; import { useOrganizationsQuery } from '../../hooks/queries'; import { useRepoStatusQuery, useUserQuery } from 'app-shared/hooks/queries'; @@ -13,6 +8,7 @@ import { GiteaHeader } from 'app-shared/components/GiteaHeader'; import { useUrlParams } from '../../hooks/useUrlParams'; import postMessages from 'app-shared/utils/postMessages'; import { MergeConflictModal } from '../../components/MergeConflictModal'; +import { ResourceAdmHeader } from '../../components/ResourceAdmHeader'; /** * @component @@ -26,7 +22,7 @@ export const PageLayout = (): React.JSX.Element => { const { data: organizations } = useOrganizationsQuery(); const mergeConflictModalRef = useRef(null); - const { org = SelectedContextType.Self, app } = useUrlParams(); + const { org, app } = useUrlParams(); const { data: repoStatus } = useRepoStatusQuery(org, app); const navigate = useNavigate(); @@ -63,22 +59,11 @@ export const PageLayout = (): React.JSX.Element => { }; }, [mergeConflictModalRef]); - const headerContextValue: IHeaderContext = useMemo( - () => ({ - selectableOrgs: organizations, - user, - }), - [organizations, user], - ); - return ( <> - - - {/* TODO - Find out if should be replaced to be the same as studio */} - - - + + {organizations && user && } + ); diff --git a/frontend/resourceadm/pages/RedirectPage/RedirectPage.tsx b/frontend/resourceadm/pages/RedirectPage/RedirectPage.tsx index 4f73093d85d..a7e44e2efaf 100644 --- a/frontend/resourceadm/pages/RedirectPage/RedirectPage.tsx +++ b/frontend/resourceadm/pages/RedirectPage/RedirectPage.tsx @@ -3,6 +3,7 @@ import { Navigate } from 'react-router-dom'; import classes from './RedirectPage.module.css'; import { ErrorPage } from '../ErrorPage'; import { useUrlParams } from '../../hooks/useUrlParams'; +import { getAppName } from '../../utils/stringUtils'; /** * @component @@ -19,7 +20,7 @@ export const RedirectPage = (): React.JSX.Element => { // Error page if user has chosen "Alle" ) : ( - + )} ); diff --git a/frontend/resourceadm/utils/stringUtils/index.ts b/frontend/resourceadm/utils/stringUtils/index.ts index a8b3f0dc76f..4326770255e 100644 --- a/frontend/resourceadm/utils/stringUtils/index.ts +++ b/frontend/resourceadm/utils/stringUtils/index.ts @@ -4,4 +4,5 @@ export { isSePrefix, stringNumberToAriaLabel, isOrgNrString, + getAppName, } from './stringUtils'; diff --git a/frontend/resourceadm/utils/stringUtils/stringUtils.ts b/frontend/resourceadm/utils/stringUtils/stringUtils.ts index eb9f7be6f85..8d319fb4fbc 100644 --- a/frontend/resourceadm/utils/stringUtils/stringUtils.ts +++ b/frontend/resourceadm/utils/stringUtils/stringUtils.ts @@ -30,3 +30,7 @@ export const stringNumberToAriaLabel = (s: string): string => { export const isOrgNrString = (s: string): boolean => { return /^\d{9}$/.test(s); // regex for search string is exactly 9 digits }; + +export const getAppName = (org: string): string => { + return `${org}-resources`; +}; diff --git a/frontend/resourceadm/utils/userUtils/index.ts b/frontend/resourceadm/utils/userUtils/index.ts index 20865c094f2..c1b0eaffa3b 100644 --- a/frontend/resourceadm/utils/userUtils/index.ts +++ b/frontend/resourceadm/utils/userUtils/index.ts @@ -1 +1 @@ -export { userHasAccessToOrganization } from './userUtils'; +export { userHasAccessToOrganization, getOrgNameByUsername } from './userUtils'; diff --git a/frontend/resourceadm/utils/userUtils/userUtils.ts b/frontend/resourceadm/utils/userUtils/userUtils.ts index 57ee2512e92..9373106c2b9 100644 --- a/frontend/resourceadm/utils/userUtils/userUtils.ts +++ b/frontend/resourceadm/utils/userUtils/userUtils.ts @@ -14,3 +14,8 @@ export const userHasAccessToOrganization = ({ return Boolean(orgs.find((x) => x.username === org)); }; + +export const getOrgNameByUsername = (username: string, orgs: Organization[]) => { + const org = orgs?.find((o) => o.username === username); + return org?.full_name || org?.username; +};