From 220cf696ac5ef9f5cb6e81d3a7dc5ef553bbbe78 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Thu, 25 Jun 2026 12:22:18 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20a=20user=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We are now using the UserMenu component from the ui-kit. It is a dropdown displaying the user's name and email, along with a logout button and a language picker. To fit the design of the user menu, we adapted the LanguagePicker adn are now using the ui-kit language picker component. --- CHANGELOG.md | 1 + .../e2e/__tests__/app-impress/auth.setup.ts | 7 +-- .../app-impress/doc-comments.spec.ts | 3 +- .../app-impress/doc-member-create.spec.ts | 8 +-- .../app-impress/doc-visibility.spec.ts | 16 ++---- .../e2e/__tests__/app-impress/header.spec.ts | 27 ---------- .../__tests__/app-impress/language.spec.ts | 49 +++++++++-------- .../__tests__/app-impress/left-panel.spec.ts | 16 ++++++ .../e2e/__tests__/app-impress/utils-common.ts | 8 +-- .../e2e/__tests__/app-impress/utils-signin.ts | 5 ++ .../features/auth/components/ButtonLogin.tsx | 45 +++++----------- .../src/features/auth/hooks/useAuth.tsx | 17 +++--- .../src/features/header/components/Header.tsx | 2 - .../features/home/components/HomeHeader.tsx | 4 +- .../language/components/LanguagePicker.tsx | 52 ++++++++++++++++++- .../left-panel/components/LeftPanelFooter.tsx | 28 +++++++--- 16 files changed, 158 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b148363ac..1d98fa07e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to - ✨(y-provider) preserve callouts, PDFs, page breaks, interlinking links and commented text on HTML/markdown export #2296 +- ✨(frontend) add a user menu #2463 ### Changed diff --git a/src/frontend/apps/e2e/__tests__/app-impress/auth.setup.ts b/src/frontend/apps/e2e/__tests__/app-impress/auth.setup.ts index 5ccee2192e..a28440f446 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/auth.setup.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/auth.setup.ts @@ -24,10 +24,11 @@ const saveStorageState = async ( await SignIn(page, browserName); + /** + * If the grid is displayed, it means the user is logged in and the storage state can be saved. + */ await expect( - page.locator('header').first().getByRole('button', { - name: 'Logout', - }), + page.getByRole('heading', { name: 'All docs', level: 2 }), ).toBeVisible({ timeout: 10000 }); await page.context().storageState({ diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts index 2b4f746a27..0b7ad3a9da 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-comments.spec.ts @@ -17,6 +17,7 @@ import { updateRoleUser, updateShareLink, } from './utils-share'; +import { logOut } from './utils-signin'; test.beforeEach(async ({ page }) => { await page.goto('/'); @@ -312,7 +313,7 @@ test.describe('Doc Comments', () => { await updateShareLink(page, 'Public', 'Editing'); // Anonymous user can see and add comments - await otherPage.getByRole('button', { name: 'Logout' }).click(); + await logOut(otherPage); await expect( otherPage diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts index c6c804e8cb..37817bc126 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts @@ -9,7 +9,7 @@ import { } from './utils-common'; import { writeInEditor } from './utils-editor'; import { connectOtherUserToDoc, updateRoleUser } from './utils-share'; -import { SignIn } from './utils-signin'; +import { SignIn, logOut } from './utils-signin'; import { createRootSubPage } from './utils-sub-pages'; test.describe('Document create member', () => { @@ -371,11 +371,7 @@ test.describe('Document create member: Multiple login', () => { const urlDoc = page.url(); - await page - .getByRole('button', { - name: 'Logout', - }) - .click(); + await logOut(page); const otherBrowser = BROWSERS.find((b) => b !== browserName); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts index 6dca8b40ba..5488806002 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-visibility.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; import { BROWSERS, createDoc, verifyDocName } from './utils-common'; import { getEditor, writeInEditor } from './utils-editor'; import { addNewMember, connectOtherUserToDoc } from './utils-share'; -import { SignIn, expectLoginPage } from './utils-signin'; +import { SignIn, expectLoginPage, logOut } from './utils-signin'; import { createRootSubPage } from './utils-sub-pages'; test.describe('Doc Visibility', () => { @@ -81,13 +81,7 @@ test.describe('Doc Visibility: Restricted', () => { await verifyDocName(page, docTitle); const urlDoc = page.url(); - - await page - .getByRole('button', { - name: 'Logout', - }) - .click(); - + await logOut(page); await expectLoginPage(page); await page.goto(urlDoc); @@ -112,11 +106,7 @@ test.describe('Doc Visibility: Restricted', () => { const urlDoc = page.url(); - await page - .getByRole('button', { - name: 'Logout', - }) - .click(); + await logOut(page); const otherBrowser = BROWSERS.find((b) => b !== browserName); if (!otherBrowser) { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts index 2036733d8a..cdeacf0dff 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/header.spec.ts @@ -1,7 +1,6 @@ import { expect, test } from '@playwright/test'; import { overrideConfig } from './utils-common'; -import { SignIn, expectLoginPage } from './utils-signin'; test.describe('Header', () => { test('checks all the elements are visible', async ({ page }) => { @@ -14,14 +13,6 @@ test.describe('Header', () => { 'font-family', /Roboto/i, ); - - await expect( - header.getByRole('button', { - name: 'Logout', - }), - ).toBeVisible(); - - await expect(header.getByText('English')).toBeVisible(); }); test('checks all the elements are visible with DSFR theme', async ({ @@ -106,21 +97,3 @@ test.describe('Header', () => { await expect(logoImage).toHaveAttribute('alt', ''); }); }); - -test.describe('Header: Log out', () => { - test.use({ storageState: { cookies: [], origins: [] } }); - - // eslint-disable-next-line playwright/expect-expect - test('checks logout button', async ({ page, browserName }) => { - await page.goto('/'); - await SignIn(page, browserName); - - await page - .getByRole('button', { - name: 'Logout', - }) - .click(); - - await expectLoginPage(page); - }); -}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index 56e20515e2..c7129e5d9b 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -40,9 +40,6 @@ test.describe('Language', () => { }); test('checks language switching', async ({ page }) => { - const header = page.locator('header').first(); - const languagePicker = header.locator('.--docs--language-picker-text'); - await expect(page.locator('html')).toHaveAttribute('lang', 'en-us'); // initial language should be english @@ -58,34 +55,38 @@ test.describe('Language', () => { await expect(page.locator('html')).toHaveAttribute('lang', 'fr'); + await page.getByLabel('Ouvrir le menu utilisateur').click(); await expect( - header.getByRole('button').getByText('Français'), + page.getByRole('button', { name: 'Language: Français' }), ).toBeVisible(); - await expect(page.getByLabel('Se déconnecter')).toBeVisible(); - - // Switch to German using the utility function for consistency - await waitForLanguageSwitch(page, TestLanguage.German); - await expect(header.getByRole('button').getByText('Deutsch')).toBeVisible(); - - await expect(page.getByLabel('Abmelden')).toBeVisible(); - - await expect(page.locator('html')).toHaveAttribute('lang', 'de'); + await expect(page.getByText('Déconnexion')).toBeVisible(); - await languagePicker.click(); + await page.keyboard.press('Escape'); - await expect(page.locator('[role="menu"]')).toBeVisible(); + // Switch to German using the utility function for consistency + await waitForLanguageSwitch( + page, + TestLanguage.German, + 'Ouvrir le menu utilisateur', + ); - const menuItems = page.locator('[role="menuitemradio"]'); - await expect(menuItems.first()).toBeVisible(); + await page.getByLabel('Open user menu').click(); + await expect( + page.getByRole('button', { name: 'Language: Deutsch' }), + ).toBeVisible(); - await menuItems.first().click(); + await expect(page.getByText('Logout', { exact: true })).toBeVisible(); - await expect(page.locator('html')).toHaveAttribute('lang', 'en'); - await expect(languagePicker).toContainText('English'); + await expect(page.locator('html')).toHaveAttribute('lang', 'de'); }); - test('can switch language using only keyboard', async ({ page }) => { + /** + * This test is currently failing due to a known issue with the language picker component. + * A pull request has been created to address this issue: https://github.com/suitenumerique/ui-kit/pull/265 + * TODO: Adapt this test once the PR is merged and the uikit version bumped. + */ + test.skip('can switch language using only keyboard', async ({ page }) => { await page.goto('/'); await waitForLanguageSwitch(page, TestLanguage.English); @@ -171,7 +172,11 @@ test.describe('Language', () => { /** * Swedish is not yet supported in the BlockNote locales, so it should fallback to English */ - await waitForLanguageSwitch(page, TestLanguage.Swedish); + await waitForLanguageSwitch( + page, + TestLanguage.Swedish, + 'Ouvrir le menu utilisateur', + ); await openSuggestionMenu({ page }); await expect( suggestionMenu.getByText('Headings', { exact: true }), diff --git a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts index e3104b4bd5..4ca23ed0c7 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/left-panel.spec.ts @@ -7,6 +7,7 @@ import { verifyDocName, } from './utils-common'; import { tryFocusEditorContent } from './utils-editor'; +import { SignIn, expectLoginPage, logOut } from './utils-signin'; import { createRootSubPage } from './utils-sub-pages'; test.describe('Left panel desktop', () => { @@ -19,6 +20,8 @@ test.describe('Left panel desktop', () => { await expect(page.getByTestId('left-panel-mobile')).toBeHidden(); await expect(page.getByTestId('home-button')).toBeHidden(); await expect(page.getByTestId('new-doc-button')).toBeVisible(); + await expect(page.getByLabel('Open user menu')).toBeVisible(); + await expect(page.getByLabel('Open help menu')).toBeVisible(); await goToGridDoc(page); @@ -309,3 +312,16 @@ test.describe('Left panel responsive', () => { await expect(leftPanel).toBeHidden(); }); }); + +test.describe('Left Panel: Log out', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + // eslint-disable-next-line playwright/expect-expect + test('checks logout button', async ({ page, browserName }) => { + await page.goto('/'); + await SignIn(page, browserName); + await logOut(page); + + await expectLoginPage(page); + }); +}); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts index 24b2c0b88a..83e85aa4fa 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-common.ts @@ -399,6 +399,7 @@ type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey]; export async function waitForLanguageSwitch( page: Page, lang: TestLanguageValue, + labelUserMenu = 'Open user menu', ) { await page.route(/\**\/api\/v1.0\/users\/\**/, async (route, request) => { if (request.method().includes('PATCH')) { @@ -412,8 +413,8 @@ export async function waitForLanguageSwitch( } }); - const header = page.locator('header').first(); - const languagePicker = header.locator('.--docs--language-picker-text'); + await page.getByLabel(labelUserMenu).click(); + const languagePicker = page.getByRole('button', { name: /Language/ }); const isAlreadyTargetLanguage = await languagePicker .innerText() .then((text) => text.toLowerCase().includes(lang.label.toLowerCase())); @@ -424,7 +425,8 @@ export async function waitForLanguageSwitch( await languagePicker.click(); - await page.getByRole('menuitemradio', { name: lang.label }).click(); + await page.getByRole('menuitem', { name: lang.label }).click(); + await page.keyboard.press('Escape'); } export const clickInEditorShareButton = async (page: Page) => { diff --git a/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts b/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts index a3331011f6..2559e05e66 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/utils-signin.ts @@ -13,6 +13,11 @@ export const SignIn = async ( await keycloakSignIn(page, browserName, fromHome); }; +export const logOut = async (page: Page) => { + await page.getByLabel('Open user menu').click(); + await page.getByText('Logout', { exact: true }).click(); +}; + export const customSignIn = async ( page: Page, browserName: string, diff --git a/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx b/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx index b4666f47a4..2d5e5da18a 100644 --- a/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx +++ b/src/frontend/apps/impress/src/features/auth/components/ButtonLogin.tsx @@ -2,51 +2,30 @@ import { Button } from '@gouvfr-lasuite/cunningham-react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; -import { Box, BoxButton } from '@/components'; -import { useCunninghamTheme } from '@/cunningham'; +import { BoxButton } from '@/components'; import ProConnectImg from '../assets/button-proconnect.svg'; import { useAuth } from '../hooks'; -import { gotoLogin, gotoLogout } from '../utils'; +import { gotoLogin } from '../utils'; export const ButtonLogin = () => { const { t } = useTranslation(); const { authenticated } = useAuth(); - const { colorsTokens } = useCunninghamTheme(); - if (!authenticated) { - return ( - - ); + if (authenticated) { + return null; } return ( - gotoLogin()} + color="brand" + size="small" + aria-label={t('Sign in')} + className="--docs--button-login" > - - + {t('Sign in')} + ); }; diff --git a/src/frontend/apps/impress/src/features/auth/hooks/useAuth.tsx b/src/frontend/apps/impress/src/features/auth/hooks/useAuth.tsx index 0663a99d9d..a7a6e11d53 100644 --- a/src/frontend/apps/impress/src/features/auth/hooks/useAuth.tsx +++ b/src/frontend/apps/impress/src/features/auth/hooks/useAuth.tsx @@ -8,14 +8,14 @@ import { useAuthQuery } from '../api'; const regexpUrlsAuth = [/\/docs\/$/g, /\/docs$/g, /^\/$/g]; export const useAuth = () => { - const { data: user, ...authStates } = useAuthQuery(); + const { data: user, isFetched, isLoading, isSuccess } = useAuthQuery(); const { pathname } = useRouter(); const { trackEvent } = useAnalytics(); - const [hasTracked, setHasTracked] = useState(authStates.isFetched); - const isAuthLoading = - authStates.fetchStatus !== 'idle' || authStates.isLoading; + const [hasTracked, setHasTracked] = useState(isFetched); + const isAuthLoading = isLoading; + const hasInitiallyLoaded = useRef(false); - if (authStates.isFetched) { + if (isFetched) { hasInitiallyLoaded.current = true; } const [pathAllowed, setPathAllowed] = useState( @@ -27,7 +27,7 @@ export const useAuth = () => { }, [pathname]); useEffect(() => { - if (!hasTracked && user && authStates.isSuccess) { + if (!hasTracked && user && isSuccess) { trackEvent({ eventName: 'user', id: user?.id || '', @@ -35,14 +35,13 @@ export const useAuth = () => { }); setHasTracked(true); } - }, [hasTracked, authStates.isSuccess, user, trackEvent]); + }, [hasTracked, isSuccess, user, trackEvent]); return { user, - authenticated: !!user && authStates.isSuccess, + authenticated: !!user && isSuccess, pathAllowed, hasInitiallyLoaded: hasInitiallyLoaded.current, isAuthLoading, - ...authStates, }; }; diff --git a/src/frontend/apps/impress/src/features/header/components/Header.tsx b/src/frontend/apps/impress/src/features/header/components/Header.tsx index 4ce20a0939..ae97f09ccd 100644 --- a/src/frontend/apps/impress/src/features/header/components/Header.tsx +++ b/src/frontend/apps/impress/src/features/header/components/Header.tsx @@ -6,7 +6,6 @@ import { Box, SkipToContent, StyledLink } from '@/components/'; import { useConfig } from '@/core/config'; import { useCunninghamTheme } from '@/cunningham'; import { ButtonLogin } from '@/features/auth'; -import { LanguagePicker } from '@/features/language'; import { LeftPanelToggleMobile } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; @@ -89,7 +88,6 @@ export const Header = () => { $direction="row" > - )} diff --git a/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx b/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx index 0c854c1bfd..e33746354c 100644 --- a/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx +++ b/src/frontend/apps/impress/src/features/home/components/HomeHeader.tsx @@ -5,7 +5,7 @@ import { Waffle } from '@/components/Waffle'; import { useConfig } from '@/core'; import { useCunninghamTheme } from '@/cunningham'; import { Title } from '@/features/header'; -import { LanguagePicker } from '@/features/language'; +import { LanguagePickerLegacy } from '@/features/language'; import { LeftPanelToggleMobile } from '@/features/left-panel'; import { useResponsiveStore } from '@/stores'; @@ -80,7 +80,7 @@ export const HomeHeader = () => { {!isSmallMobile && ( - + )} diff --git a/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx index 59655ca591..75b219fff0 100644 --- a/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx @@ -1,3 +1,7 @@ +import { + LanguagePicker as LanguagePickerUIKit, + LanguagesOption, +} from '@gouvfr-lasuite/ui-kit'; import { announce } from '@react-aria/live-announcer'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,7 +15,13 @@ import { useSynchronizedLanguage, } from '@/features/language'; -export const LanguagePicker = () => { +/** + * LanguagePickerLegacy component for selecting language. + * We still have some legacy code that uses this component, so we keep it for now. + * @deprecated Use LanguagePicker instead. + * @returns JSX.Element + */ +export const LanguagePickerLegacy = () => { const { t, i18n } = useTranslation(); const { data: conf } = useConfig(); const { data: user } = useAuthQuery(); @@ -85,3 +95,43 @@ export const LanguagePicker = () => { ); }; + +export const LanguagePicker = () => { + const { t, i18n } = useTranslation(); + const { data: conf } = useConfig(); + const { data: user } = useAuthQuery(); + const { changeLanguageSynchronized } = useSynchronizedLanguage(); + const language = i18n.language; + + const languages: LanguagesOption[] = useMemo(() => { + const backendOptions = conf?.LANGUAGES ?? [[language, language]]; + return backendOptions.map(([backendLocale, backendLabel]) => ({ + label: backendLabel, + value: backendLocale, + isChecked: getMatchingLocales([backendLocale], [language]).length > 0, + })); + }, [conf?.LANGUAGES, language]); + + const onChange = (value: string) => { + const lang = conf?.LANGUAGES?.find(([code]) => code === value); + const backendLabel = lang?.[1] ?? value; + void changeLanguageSynchronized(value, user).then(() => { + announce( + t('Language changed to {{language}}', { + language: backendLabel, + defaultValue: `Language changed to ${backendLabel}`, + }), + 'polite', + ); + }); + }; + + return ( + + ); +}; diff --git a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFooter.tsx b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFooter.tsx index 5c6e762dbd..61525f4384 100644 --- a/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFooter.tsx +++ b/src/frontend/apps/impress/src/features/left-panel/components/LeftPanelFooter.tsx @@ -1,12 +1,24 @@ +import { UserMenu } from '@gouvfr-lasuite/ui-kit'; +import { useTranslation } from 'react-i18next'; + import { Box, SeparatedSection } from '@/components'; import { Waffle } from '@/components/Waffle'; import { ButtonLogin } from '@/features/auth'; +import { useAuth } from '@/features/auth/hooks/useAuth'; +import { gotoLogout } from '@/features/auth/utils'; import { HelpMenu } from '@/features/help'; -import { LanguagePicker } from '@/features/language'; +import { LanguagePicker } from '@/features/language/components/LanguagePicker'; import { useResponsiveStore } from '@/stores'; export const LeftPanelFooter = () => { const { isLargeScreen } = useResponsiveStore(); + const { t } = useTranslation(); + const { user } = useAuth(); + + const userMenu = user || { + full_name: t('Guest'), + email: '', + }; return ( @@ -15,14 +27,14 @@ export const LeftPanelFooter = () => { $justify="space-between" $direction="row" > - + + } + /> - {!isLargeScreen && ( - <> - - - - )} + {!isLargeScreen && }