diff --git a/CHANGELOG.md b/CHANGELOG.md index cf9798e3..1367bc1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,17 +13,18 @@ and this project adheres to - ✨(back) add endpoint checking media status - ✨(backend) allow setting session cookie age via env var #977 - ✨(backend) allow theme customnization using a configuration file #948 -- ✨ Add a custom callout block to the editor #892 +- ✨(frontend) Add a custom callout block to the editor #892 - 🚩(frontend) version MIT only #911 - ✨(backend) integrate maleware_detection from django-lasuite #936 +- 🏗️(frontend) Footer configurable #959 - 🩺(CI) add lint spell mistakes #954 - 🛂(frontend) block edition to not connected users #945 - 🚸 Let loader during upload analyze #984 ### Changed -- 📝(frontend) Update documentation -- ✅(frontend) Improve tests coverage +- 📝(frontend) Update documentation #949 +- ✅(frontend) Improve tests coverage #949 - ⬆️(docker) upgrade backend image to python 3.13 #973 - ⬆️(docker) upgrade node images to alpine 3.21 - 🐛(y-provider) increase JSON size limits for transcription conversion @@ -31,7 +32,7 @@ and this project adheres to ### Removed -- 🔥(back) remove footer endpoint +- 🔥(back) remove footer endpoint #948 ## [3.2.1] - 2025-05-06 diff --git a/docs/assets/footer-configurable.png b/docs/assets/footer-configurable.png new file mode 100644 index 00000000..c79ebea1 Binary files /dev/null and b/docs/assets/footer-configurable.png differ diff --git a/docs/theming.md b/docs/theming.md index 6f8c65a9..2c511d71 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -30,4 +30,27 @@ body { Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the background color to the custom color you specified. +---- + +# **Footer Configuration** 📝 + +The footer is configurable from the theme customization file. + +### Settings 🔧 + +```shellscript +THEME_CUSTOMIZATION_FILE_PATH= +``` + +### Example of JSON + +The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json + +`footer.default` is the fallback if the language is not supported. + +--- +Below is a visual example of a configured footer ⬇️: + +![Footer Configuration Example](./assets/footer-configurable.png) + diff --git a/src/backend/impress/configuration/theme/default.json b/src/backend/impress/configuration/theme/default.json index c34df5c0..29200ada 100644 --- a/src/backend/impress/configuration/theme/default.json +++ b/src/backend/impress/configuration/theme/default.json @@ -1,124 +1,129 @@ { - "footer": { - "default": { - "externalLinks": [ - { - "label": "Github", - "href": "https://github.com/suitenumerique/docs/" - }, - { - "label": "DINUM", - "href": "https://www.numerique.gouv.fr/dinum/" - }, - { - "label": "ZenDiS", - "href": "https://zendis.de/" - }, - { - "label": "BlockNote.js", - "href": "https://www.blocknotejs.org/" - } - ], - "bottomInformation": { - "label": "Unless otherwise stated, all content on this site is under", - "link": { - "label": "licence etalab-2.0", - "href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md" - } - } + "footer": { + "default": { + "logo": { + "src": "/assets/icon-docs.svg", + "width": "54px", + "alt": "Docs Logo", + "withTitle": true }, - "en": { - "legalLinks": [ - { - "label": "Legal Notice", - "href": "#" - }, - { - "label": "Personal data and cookies", - "href": "#" - }, - { - "label": "Accessibility", - "href": "#" - } - ], - "bottomInformation": { - "label": "Unless otherwise stated, all content on this site is under", - "link": { - "label": "licence MIT", - "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" - } + "externalLinks": [ + { + "label": "Github", + "href": "https://github.com/suitenumerique/docs/" + }, + { + "label": "DINUM", + "href": "https://www.numerique.gouv.fr/dinum/" + }, + { + "label": "ZenDiS", + "href": "https://zendis.de/" + }, + { + "label": "BlockNote.js", + "href": "https://www.blocknotejs.org/" } - }, - "fr": { - "legalLinks": [ - { - "label": "Mentions légales", - "href": "#" - }, - { - "label": "Données personnelles et cookies", - "href": "#" - }, - { - "label": "Accessibilité", - "href": "#" - } - ], - "bottomInformation": { - "label": "Sauf mention contraire, tout le contenu de ce site est sous", - "link": { - "label": "licence MIT", - "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" - } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence etalab-2.0", + "href": "https://github.com/etalab/licence-ouverte/blob/master/LO.md" } - }, - "de": { - "legalLinks": [ - { - "label": "Impressum", - "href": "#" - }, - { - "label": "Personenbezogene Daten und Cookies", - "href": "#" - }, - { - "label": "Barrierefreiheit", - "href": "#" - } - ], - "bottomInformation": { - "label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter", - "link": { - "label": "licence MIT", - "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" - } + } + }, + "en": { + "legalLinks": [ + { + "label": "Legal Notice", + "href": "#" + }, + { + "label": "Personal data and cookies", + "href": "#" + }, + { + "label": "Accessibility", + "href": "#" } - }, - "nl": { - "legalLinks": [ - { - "label": "Wettelijke bepalingen", - "href": "#" - }, - { - "label": "Persoonlijke gegevens en cookies", - "href": "#" - }, - { - "label": "Toegankelijkheid", - "href": "#" - } - ], - "bottomInformation": { - "label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder", - "link": { - "label": "licence MIT", - "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" - } + ], + "bottomInformation": { + "label": "Unless otherwise stated, all content on this site is under", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "fr": { + "legalLinks": [ + { + "label": "Mentions légales", + "href": "#" + }, + { + "label": "Données personnelles et cookies", + "href": "#" + }, + { + "label": "Accessibilité", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sauf mention contraire, tout le contenu de ce site est sous", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "de": { + "legalLinks": [ + { + "label": "Impressum", + "href": "#" + }, + { + "label": "Personenbezogene Daten und Cookies", + "href": "#" + }, + { + "label": "Barrierefreiheit", + "href": "#" + } + ], + "bottomInformation": { + "label": "Sofern nicht anders angegeben, steht der gesamte Inhalt dieser Website unter", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" + } + } + }, + "nl": { + "legalLinks": [ + { + "label": "Wettelijke bepalingen", + "href": "#" + }, + { + "label": "Persoonlijke gegevens en cookies", + "href": "#" + }, + { + "label": "Toegankelijkheid", + "href": "#" + } + ], + "bottomInformation": { + "label": "Tenzij anders vermeld, is alle inhoud van deze site ondergebracht onder", + "link": { + "label": "licence MIT", + "href": "https://github.com/suitenumerique/docs/blob/main/LICENSE" } } } } - \ No newline at end of file +} diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 276e06dc..28379315 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -159,8 +159,8 @@ test.describe('Config: Not loggued', () => { const jsonResponse = await response.json(); expect(jsonResponse.FRONTEND_THEME).toStrictEqual('default'); - const footer = page.locator('footer').first(); + const header = page.locator('header').first(); // alt 'Gouvernement Logo' comes from the theme - await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible(); + await expect(header.getByAltText('Gouvernement Logo')).toBeVisible(); }); }); diff --git a/src/frontend/apps/e2e/__tests__/app-impress/footer.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/footer.spec.ts index a1878ccb..93405911 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/footer.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/footer.spec.ts @@ -1,14 +1,115 @@ import { expect, test } from '@playwright/test'; +import { overrideConfig } from './common'; + test.describe('Footer', () => { test.use({ storageState: { cookies: [], origins: [] } }); + test('checks the footer is not displayed if no config', async ({ page }) => { + await overrideConfig(page, { + theme_customization: {}, + }); + + await page.goto('/'); + await expect(page.locator('footer')).toBeHidden(); + }); + test('checks all the elements are visible', async ({ page }) => { await page.goto('/'); const footer = page.locator('footer').first(); + await expect(footer.getByAltText('Docs Logo')).toBeVisible(); + await expect(footer.getByRole('heading', { name: 'Docs' })).toBeVisible(); + await expect(footer.getByText('BETA')).toBeVisible(); + + await expect(footer.getByRole('link', { name: 'Github' })).toBeVisible(); + await expect(footer.getByRole('link', { name: 'DINUM' })).toBeVisible(); + await expect(footer.getByRole('link', { name: 'ZenDiS' })).toBeVisible(); + + await expect( + footer.getByRole('link', { name: 'BlockNote.js' }), + ).toBeVisible(); + await expect( + footer.getByRole('link', { name: 'Legal Notice' }), + ).toBeVisible(); + await expect( + footer.getByRole('link', { name: 'Personal data and cookies' }), + ).toBeVisible(); + await expect( + footer.getByRole('link', { name: 'Accessibility' }), + ).toBeVisible(); + + await expect( + footer.getByText( + 'Unless otherwise stated, all content on this site is under licence', + ), + ).toBeVisible(); + + // Check the translation + const header = page.locator('header').first(); + await header.getByRole('button').getByText('English').click(); + await page.getByLabel('Français').click(); + + await expect( + page.locator('footer').getByText('Mentions légales'), + ).toBeVisible(); + }); + + test('checks the footer is correctly overrided', async ({ page }) => { + await overrideConfig(page, { + theme_customization: { + footer: { + default: { + logo: { + src: '/assets/logo-gouv.svg', + width: '220px', + alt: 'Gouvernement Logo', + }, + externalLinks: [ + { + label: 'legifrance.gouv.fr', + href: '#', + }, + { + label: 'info.gouv.fr', + href: '#', + }, + ], + legalLinks: [ + { + label: 'Legal link', + href: '#', + }, + ], + bottomInformation: { + label: 'Some bottom information text', + link: { + label: 'a custom label', + href: '#', + }, + }, + }, + fr: { + bottomInformation: { + label: "Text d'information en bas de page en français", + link: { + label: 'un label personnalisé', + href: '#', + }, + }, + }, + }, + }, + }); + + await page.goto('/'); + const footer = page.locator('footer').first(); + await expect(footer.getByAltText('Gouvernement Logo')).toBeVisible(); + await expect(footer.getByRole('heading', { name: 'Docs' })).toBeHidden(); + await expect(footer.getByText('BETA')).toBeHidden(); + await expect( footer.getByRole('link', { name: 'legifrance.gouv.fr' }), ).toBeVisible(); @@ -18,53 +119,30 @@ test.describe('Footer', () => { ).toBeVisible(); await expect( - footer.getByRole('link', { name: 'service-public.fr' }), + footer.getByRole('link', { name: 'Legal link' }), ).toBeVisible(); await expect( - footer.getByRole('link', { name: 'data.gouv.fr' }), + footer.getByText('Some bottom information text'), ).toBeVisible(); await expect( - footer.getByRole('link', { name: 'Legal Notice' }), + footer.getByRole('link', { name: 'a custom label' }), + ).toBeVisible(); + + // Check the translation + const header = page.locator('header').first(); + await header.getByRole('button').getByText('English').click(); + await page.getByLabel('Français').click(); + + await expect( + page + .locator('footer') + .getByText("Text d'information en bas de page en français"), ).toBeVisible(); await expect( - footer.getByRole('link', { name: 'Personal data and cookies' }), - ).toBeVisible(); - - await expect( - footer.getByRole('link', { name: 'Accessibility' }), - ).toBeVisible(); - - await expect( - footer.getByText( - 'Unless otherwise stated, all content on this site is under licence', - ), + footer.getByRole('link', { name: 'un label personnalisé' }), ).toBeVisible(); }); - - const legalPages = [ - { name: 'Legal Notice', url: '/legal-notice/' }, - { name: 'Personal data and cookies', url: '/personal-data-cookies/' }, - { name: 'Accessibility', url: '/accessibility/' }, - ]; - for (const { name, url } of legalPages) { - test(`checks ${name} page`, async ({ page }) => { - await page.goto('/'); - - const footer = page.locator('footer').first(); - await footer.getByRole('link', { name }).click(); - - await expect( - page - .getByRole('heading', { - name, - }) - .first(), - ).toBeVisible(); - - await expect(page).toHaveURL(url); - }); - } }); diff --git a/src/frontend/apps/impress/public/assets/icon-docs.svg b/src/frontend/apps/impress/public/assets/icon-docs.svg new file mode 100644 index 00000000..05cf0436 --- /dev/null +++ b/src/frontend/apps/impress/public/assets/icon-docs.svg @@ -0,0 +1,12 @@ + + + + diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index fdfe8c97..306cf100 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -2,8 +2,13 @@ import { useQuery } from '@tanstack/react-query'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Theme } from '@/cunningham/'; +import { FooterType } from '@/features/footer'; import { PostHogConf } from '@/services'; +interface ThemeCustomization { + footer?: FooterType; +} + interface ConfigResponse { AI_FEATURE_ENABLED?: boolean; COLLABORATION_WS_URL?: string; @@ -17,6 +22,7 @@ interface ConfigResponse { MEDIA_BASE_URL?: string; POSTHOG_KEY?: PostHogConf; SENTRY_DSN?: string; + theme_customization?: ThemeCustomization; } const LOCAL_STORAGE_KEY = 'docs_config'; diff --git a/src/frontend/apps/impress/src/features/footer/Footer.tsx b/src/frontend/apps/impress/src/features/footer/Footer.tsx index 92f249e2..c6a81c73 100644 --- a/src/frontend/apps/impress/src/features/footer/Footer.tsx +++ b/src/frontend/apps/impress/src/features/footer/Footer.tsx @@ -1,11 +1,15 @@ import Image from 'next/image'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Box, StyledLink, Text } from '@/components/'; -import { useCunninghamTheme } from '@/cunningham'; +import { useConfig } from '@/core/config'; + +import { Title } from '../header'; import IconLink from './assets/external-link.svg'; +import { ContentType } from './types'; const BlueStripe = styled.div` position: absolute; @@ -16,9 +20,40 @@ const BlueStripe = styled.div` `; export const Footer = () => { - const { t } = useTranslation(); - const { themeTokens } = useCunninghamTheme(); - const logo = themeTokens.logo; + const { data: config } = useConfig(); + const footerJson = config?.theme_customization?.footer; + const { i18n, t } = useTranslation(); + const resolvedLanguage = i18n.resolvedLanguage; + const [content, setContent] = useState(); + + useEffect(() => { + if (!footerJson) { + return; + } + + const langData = footerJson[resolvedLanguage as keyof typeof footerJson]; + const innerContent: ContentType = {}; + + innerContent.logo = langData?.logo || footerJson?.default?.logo; + innerContent.legalLinks = + langData?.legalLinks || footerJson?.default?.legalLinks; + innerContent.externalLinks = + langData && 'externalLinks' in langData + ? langData?.externalLinks + : footerJson?.default?.externalLinks; + innerContent.bottomInformation = + langData && 'bottomInformation' in langData + ? langData?.bottomInformation + : footerJson?.default?.bottomInformation; + + setContent(innerContent); + }, [footerJson, resolvedLanguage]); + + const { logo, legalLinks, externalLinks, bottomInformation } = content || {}; + + if (!footerJson || (!legalLinks && !externalLinks && !bottomInformation)) { + return null; + } return ( @@ -34,14 +69,29 @@ export const Footer = () => { {logo && ( - {logo.alt} + + {logo?.src && ( + {logo?.alt + )} + {logo.withTitle && ( + + + </Box> + )} + </Box> )} </Box> </Box> @@ -54,40 +104,24 @@ export const Footer = () => { `} className="--docs--footer-external-links" > - {[ - { - label: 'legifrance.gouv.fr', - href: 'https://legifrance.gouv.fr/', - }, - { - label: 'info.gouv.fr', - href: 'https://info.gouv.fr/', - }, - { - label: 'service-public.fr', - href: 'https://service-public.fr/', - }, - { - label: 'data.gouv.fr', - href: 'https://data.gouv.fr/', - }, - ].map(({ label, href }) => ( - <StyledLink - key={label} - href={href} - target="__blank" - $css={` - gap:0.2rem; - transition: box-shadow 0.3s; - &:hover { - box-shadow: 0px 2px 0 0 var(--c--theme--colors--greyscale-text); - } - `} - > - <Text $weight="bold">{label}</Text> - <IconLink width={18} /> - </StyledLink> - ))} + {externalLinks && + externalLinks.map(({ label, href }) => ( + <StyledLink + key={label} + href={href} + target="__blank" + $css={` + gap:0.2rem; + transition: box-shadow 0.3s; + &:hover { + box-shadow: 0px 2px 0 0 var(--c--theme--colors--greyscale-text); + } + `} + > + <Text $weight="bold">{label}</Text> + <IconLink width={18} /> + </StyledLink> + ))} </Box> </Box> <Box @@ -102,66 +136,62 @@ export const Footer = () => { `} className="--docs--footer-internal-links" > - {[ - { - label: t('Legal Notice'), - href: '/legal-notice', - }, - { - label: t('Personal data and cookies'), - href: '/personal-data-cookies', - }, - { - label: t('Accessibility'), - href: '/accessibility', - }, - ].map(({ label, href }) => ( - <StyledLink - key={label} - href={href} - $css={` - padding-right: 1rem; - &:not(:last-child) { - box-shadow: inset -1px 0px 0px 0px var(--c--theme--colors--greyscale-200); - } - `} - > - <Text - $variation="600" - $size="m" - $transition="box-shadow 0.3s" - $css={` - &:hover { - box-shadow: 0px 2px 0 0 var(--c--theme--colors--greyscale-text); + {legalLinks && + legalLinks.map(({ label, href }) => ( + <StyledLink + key={label} + href={href} + $css={css` + padding-right: 1rem; + &:not(:last-child) { + box-shadow: inset -1px 0px 0px 0px + var(--c--theme--colors--greyscale-200); } `} > - {label} - </Text> - </StyledLink> - ))} + <Text + $variation="600" + $size="m" + $transition="box-shadow 0.3s" + $css={css` + &:hover { + box-shadow: 0px 2px 0 0 + var(--c--theme--colors--greyscale-text); + } + `} + > + {label} + </Text> + </StyledLink> + ))} </Box> - <Text - as="p" - $size="m" - $margin={{ top: 'big' }} - $variation="600" - $display="inline" - className="--docs--footer-licence" - > - {t('Unless otherwise stated, all content on this site is under')}{' '} - <StyledLink - href="https://github.com/etalab/licence-ouverte/blob/master/LO.md" - target="__blank" - $css={` - display:inline-flex; - box-shadow: 0px 1px 0 0 var(--c--theme--colors--greyscale-text); - `} + {bottomInformation && ( + <Text + as="p" + $size="m" + $margin={{ top: 'big' }} + $variation="600" + $display="inline" + className="--docs--footer-licence" > - <Text $variation="600">licence etalab-2.0</Text> - <IconLink width={18} /> - </StyledLink> - </Text> + {bottomInformation.label}{' '} + {bottomInformation.link && ( + <StyledLink + href={bottomInformation.link.href} + target="__blank" + $css={css` + display: inline-flex; + box-shadow: 0px 1px 0 0 + var(--c--theme--colors--greyscale-text); + gap: 0.2rem; + `} + > + <Text $variation="600">{bottomInformation.link.label}</Text> + <IconLink width={14} /> + </StyledLink> + )} + </Text> + )} </Box> </Box> ); diff --git a/src/frontend/apps/impress/src/features/footer/index.tsx b/src/frontend/apps/impress/src/features/footer/index.tsx index ddcc5a9c..166c3c20 100644 --- a/src/frontend/apps/impress/src/features/footer/index.tsx +++ b/src/frontend/apps/impress/src/features/footer/index.tsx @@ -1 +1,2 @@ export * from './Footer'; +export * from './types'; diff --git a/src/frontend/apps/impress/src/features/footer/types.ts b/src/frontend/apps/impress/src/features/footer/types.ts new file mode 100644 index 00000000..10320f18 --- /dev/null +++ b/src/frontend/apps/impress/src/features/footer/types.ts @@ -0,0 +1,28 @@ +export interface FooterType { + default: ContentType; + [key: string]: ContentType; +} + +export interface BottomInformation { + label: string; + link?: Link; +} + +export interface Link { + label: string; + href: string; +} + +export interface Logo { + src: string; + width: string; + alt: string; + withTitle: boolean; +} + +export interface ContentType { + logo?: Logo; + externalLinks?: Link[]; + legalLinks?: Link[]; + bottomInformation?: BottomInformation; +} diff --git a/src/helm/env.d/dev/configuration/theme/demo.json b/src/helm/env.d/dev/configuration/theme/demo.json index aeefb536..29200ada 100644 --- a/src/helm/env.d/dev/configuration/theme/demo.json +++ b/src/helm/env.d/dev/configuration/theme/demo.json @@ -1,6 +1,12 @@ { "footer": { "default": { + "logo": { + "src": "/assets/icon-docs.svg", + "width": "54px", + "alt": "Docs Logo", + "withTitle": true + }, "externalLinks": [ { "label": "Github",