From 0cd54e09e49eb16102ef8174b32d6d9b1b9cae52 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 16 Oct 2025 18:57:38 +0300 Subject: [PATCH 01/15] fix(server): handle unhandledRejection and catch sync SSR errors to avoid crashes Add a global handler for unhandled promise rejections during SSR to log and prevent the server from crashing on common HTTP errors. Wrap loadOnServer in a try/catch to handle synchronous errors (e.g. superagent 404) and route them to the existing error handler. --- src/customizations/volto/server.jsx | 159 ++++++++++++++++------------ 1 file changed, 91 insertions(+), 68 deletions(-) diff --git a/src/customizations/volto/server.jsx b/src/customizations/volto/server.jsx index 08d1af67..ec398b9f 100644 --- a/src/customizations/volto/server.jsx +++ b/src/customizations/volto/server.jsx @@ -45,6 +45,20 @@ import { loadOnServer, } from '@plone/volto/helpers/AsyncConnect'; +// Global handler for unhandled promise rejections during SSR +// This prevents the server from crashing on API errors like 404 +process.on('unhandledRejection', (reason, promise) => { + console.error('[SSR] Unhandled Promise Rejection:', { + reason: reason?.message || reason, + status: reason?.status, + }); + // Don't crash the server for HTTP errors (401, 404, etc.) + const ignoredStatuses = [301, 302, 401, 404]; + if (reason?.status && !ignoredStatuses.includes(reason.status)) { + console.error('[SSR] Full error:', reason); + } +}); + let locales = {}; const isCSP = process.env.CSP_HEADER || config.settings.serverConfig.csp; @@ -251,67 +265,72 @@ server.get('/*', (req, res) => { const url = req.originalUrl || req.url; const location = parseUrl(url); - loadOnServer({ store, location, routes, api }) - .then(() => { - const initialLang = - req.universalCookies.get('I18N_LANGUAGE') || - config.settings.defaultLanguage || - req.headers['accept-language']; - - // The content info is in the store at this point thanks to the asynconnect - // features, then we can force the current language info into the store when - // coming from an SSR request - - // TODO: there is a bug here with content that, for any reason, doesn't - // present the language token field, for some reason. In this case, we - // should follow the cookie rather then switching the language - const contentLang = store.getState().content.get?.error - ? initialLang - : store.getState().content.data?.language?.token || - config.settings.defaultLanguage; - - if (toBackendLang(initialLang) !== contentLang && url !== '/') { - const newLang = toReactIntlLang( - new locale.Locales(contentLang).best(supported).toString(), + // Wrap in try-catch to handle synchronous errors from superagent + try { + loadOnServer({ store, location, routes, api }) + .then(() => { + const initialLang = + req.universalCookies.get('I18N_LANGUAGE') || + config.settings.defaultLanguage || + req.headers['accept-language']; + + // The content info is in the store at this point thanks to the asynconnect + // features, then we can force the current language info into the store when + // coming from an SSR request + + // TODO: there is a bug here with content that, for any reason, doesn't + // present the language token field, for some reason. In this case, we + // should follow the cookie rather then switching the language + const contentLang = store.getState().content.get?.error + ? initialLang + : store.getState().content.data?.language?.token || + config.settings.defaultLanguage; + + if (toBackendLang(initialLang) !== contentLang && url !== '/') { + const newLang = toReactIntlLang( + new locale.Locales(contentLang).best(supported).toString(), + ); + store.dispatch(changeLanguage(newLang, locales[newLang], req)); + } + + const context = {}; + resetServerContext(); + const markup = renderToString( + + + + + + + + + , ); - store.dispatch(changeLanguage(newLang, locales[newLang], req)); - } - - const context = {}; - resetServerContext(); - const markup = renderToString( - - - - - - - - - , - ); - - const readCriticalCss = - config.settings.serverConfig.readCriticalCss || defaultReadCriticalCss; - - // If we are showing an "old browser" warning, - // make sure it doesn't get cached in a shared cache - const browserdetect = store.getState().browserdetect; - if (config.settings.notSupportedBrowsers.includes(browserdetect?.name)) { - res.set({ - 'Cache-Control': 'private', - }); - } - - if (context.url) { - res.redirect(flattenToAppURL(context.url)); - } else if (context.error_code) { - res.set({ - 'Cache-Control': 'no-cache', - }); - - res.status(context.error_code).send( - ` + + const readCriticalCss = + config.settings.serverConfig.readCriticalCss || + defaultReadCriticalCss; + + // If we are showing an "old browser" warning, + // make sure it doesn't get cached in a shared cache + const browserdetect = store.getState().browserdetect; + if ( + config.settings.notSupportedBrowsers.includes(browserdetect?.name) + ) { + res.set({ + 'Cache-Control': 'private', + }); + } + + if (context.url) { + res.redirect(flattenToAppURL(context.url)); + } else if (context.error_code) { + res.set({ + 'Cache-Control': 'no-cache', + }); + + res.status(context.error_code).send( + ` ${renderToString( { />, )} `, - ); - } else { - res.status(200).send( - ` + ); + } else { + res.status(200).send( + ` ${renderToString( { />, )} `, - ); - } - }, errorHandler) - .catch(errorHandler); + ); + } + }, errorHandler) + .catch(errorHandler); + } catch (error) { + // Handle synchronous errors from superagent (e.g., 404 during SSR) + errorHandler(error); + } }); export const defaultReadCriticalCss = () => { From dcb6bfe8b870e6e26f0c5070d45a774255cdc1e9 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 16 Oct 2025 19:52:31 +0300 Subject: [PATCH 02/15] fix(UniversalLink): return null for array hrefs and guard external check - add original tests & snapshots plus the test for array being based within href --- .../manage/UniversalLink/UniversalLink.jsx | 10 +- .../UniversalLink/UniversalLink.test.jsx | 286 ++++++++++++++++++ .../__snapshots__/UniversalLink.test.jsx.snap | 71 +++++ 3 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx create mode 100644 src/customizations/volto/components/manage/UniversalLink/__snapshots__/UniversalLink.test.jsx.snap diff --git a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx index 6fa5c0a7..d022babe 100644 --- a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx +++ b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx @@ -28,6 +28,14 @@ const UniversalLink = ({ }) => { const token = useSelector((state) => state.userSession?.token); + if (Array.isArray(href)) { + // eslint-disable-next-line no-console + console.error( + 'Invalid href passed to UniversalLink, received an array as href instead of a string', + href, + ); + return null; + } let url = href; if (!href && item) { @@ -71,7 +79,7 @@ const UniversalLink = ({ url = url.includes('/@@download/file') ? url : `${url}/@@download/file`; } - const isExternal = !isInternalURL(url); + const isExternal = url && !isInternalURL(url); const isDownload = (!isExternal && url && url.includes('@@download')) || download; diff --git a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx new file mode 100644 index 00000000..51d524bf --- /dev/null +++ b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx @@ -0,0 +1,286 @@ +import React from 'react'; +import renderer from 'react-test-renderer'; +import { Provider } from 'react-intl-redux'; +import configureStore from 'redux-mock-store'; +import { render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import UniversalLink from './UniversalLink'; +import config from '@plone/volto/registry'; + +const mockStore = configureStore(); +const store = mockStore({ + userSession: { + token: null, + }, + intl: { + locale: 'en', + messages: {}, + }, +}); + +global.console.error = jest.fn(); + +describe('UniversalLink', () => { + it('renders a UniversalLink component with internal link', () => { + const component = renderer.create( + + + +

Title

+
+
+
, + ); + const json = component.toJSON(); + expect(json).toMatchSnapshot(); + }); + + it('renders a UniversalLink component with external link', () => { + const component = renderer.create( + + + +

Title

+
+
+
, + ); + const json = component.toJSON(); + expect(json).toMatchSnapshot(); + }); + + it('renders a UniversalLink component if no external(href) link passed', () => { + const component = renderer.create( + + + +

Title

+
+
+
, + ); + const json = component.toJSON(); + expect(json).toMatchSnapshot(); + }); + + it('check UniversalLink set rel attribute for ext links', () => { + const { getByTitle } = render( + + + +

Title

+
+
+
, + ); + + expect(getByTitle('Volto GitHub repository').getAttribute('rel')).toBe( + 'noopener', + ); + }); + + it('check UniversalLink set target attribute for ext links', () => { + const { getByTitle } = render( + + + +

Title

+
+
+
, + ); + + expect(getByTitle('Volto GitHub repository').getAttribute('target')).toBe( + '_blank', + ); + }); + + it('check UniversalLink can unset target for ext links with prop', () => { + const { getByTitle } = render( + + + +

Title

+
+
+
, + ); + + expect(getByTitle('Volto GitHub repository').getAttribute('target')).toBe( + null, + ); + }); + + it('check UniversalLink renders ext link for blacklisted urls', () => { + config.settings.externalRoutes = [ + { + match: { + path: '/external-app', + exact: true, + strict: false, + }, + url(payload) { + return payload.location.pathname; + }, + }, + ]; + + const { getByTitle } = render( + + + +

Title

+
+
+
, + ); + + expect(getByTitle('Blacklisted route').getAttribute('target')).toBe( + '_blank', + ); + }); + + it('UniversalLink renders external link where link is blacklisted', () => { + const notInEN = + /^(?!.*(#|\/en|\/static|\/controlpanel|\/cypress|\/login|\/logout|\/contact-form)).*$/; + config.settings.externalRoutes = [ + { + match: { + path: notInEN, + exact: false, + strict: false, + }, + url(payload) { + return payload.location.pathname; + }, + }, + ]; + + const { getByTitle } = render( + + + +

Title

+
+
+
, + ); + + expect(getByTitle('External blacklisted app').getAttribute('target')).toBe( + '_blank', + ); + expect(getByTitle('External blacklisted app').getAttribute('rel')).toBe( + 'noopener', + ); + }); + + it('check UniversalLink does not break with error in item', () => { + const component = renderer.create( + + + +

Title

+
+
+
, + ); + const json = component.toJSON(); + expect(json).toMatchSnapshot(); + expect(global.console.error).toHaveBeenCalled(); + }); + + it('renders a UniversalLink component when url ends with @@display-file', () => { + const component = renderer.create( + + + +

Title

+
+
+
, + ); + const json = component.toJSON(); + expect(json).toMatchSnapshot(); + }); + + it('returns null when href is an empty array', () => { + const component = renderer.create( + + + +

Title

+
+
+
, + ); + const json = component.toJSON(); + expect(json).toBeNull(); + expect(global.console.error).toHaveBeenCalledWith( + 'Invalid href passed to UniversalLink, received an array as href instead of a string', + [], + ); + }); + + it('returns null when href is a non-empty array', () => { + const invalidHref = ['http://localhost:3000/en/page1', '/en/page2']; + const component = renderer.create( + + + +

Title

+
+
+
, + ); + const json = component.toJSON(); + expect(json).toBeNull(); + expect(global.console.error).toHaveBeenCalledWith( + 'Invalid href passed to UniversalLink, received an array as href instead of a string', + invalidHref, + ); + }); + + it('returns null when href is an array with children elements', () => { + const invalidHref = ['/en/page']; + const { container } = render( + + + +

Title

+

Description

+
+
+
, + ); + expect(container.firstChild).toBeNull(); + expect(global.console.error).toHaveBeenCalledWith( + 'Invalid href passed to UniversalLink, received an array as href instead of a string', + invalidHref, + ); + }); +}); diff --git a/src/customizations/volto/components/manage/UniversalLink/__snapshots__/UniversalLink.test.jsx.snap b/src/customizations/volto/components/manage/UniversalLink/__snapshots__/UniversalLink.test.jsx.snap new file mode 100644 index 00000000..8a75469d --- /dev/null +++ b/src/customizations/volto/components/manage/UniversalLink/__snapshots__/UniversalLink.test.jsx.snap @@ -0,0 +1,71 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UniversalLink check UniversalLink does not break with error in item 1`] = ` + +

+ Title +

+
+`; + +exports[`UniversalLink renders a UniversalLink component if no external(href) link passed 1`] = ` + +

+ Title +

+
+`; + +exports[`UniversalLink renders a UniversalLink component when url ends with @@display-file 1`] = ` + +

+ Title +

+
+`; + +exports[`UniversalLink renders a UniversalLink component with external link 1`] = ` + +

+ Title +

+
+`; + +exports[`UniversalLink renders a UniversalLink component with internal link 1`] = ` + +

+ Title +

+
+`; From 6aaab04402b9a021409cb1157d570fc9c99c8508 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 16 Oct 2025 21:56:23 +0300 Subject: [PATCH 03/15] fix(server): remove global unhandledRejection and use request-scoped SSR error handler with detailed logging - This is to take into consideration the feedback of Github copilot --- src/customizations/volto/server.jsx | 64 ++++++++++++++++++----------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/src/customizations/volto/server.jsx b/src/customizations/volto/server.jsx index ec398b9f..f792ca6a 100644 --- a/src/customizations/volto/server.jsx +++ b/src/customizations/volto/server.jsx @@ -45,20 +45,6 @@ import { loadOnServer, } from '@plone/volto/helpers/AsyncConnect'; -// Global handler for unhandled promise rejections during SSR -// This prevents the server from crashing on API errors like 404 -process.on('unhandledRejection', (reason, promise) => { - console.error('[SSR] Unhandled Promise Rejection:', { - reason: reason?.message || reason, - status: reason?.status, - }); - // Don't crash the server for HTTP errors (401, 404, etc.) - const ignoredStatuses = [301, 302, 401, 404]; - if (reason?.status && !ignoredStatuses.includes(reason.status)) { - console.error('[SSR] Full error:', reason); - } -}); - let locales = {}; const isCSP = process.env.CSP_HEADER || config.settings.serverConfig.csp; @@ -170,7 +156,33 @@ function setupServer(req, res, next) { // and for being used by the rest of the middlewares, if required const store = configureStore(initialState, history, api); + /** + * Request-scoped error handler for SSR errors. + * This function is called when errors occur during server-side rendering, + * including API errors (404, 401, etc.) and rendering errors. + * + * @param {Error} error - The error object with optional status property + */ function errorHandler(error) { + // Log error details for debugging + const ignoredErrors = [301, 302, 401, 404]; + if (!ignoredErrors.includes(error.status)) { + console.error('[SSR Error Handler]', { + url: req.url, + status: error.status, + message: error.message, + stack: error.stack, + }); + } else { + // Log ignored errors at debug level + console.log('[SSR Error Handler] HTTP error:', { + url: req.url, + status: error.status, + message: error.message, + }); + } + + // Render error page const errorPage = ( @@ -183,13 +195,6 @@ function setupServer(req, res, next) { 'Cache-Control': 'public, max-age=60, no-transform', }); - /* Displays error in console - * TODO: - * - get ignored codes from Plone error_log - */ - const ignoredErrors = [301, 302, 401, 404]; - if (!ignoredErrors.includes(error.status)) console.error(error); - res .status(error.status || 500) // If error happens in Volto code itself error status is undefined .send(` ${renderToString(errorPage)}`); @@ -265,7 +270,16 @@ server.get('/*', (req, res) => { const url = req.originalUrl || req.url; const location = parseUrl(url); - // Wrap in try-catch to handle synchronous errors from superagent + /** + * Request-scoped error handling for SSR. + * + * The try-catch block catches synchronous errors (e.g., immediate throws from superagent). + * The .then(success, errorHandler) catches promise rejections from loadOnServer. + * The .catch(errorHandler) is a safety net for any unhandled rejections. + * + * This ensures all errors during SSR are caught and handled within the context + * of this specific request, without affecting other requests or the process. + */ try { loadOnServer({ store, location, routes, api }) .then(() => { @@ -372,7 +386,11 @@ server.get('/*', (req, res) => { }, errorHandler) .catch(errorHandler); } catch (error) { - // Handle synchronous errors from superagent (e.g., 404 during SSR) + /** + * Catch synchronous errors that occur before the promise chain is established. + * This includes immediate throws from superagent when API calls fail synchronously. + * The errorHandler will render an error page and send a proper HTTP response. + */ errorHandler(error); } }); From db402f5a33ee19846f118ba25b292d43d14faae4 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 16 Oct 2025 21:57:18 +0300 Subject: [PATCH 04/15] fix(UniversalLink): guard url before checking for @@display-file to avoid errors when url is undefined - If url is falsy and isExternal is false (now guaranteed by your change on line 82), line 87 will throw a TypeError when attempting to call .includes() on a falsy value. --- .../volto/components/manage/UniversalLink/UniversalLink.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx index d022babe..59397fe0 100644 --- a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx +++ b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx @@ -84,7 +84,7 @@ const UniversalLink = ({ (!isExternal && url && url.includes('@@download')) || download; const isDisplayFile = - (!isExternal && url.includes('@@display-file')) || false; + (!isExternal && url && url.includes('@@display-file')) || false; const checkedURL = URLUtils.checkAndNormalizeUrl(url); // we can receive an item with a linkWithHash property set from ObjectBrowserWidget From 7af4a93fb7d7d240496019fce7e1f6213c0d9c85 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Fri, 17 Oct 2025 15:02:44 +0300 Subject: [PATCH 05/15] fix(UniversalLink): guard url when checking isInternalURL to avoid errors on undefined href --- .../volto/components/manage/UniversalLink/UniversalLink.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx index 59397fe0..05705333 100644 --- a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx +++ b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.jsx @@ -79,7 +79,7 @@ const UniversalLink = ({ url = url.includes('/@@download/file') ? url : `${url}/@@download/file`; } - const isExternal = url && !isInternalURL(url); + const isExternal = url ? !isInternalURL(url) : false; const isDownload = (!isExternal && url && url.includes('@@download')) || download; From a56d7d45a7f6764177e90f4a9a11c5ef62c1f14a Mon Sep 17 00:00:00 2001 From: David Ichim Date: Fri, 17 Oct 2025 15:03:14 +0300 Subject: [PATCH 06/15] feat(docs): add AGENTS.md with repository guidelines (project overview, structure, build/test, coding & testing, PRs, security) --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..81ceacb1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ +# Repository Guidelines + +## Project Overview + +This repository contains the `volto-eea-website-theme`, a Volto addon that provides the website theme for the European Environment Agency (EEA). It includes custom components, styles, and functionality to align with the EEA's branding and requirements. The primary technologies are React (via Volto), LESS for styling, and Plone as the backend CMS. + +## Project Structure & Modules +- `src/`: add-on source (entry: `src/index.js`, config in `src/config.js`). +- `src/components/`, `src/hooks/`, `src/helpers/`, `src/actions/`, `src/reducers/`, `src/icons/`, `src/customizations/`: feature modules. +- `theme/`: LESS/CSS overrides and assets. +- `locales/`: i18n messages. +- `cypress/`: E2E tests; config in `cypress.config.js`. +- Root tooling: `Makefile`, `jest-addon.config.js`, `razzle.extend.js`, `Dockerfile`, `docker-compose.yml`. + +## Build, Test, and Development +- `make`: build Docker images for backend/frontend. +- `make start`: start Plone backend (:8080) and Volto (:3000). +- `make test` / `make test-update`: run Jest / update snapshots. +- `make cypress-open` / `make cypress-run`: open/run Cypress E2E. +- Lint/format: `make lint`, `make lint-fix`, `make stylelint[-fix]`, `make prettier[-fix]`. +- Git hooks: `yarn prepare` (runs Husky setup). + +## Coding Style & Naming +- Language: JS/JSX; 2-space indent; single quotes; Prettier defaults. +- Linting: ESLint extends Volto; Stylelint for `*.css|*.less`. +- Aliases: prefer `@package`, `@plone/volto`, and `~` over deep relative paths. +- Naming: components `PascalCase.jsx`; hooks `useXxx.js`; helpers `camelCase.js`. +- Before push: `make lint-fix && make prettier-fix && make stylelint-fix`. + +## Testing Guidelines +- Unit tests: Jest; place near code under `src/`; name `*.test.js[x]`. +- Coverage: thresholds defined in `jest-addon.config.js` (baseline 5%); raise when adding features. +- E2E: Cypress base URL `http://localhost:3000`; ensure stack is running (`make start`). + +## Commit & Pull Request Guidelines +- Messages: imperative, concise; use types like `feat:`, `fix:`, `change(scope):`, `chore:`; reference issues (`refs #123`, `closes #123`). +- PRs: include summary, linked issues, UI screenshots for visual changes, and notes on testing. Keep scope focused. +- Ensure CI passes: lint, unit tests, and (when relevant) Cypress run. + +## Security & Configuration Tips +- Backend URL via `.env` or Make variables (e.g., `RAZZLE_DEV_PROXY_API_PATH`, default `http://localhost:8080/Plone`). +- CSP/nonces support is present; review `src/config.js` if adjusting headers. +- Never commit secrets; `.env` is ignored. Use environment variables in CI. From 974ca275cf1a813ef4e3e872f94b6a5d7d970fbf Mon Sep 17 00:00:00 2001 From: David Ichim Date: Fri, 17 Oct 2025 15:36:01 +0300 Subject: [PATCH 07/15] docs(AGENTS): specify Volto 17.20.0+ and Plone 6; expand File Structure and Dependencies sections --- AGENTS.md | 42 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 81ceacb1..10c5bde7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,9 +2,10 @@ ## Project Overview -This repository contains the `volto-eea-website-theme`, a Volto addon that provides the website theme for the European Environment Agency (EEA). It includes custom components, styles, and functionality to align with the EEA's branding and requirements. The primary technologies are React (via Volto), LESS for styling, and Plone as the backend CMS. +This repository contains the `volto-eea-website-theme`, a Volto addon that provides the website theme for the European Environment Agency (EEA). It includes custom components, styles, and functionality to align with the EEA's branding and requirements. The primary technologies are React (via Volto 17.20.0+), LESS for styling, and Plone 6 as the backend CMS. ## Project Structure & Modules + - `src/`: add-on source (entry: `src/index.js`, config in `src/config.js`). - `src/components/`, `src/hooks/`, `src/helpers/`, `src/actions/`, `src/reducers/`, `src/icons/`, `src/customizations/`: feature modules. - `theme/`: LESS/CSS overrides and assets. @@ -12,7 +13,42 @@ This repository contains the `volto-eea-website-theme`, a Volto addon that provi - `cypress/`: E2E tests; config in `cypress.config.js`. - Root tooling: `Makefile`, `jest-addon.config.js`, `razzle.extend.js`, `Dockerfile`, `docker-compose.yml`. +## File Structure + +``` +src/ +├── components/ +│ ├── theme/ # Theme-specific components +│ │ ├── Homepage/ # Homepage layouts +│ │ ├── Widgets/ # Custom widgets +│ │ ├── CustomCSS/ # Custom CSS injection +│ │ ├── DraftBackground/ # Draft mode background +│ │ ├── NotFound/ # 404 page +│ │ └── PrintLoader/ # Print functionality +│ └── manage/ # Management components +│ └── Blocks/ # Custom blocks +├── customizations/ # Volto component overrides +│ ├── volto/ # Core Volto customizations +│ └── @root/ # Root-level customizations +├── middleware/ # Express middleware +├── helpers/ # Utility functions +├── reducers/ # Redux reducers +├── slate.js # Slate editor configuration +├── config.js # Theme configuration +└── index.js # Addon configuration +``` + +## Dependencies + +- **@eeacms/volto-anchors**: Anchor link functionality +- **@eeacms/volto-block-style**: Block styling integration +- **@eeacms/volto-block-toc**: Table of contents functionality +- **@eeacms/volto-eea-design-system**: EEA design system components +- **@eeacms/volto-group-block**: Group block functionality +- **volto-subsites**: Subsite management + ## Build, Test, and Development + - `make`: build Docker images for backend/frontend. - `make start`: start Plone backend (:8080) and Volto (:3000). - `make test` / `make test-update`: run Jest / update snapshots. @@ -21,6 +57,7 @@ This repository contains the `volto-eea-website-theme`, a Volto addon that provi - Git hooks: `yarn prepare` (runs Husky setup). ## Coding Style & Naming + - Language: JS/JSX; 2-space indent; single quotes; Prettier defaults. - Linting: ESLint extends Volto; Stylelint for `*.css|*.less`. - Aliases: prefer `@package`, `@plone/volto`, and `~` over deep relative paths. @@ -28,16 +65,19 @@ This repository contains the `volto-eea-website-theme`, a Volto addon that provi - Before push: `make lint-fix && make prettier-fix && make stylelint-fix`. ## Testing Guidelines + - Unit tests: Jest; place near code under `src/`; name `*.test.js[x]`. - Coverage: thresholds defined in `jest-addon.config.js` (baseline 5%); raise when adding features. - E2E: Cypress base URL `http://localhost:3000`; ensure stack is running (`make start`). ## Commit & Pull Request Guidelines + - Messages: imperative, concise; use types like `feat:`, `fix:`, `change(scope):`, `chore:`; reference issues (`refs #123`, `closes #123`). - PRs: include summary, linked issues, UI screenshots for visual changes, and notes on testing. Keep scope focused. - Ensure CI passes: lint, unit tests, and (when relevant) Cypress run. ## Security & Configuration Tips + - Backend URL via `.env` or Make variables (e.g., `RAZZLE_DEV_PROXY_API_PATH`, default `http://localhost:8080/Plone`). - CSP/nonces support is present; review `src/config.js` if adjusting headers. - Never commit secrets; `.env` is ignored. Use environment variables in CI. From c6ae82e4f7be2612b119e673b40be69296e37bb0 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Mon, 20 Oct 2025 22:38:10 +0300 Subject: [PATCH 08/15] fix(print): loading of plotly charts by scrolling to the bottom of the page-document area --- src/helpers/setupPrintView.js | 152 +++++++++++++++++++++++++++++++--- 1 file changed, 141 insertions(+), 11 deletions(-) diff --git a/src/helpers/setupPrintView.js b/src/helpers/setupPrintView.js index 87c2aeed..60ac5047 100644 --- a/src/helpers/setupPrintView.js +++ b/src/helpers/setupPrintView.js @@ -88,22 +88,89 @@ export const setupPrintView = (dispatch) => { return Promise.all(imagePromises); }; - // Wait for plotly charts if they exist - const waitForPlotlyCharts = () => { - const plotlyCharts = document.getElementsByClassName( - 'visualization-wrapper', + // Force load all lazy-loaded blocks by scrolling through the page in steps + const forceLoadLazyBlocks = async () => { + const pageDocument = + document.getElementById('page-document') || document.body; + const scrollHeight = pageDocument.scrollHeight; + const viewportHeight = window.innerHeight; + + // Calculate number of steps needed to scroll through entire page + const steps = Math.ceil(scrollHeight / viewportHeight) + 1; + + // Scroll through the page in steps to trigger lazy loading + for (let i = 0; i <= steps; i++) { + const scrollPosition = (scrollHeight / steps) * i; + window.scrollTo({ top: scrollPosition, behavior: 'instant' }); + + // Small delay to allow IntersectionObserver to trigger + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + // Final scroll to absolute bottom + window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }); + }; + + // Wait for plotly charts to load and re-render in mobile layout + const waitForPlotlyCharts = async () => { + // Give a small delay for isPrint state to propagate and VisibilitySensor to re-render + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Force load all lazy blocks by scrolling through the page + await forceLoadLazyBlocks(); + + // Now wait a bit for the blocks to start loading + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Find all plotly chart containers (now they should be loaded) + const plotlyCharts = document.querySelectorAll( + '.embed-visualization, .plotly-component, .plotly-chart, .treemap-chart', ); + if (plotlyCharts.length === 0) { - return Promise.resolve(); + return; } - // Give plotly charts some time to render - return new Promise((resolve) => setTimeout(resolve, 2000)); + // Check chart loading status periodically + return new Promise((resolve) => { + let checkCount = 0; + const maxChecks = 40; // 40 checks * 250ms = 10 seconds max + const checkInterval = 250; + + const checkChartsLoaded = () => { + checkCount++; + + const allCharts = document.querySelectorAll( + '.embed-visualization, .plotly-component, .plotly-chart, .treemap-chart', + ); + + let loadedCount = 0; + + allCharts.forEach((chart, index) => { + const hasPlotlyDiv = chart.querySelector('.js-plotly-plot'); + + if (hasPlotlyDiv) { + loadedCount++; + } + }); + + // If all charts are loaded or we've reached max checks, resolve + if (loadedCount === allCharts.length || checkCount >= maxChecks) { + resolve(); + } else { + // Continue checking + setTimeout(checkChartsLoaded, checkInterval); + } + }; + + // Start checking after a brief delay to allow initial rendering + setTimeout(checkChartsLoaded, 500); + }); }; // Wait for all content to load before printing const waitForAllContentToLoad = async () => { - // Wait for iframes, images, and Plotly charts to load + // Wait for iframes, images, and Plotly charts to re-render Promise.all([waitForIframes(), waitForImages(), waitForPlotlyCharts()]) .then(() => { // Scroll back to top @@ -114,12 +181,75 @@ export const setupPrintView = (dispatch) => { tab.style.display = ''; }); - // Update state and trigger print + // Keep isPrint=true during printing so charts stay in mobile layout + // Only turn off loading indicator dispatch(setPrintLoading(false)); - dispatch(setIsPrint(false)); + + // Use matchMedia to detect when print is actually happening + const printMediaQuery = window.matchMedia('print'); + let printDialogClosed = false; + + // Function to reset isPrint state + const resetPrintState = () => { + if (printDialogClosed) return; // Prevent multiple resets + printDialogClosed = true; + + dispatch(setIsPrint(false)); + + // Clean up listeners + printMediaQuery.removeEventListener('change', handlePrintMediaChange); + window.removeEventListener('afterprint', handleAfterPrint); + window.removeEventListener('focus', handleWindowFocus); + }; + + // Listen for print media query changes + const handlePrintMediaChange = (e) => { + // When print media query becomes false, the print dialog was closed + if (!e.matches) { + // Add a small delay to ensure the dialog is fully closed + setTimeout(resetPrintState, 100); + } + }; + + // Fallback: afterprint event (unreliable but keep as backup) + const handleAfterPrint = () => { + // Don't reset immediately - wait a bit to see if we're actually done + setTimeout(() => { + // Only reset if print media query is not active + if (!printMediaQuery.matches) { + resetPrintState(); + } + }, 500); + }; + + // Fallback: window focus event (when user cancels or completes print) + const handleWindowFocus = () => { + // Wait a bit to ensure print dialog is closed + setTimeout(() => { + // Only reset if print media query is not active + if (!printMediaQuery.matches) { + resetPrintState(); + } + }, 300); + }; + + // Set up all listeners + printMediaQuery.addEventListener('change', handlePrintMediaChange); + window.addEventListener('afterprint', handleAfterPrint); + // Focus event fires when user returns from print dialog + window.addEventListener('focus', handleWindowFocus, { once: true }); + + // Safety timeout: reset after 30 seconds no matter what + setTimeout(() => { + if (!printDialogClosed) { + resetPrintState(); + } + }, 30000); + + // Trigger print - isPrint remains true during the dialog window.print(); }) - .catch(() => { + .catch((error) => { // Still try to print even if there was an error dispatch(setPrintLoading(false)); dispatch(setIsPrint(false)); From c35da3eea925efe198541a80b29d493c93fc0a81 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 22 Oct 2025 17:22:28 +0300 Subject: [PATCH 09/15] fix(print): await all content before printing and use pageDocument scrollHeight; update test - Convert waitForAllContentToLoad to async/await with try/catch to properly wait for iframes, images and Plotly charts before printing - Use pageDocument.scrollHeight for the final scroll to ensure the correct container is scrolled - Update test to repeatedly flush timers/promises and assert window.scrollTo includes behavior: 'instant' --- src/helpers/setupPrintView.js | 166 +++++++++++++++-------------- src/helpers/setupPrintView.test.js | 15 ++- 2 files changed, 96 insertions(+), 85 deletions(-) diff --git a/src/helpers/setupPrintView.js b/src/helpers/setupPrintView.js index 60ac5047..37e9dd56 100644 --- a/src/helpers/setupPrintView.js +++ b/src/helpers/setupPrintView.js @@ -108,7 +108,7 @@ export const setupPrintView = (dispatch) => { } // Final scroll to absolute bottom - window.scrollTo({ top: document.body.scrollHeight, behavior: 'instant' }); + window.scrollTo({ top: pageDocument.scrollHeight, behavior: 'instant' }); }; // Wait for plotly charts to load and re-render in mobile layout @@ -170,91 +170,95 @@ export const setupPrintView = (dispatch) => { // Wait for all content to load before printing const waitForAllContentToLoad = async () => { - // Wait for iframes, images, and Plotly charts to re-render - Promise.all([waitForIframes(), waitForImages(), waitForPlotlyCharts()]) - .then(() => { - // Scroll back to top - window.scrollTo({ top: 0 }); - - // Reset tab display - Array.from(tabs).forEach((tab) => { - tab.style.display = ''; - }); + try { + // Wait for iframes, images, and Plotly charts to re-render + await Promise.all([ + waitForIframes(), + waitForImages(), + waitForPlotlyCharts(), + ]); + + // Scroll back to top + window.scrollTo({ top: 0 }); + + // Reset tab display + Array.from(tabs).forEach((tab) => { + tab.style.display = ''; + }); + + // Keep isPrint=true during printing so charts stay in mobile layout + // Only turn off loading indicator + dispatch(setPrintLoading(false)); + + // Use matchMedia to detect when print is actually happening + const printMediaQuery = window.matchMedia('print'); + let printDialogClosed = false; + + // Function to reset isPrint state + const resetPrintState = () => { + if (printDialogClosed) return; // Prevent multiple resets + printDialogClosed = true; + + dispatch(setIsPrint(false)); + + // Clean up listeners + printMediaQuery.removeEventListener('change', handlePrintMediaChange); + window.removeEventListener('afterprint', handleAfterPrint); + window.removeEventListener('focus', handleWindowFocus); + }; + + // Listen for print media query changes + const handlePrintMediaChange = (e) => { + // When print media query becomes false, the print dialog was closed + if (!e.matches) { + // Add a small delay to ensure the dialog is fully closed + setTimeout(resetPrintState, 100); + } + }; - // Keep isPrint=true during printing so charts stay in mobile layout - // Only turn off loading indicator - dispatch(setPrintLoading(false)); - - // Use matchMedia to detect when print is actually happening - const printMediaQuery = window.matchMedia('print'); - let printDialogClosed = false; - - // Function to reset isPrint state - const resetPrintState = () => { - if (printDialogClosed) return; // Prevent multiple resets - printDialogClosed = true; - - dispatch(setIsPrint(false)); - - // Clean up listeners - printMediaQuery.removeEventListener('change', handlePrintMediaChange); - window.removeEventListener('afterprint', handleAfterPrint); - window.removeEventListener('focus', handleWindowFocus); - }; - - // Listen for print media query changes - const handlePrintMediaChange = (e) => { - // When print media query becomes false, the print dialog was closed - if (!e.matches) { - // Add a small delay to ensure the dialog is fully closed - setTimeout(resetPrintState, 100); + // Fallback: afterprint event (unreliable but keep as backup) + const handleAfterPrint = () => { + // Don't reset immediately - wait a bit to see if we're actually done + setTimeout(() => { + // Only reset if print media query is not active + if (!printMediaQuery.matches) { + resetPrintState(); } - }; - - // Fallback: afterprint event (unreliable but keep as backup) - const handleAfterPrint = () => { - // Don't reset immediately - wait a bit to see if we're actually done - setTimeout(() => { - // Only reset if print media query is not active - if (!printMediaQuery.matches) { - resetPrintState(); - } - }, 500); - }; - - // Fallback: window focus event (when user cancels or completes print) - const handleWindowFocus = () => { - // Wait a bit to ensure print dialog is closed - setTimeout(() => { - // Only reset if print media query is not active - if (!printMediaQuery.matches) { - resetPrintState(); - } - }, 300); - }; - - // Set up all listeners - printMediaQuery.addEventListener('change', handlePrintMediaChange); - window.addEventListener('afterprint', handleAfterPrint); - // Focus event fires when user returns from print dialog - window.addEventListener('focus', handleWindowFocus, { once: true }); - - // Safety timeout: reset after 30 seconds no matter what + }, 500); + }; + + // Fallback: window focus event (when user cancels or completes print) + const handleWindowFocus = () => { + // Wait a bit to ensure print dialog is closed setTimeout(() => { - if (!printDialogClosed) { + // Only reset if print media query is not active + if (!printMediaQuery.matches) { resetPrintState(); } - }, 30000); - - // Trigger print - isPrint remains true during the dialog - window.print(); - }) - .catch((error) => { - // Still try to print even if there was an error - dispatch(setPrintLoading(false)); - dispatch(setIsPrint(false)); - window.print(); - }); + }, 300); + }; + + // Set up all listeners + printMediaQuery.addEventListener('change', handlePrintMediaChange); + window.addEventListener('afterprint', handleAfterPrint); + // Focus event fires when user returns from print dialog + window.addEventListener('focus', handleWindowFocus, { once: true }); + + // Safety timeout: reset after 30 seconds no matter what + setTimeout(() => { + if (!printDialogClosed) { + resetPrintState(); + } + }, 30000); + + // Trigger print - isPrint remains true during the dialog + window.print(); + } catch (error) { + // Still try to print even if there was an error + dispatch(setPrintLoading(false)); + dispatch(setIsPrint(false)); + window.print(); + } }; // Delay the initial call to ensure everything is rendered diff --git a/src/helpers/setupPrintView.test.js b/src/helpers/setupPrintView.test.js index 7a9d5085..171974e5 100644 --- a/src/helpers/setupPrintView.test.js +++ b/src/helpers/setupPrintView.test.js @@ -35,15 +35,22 @@ describe('setupPrintView', () => { await act(async () => { setupPrintView(dispatch); - jest.runAllTimers(); - await Promise.resolve(); - await Promise.resolve(); + + // Run all timers and flush all promises multiple times + // to handle the async/await chain in forceLoadLazyBlocks and waitForPlotlyCharts + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } }); expect(dispatch).toHaveBeenCalledWith({ type: 'SET_IS_PRINT' }); expect(dispatch).toHaveBeenCalledWith({ type: 'SET_PRINT_LOADING' }); expect(loadLazyImages).toHaveBeenCalled(); - expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }); + expect(window.scrollTo).toHaveBeenCalledWith({ + behavior: 'instant', + top: 0, + }); expect(window.print).toHaveBeenCalled(); }); }); From df9d4656be24b1a787d37b3f3dd2cb9446c54c7d Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 22 Oct 2025 17:23:12 +0300 Subject: [PATCH 10/15] update jest addon with improvements made in volto-listing-block - to be able to call individual tests and properly collect coverage --- jest-addon.config.js | 427 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 421 insertions(+), 6 deletions(-) diff --git a/jest-addon.config.js b/jest-addon.config.js index 8c5f28ad..44c4d8eb 100644 --- a/jest-addon.config.js +++ b/jest-addon.config.js @@ -1,10 +1,415 @@ -require('dotenv').config({ path: __dirname + '/.env' }) +/** + * Generic Jest configuration for Volto addons + * + * This configuration automatically: + * - Detects the addon name from the config file path + * - Configures test coverage to focus on the specific test path + * - Handles different ways of specifying test paths: + * - Full paths like src/addons/addon-name/src/components + * - Just filenames like Component.test.jsx + * - Just directory names like components + * + * Usage: + * RAZZLE_JEST_CONFIG=src/addons/addon-name/jest-addon.config.js CI=true yarn test [test-path] --collectCoverage + */ + +require('dotenv').config({ path: __dirname + '/.env' }); + +const path = require('path'); +const fs = require('fs'); +const fg = require('fast-glob'); + +// Get the addon name from the current file path +const pathParts = __filename.split(path.sep); +const addonsIdx = pathParts.lastIndexOf('addons'); +const addonName = + addonsIdx !== -1 && addonsIdx < pathParts.length - 1 + ? pathParts[addonsIdx + 1] + : path.basename(path.dirname(__filename)); // Fallback to folder name +const addonBasePath = `src/addons/${addonName}/src`; + +// --- Performance caches --- +const fileSearchCache = new Map(); +const dirSearchCache = new Map(); +const dirListingCache = new Map(); +const statCache = new Map(); +const implementationCache = new Map(); + +/** + * Cached fs.statSync wrapper to avoid redundant filesystem calls + * @param {string} p + * @returns {fs.Stats|null} + */ +const getStatSync = (p) => { + if (statCache.has(p)) return statCache.get(p); + try { + const s = fs.statSync(p); + statCache.set(p, s); + return s; + } catch { + statCache.set(p, null); + return null; + } +}; + +/** + * Find files that match a specific pattern using fast-glob + * @param {string} baseDir - The base directory to search in + * @param {string} fileName - The name of the file to find + * @param {string} [pathPattern=''] - Optional path pattern to filter results + * @returns {string[]} - Array of matching file paths + */ +const findFilesWithPattern = (baseDir, fileName, pathPattern = '') => { + const cacheKey = `${baseDir}|${fileName}|${pathPattern}`; + if (fileSearchCache.has(cacheKey)) { + return fileSearchCache.get(cacheKey); + } + + let files = []; + try { + const patterns = fileName + ? [`${baseDir}/**/${fileName}`] + : [`${baseDir}/**/*.{js,jsx,ts,tsx}`]; + + files = fg.sync(patterns, { onlyFiles: true }); + + if (pathPattern) { + files = files.filter((file) => file.includes(pathPattern)); + } + } catch { + files = []; + } + + fileSearchCache.set(cacheKey, files); + return files; +}; + +/** + * Find directories that match a specific pattern using fast-glob + * @param {string} baseDir - The base directory to search in + * @param {string} dirName - The name of the directory to find + * @param {string} [pathPattern=''] - Optional path pattern to filter results + * @returns {string[]} - Array of matching directory paths + */ +const findDirsWithPattern = (baseDir, dirName, pathPattern = '') => { + const cacheKey = `${baseDir}|${dirName}|${pathPattern}`; + if (dirSearchCache.has(cacheKey)) { + return dirSearchCache.get(cacheKey); + } + + let dirs = []; + try { + const patterns = dirName + ? [`${baseDir}/**/${dirName}`] + : [`${baseDir}/**/`]; + + dirs = fg.sync(patterns, { onlyDirectories: true }); + + if (pathPattern) { + dirs = dirs.filter((dir) => dir.includes(pathPattern)); + } + } catch { + dirs = []; + } + + dirSearchCache.set(cacheKey, dirs); + return dirs; +}; + +/** + * Find files or directories in the addon using fast-glob + * @param {string} name - The name to search for + * @param {string} type - The type of item to find ('f' for files, 'd' for directories) + * @param {string} [additionalOptions=''] - Additional options for flexible path matching + * @returns {string|null} - The path of the found item or null if not found + */ +const findInAddon = (name, type, additionalOptions = '') => { + const isFile = type === 'f'; + const isDirectory = type === 'd'; + const isFlexiblePathMatch = additionalOptions.includes('-path'); + + let pathPattern = ''; + if (isFlexiblePathMatch) { + const match = additionalOptions.match(/-path "([^"]+)"/); + if (match && match[1]) { + pathPattern = match[1].replace(/\*/g, ''); + } + } + + try { + let results = []; + if (isFile) { + results = findFilesWithPattern(addonBasePath, name, pathPattern); + } else if (isDirectory) { + results = findDirsWithPattern(addonBasePath, name, pathPattern); + } + return results.length > 0 ? results[0] : null; + } catch (error) { + return null; + } +}; + +/** + * Find the implementation file for a test file + * @param {string} testPath - Path to the test file + * @returns {string|null} - Path to the implementation file or null if not found + */ +const findImplementationFile = (testPath) => { + if (implementationCache.has(testPath)) { + return implementationCache.get(testPath); + } + + if (!fs.existsSync(testPath)) { + implementationCache.set(testPath, null); + return null; + } + + const dirPath = path.dirname(testPath); + const fileName = path.basename(testPath); + + // Regex for common test file patterns (e.g., .test.js, .spec.ts) + const TEST_OR_SPEC_FILE_REGEX = /\.(test|spec)\.[jt]sx?$/; + + if (!TEST_OR_SPEC_FILE_REGEX.test(fileName)) { + implementationCache.set(testPath, null); + return null; + } + + const baseFileName = path + .basename(fileName, path.extname(fileName)) + .replace(/\.(test|spec)$/, ''); // Remove .test or .spec + + let dirFiles = dirListingCache.get(dirPath); + if (!dirFiles) { + dirFiles = fs.readdirSync(dirPath); + dirListingCache.set(dirPath, dirFiles); + } + + const exactMatch = dirFiles.find((file) => { + const fileBaseName = path.basename(file, path.extname(file)); + return ( + fileBaseName === baseFileName && !TEST_OR_SPEC_FILE_REGEX.test(file) // Ensure it's not another test/spec file + ); + }); + + if (exactMatch) { + const result = `${dirPath}/${exactMatch}`; + implementationCache.set(testPath, result); + return result; + } + + const similarMatch = dirFiles.find((file) => { + if ( + TEST_OR_SPEC_FILE_REGEX.test(file) || + (getStatSync(`${dirPath}/${file}`)?.isDirectory() ?? false) + ) { + return false; + } + const fileBaseName = path.basename(file, path.extname(file)); + return ( + fileBaseName.toLowerCase().includes(baseFileName.toLowerCase()) || + baseFileName.toLowerCase().includes(fileBaseName.toLowerCase()) + ); + }); + + if (similarMatch) { + const result = `${dirPath}/${similarMatch}`; + implementationCache.set(testPath, result); + return result; + } + + implementationCache.set(testPath, null); + return null; +}; + +/** + * Get the test path from command line arguments + * @returns {string|null} - The resolved test path or null if not found + */ +const getTestPath = () => { + const args = process.argv; + let testPath = null; + const TEST_FILE_REGEX = /\.test\.[jt]sx?$/; // Matches .test.js, .test.jsx, .test.ts, .test.tsx + + testPath = args.find( + (arg) => + arg.includes(addonName) && + !arg.startsWith('--') && + arg !== 'test' && + arg !== 'node', + ); + + if (!testPath) { + const testIndex = args.findIndex((arg) => arg === 'test'); + if (testIndex !== -1 && testIndex < args.length - 1) { + const nextArg = args[testIndex + 1]; + if (!nextArg.startsWith('--')) { + testPath = nextArg; + } + } + } + + if (!testPath) { + testPath = args.find((arg) => TEST_FILE_REGEX.test(arg)); + } + + if (!testPath) { + return null; + } + + if (!testPath.includes(path.sep)) { + if (TEST_FILE_REGEX.test(testPath)) { + const foundTestFile = findInAddon(testPath, 'f'); + if (foundTestFile) { + return foundTestFile; + } + } else { + const foundDir = findInAddon(testPath, 'd'); + if (foundDir) { + return foundDir; + } + const flexibleDir = findInAddon(testPath, 'd', `-path "*${testPath}*"`); + if (flexibleDir) { + return flexibleDir; + } + } + } else if ( + TEST_FILE_REGEX.test(testPath) && // Check if it looks like a test file path + !testPath.startsWith('src/addons/') + ) { + const testFileName = path.basename(testPath); + const foundTestFile = findInAddon(testFileName, 'f'); + if (foundTestFile) { + const relativePath = path.dirname(testPath); + if (foundTestFile.includes(relativePath)) { + return foundTestFile; + } + const similarFiles = findFilesWithPattern( + addonBasePath, + testFileName, + relativePath, + ); + if (similarFiles && similarFiles.length > 0) { + return similarFiles[0]; + } + } + } + + if ( + !path + .normalize(testPath) + .startsWith(path.join('src', 'addons', addonName, 'src')) && + !path.isAbsolute(testPath) // Use path.isAbsolute for robust check + ) { + testPath = path.join(addonBasePath, testPath); // Use path.join for OS-agnostic paths + } + + if (fs.existsSync(testPath)) { + return testPath; + } + + const pathWithoutTrailingSlash = testPath.endsWith(path.sep) + ? testPath.slice(0, -1) + : null; + if (pathWithoutTrailingSlash && fs.existsSync(pathWithoutTrailingSlash)) { + return pathWithoutTrailingSlash; + } + + const pathWithTrailingSlash = !testPath.endsWith(path.sep) + ? testPath + path.sep + : null; + if (pathWithTrailingSlash && fs.existsSync(pathWithTrailingSlash)) { + // Generally, return paths without trailing slashes for consistency, + // unless it's specifically needed for a directory that only exists with it (rare). + return testPath; + } + return testPath; // Return the original path if no variations exist +}; + +/** + * Determine collectCoverageFrom patterns based on test path + * @returns {string[]} - Array of coverage patterns + */ +const getCoveragePatterns = () => { + const excludePatterns = [ + '!src/**/*.d.ts', + '!**/*.test.{js,jsx,ts,tsx}', + '!**/*.stories.{js,jsx,ts,tsx}', + '!**/*.spec.{js,jsx,ts,tsx}', + ]; + + const defaultPatterns = [ + `${addonBasePath}/**/*.{js,jsx,ts,tsx}`, + ...excludePatterns, + ]; + + const ANY_SCRIPT_FILE_REGEX = /\.[jt]sx?$/; + + const directoryArg = process.argv.find( + (arg) => + !arg.includes(path.sep) && + !arg.startsWith('--') && + arg !== 'test' && + arg !== 'node' && + !ANY_SCRIPT_FILE_REGEX.test(arg) && + ![ + 'yarn', + 'npm', + 'npx', + 'collectCoverage', + 'CI', + 'RAZZLE_JEST_CONFIG', + ].some( + (reserved) => + arg === reserved || arg.startsWith(reserved.split('=')[0] + '='), + ) && + process.argv.indexOf(arg) > + process.argv.findIndex((item) => item === 'test'), + ); + + if (directoryArg) { + const foundDir = findInAddon(directoryArg, 'd'); + if (foundDir) { + return [`${foundDir}/**/*.{js,jsx,ts,tsx}`, ...excludePatterns]; + } + } + + let testPath = getTestPath(); + + if (!testPath) { + return defaultPatterns; + } + + if (testPath.endsWith(path.sep)) { + testPath = testPath.slice(0, -1); + } + + const stats = getStatSync(testPath); + + if (stats && stats.isFile()) { + const implFile = findImplementationFile(testPath); + if (implFile) { + return [implFile, '!src/**/*.d.ts']; + } + const dirPath = path.dirname(testPath); + return [`${dirPath}/**/*.{js,jsx,ts,tsx}`, ...excludePatterns]; + } else if (stats && stats.isDirectory()) { + return [`${testPath}/**/*.{js,jsx,ts,tsx}`, ...excludePatterns]; + } + + return defaultPatterns; +}; + +const coverageConfig = getCoveragePatterns(); module.exports = { testMatch: ['**/src/addons/**/?(*.)+(spec|test).[jt]s?(x)'], - collectCoverageFrom: [ - 'src/addons/**/src/**/*.{js,jsx,ts,tsx}', - '!src/**/*.d.ts', + collectCoverageFrom: coverageConfig, + coveragePathIgnorePatterns: [ + '/node_modules/', + 'schema\\.[jt]s?$', + 'index\\.[jt]s?$', + 'config\\.[jt]sx?$', ], moduleNameMapper: { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', @@ -45,7 +450,17 @@ module.exports = { }, ...(process.env.JEST_USE_SETUP === 'ON' && { setupFilesAfterEnv: [ - '/node_modules/@eeacms/volto-eea-website-theme/jest.setup.js', + fs.existsSync( + path.join( + __dirname, + 'node_modules', + '@eeacms', + addonName, + 'jest.setup.js', + ), + ) + ? `/node_modules/@eeacms/${addonName}/jest.setup.js` + : `/src/addons/${addonName}/jest.setup.js`, ], }), -} +}; From ecff183a23e44e59ac0d689ff1288b6e688465f4 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 22 Oct 2025 19:06:05 +0300 Subject: [PATCH 11/15] test(print): expand setupPrintView tests to cover iframes, images, plotly charts, media events, scrolling and timeout/error cases --- src/helpers/setupPrintView.test.js | 524 +++++++++++++++++++++++++++++ 1 file changed, 524 insertions(+) diff --git a/src/helpers/setupPrintView.test.js b/src/helpers/setupPrintView.test.js index 171974e5..56944e27 100644 --- a/src/helpers/setupPrintView.test.js +++ b/src/helpers/setupPrintView.test.js @@ -12,6 +12,8 @@ jest.mock('@eeacms/volto-eea-website-theme/helpers/loadLazyImages', () => ({ })); describe('setupPrintView', () => { + let matchMediaMock; + beforeEach(() => { document.body.innerHTML = `
@@ -23,6 +25,15 @@ describe('setupPrintView', () => { jest.useFakeTimers(); window.scrollTo = jest.fn(); window.print = jest.fn(); + window.innerHeight = 1000; + + // Mock matchMedia + matchMediaMock = { + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + window.matchMedia = jest.fn(() => matchMediaMock); }); afterEach(() => { @@ -53,4 +64,517 @@ describe('setupPrintView', () => { }); expect(window.print).toHaveBeenCalled(); }); + + it('handles iframes that are already loaded', async () => { + const iframe = document.createElement('iframe'); + Object.defineProperty(iframe, 'contentDocument', { + value: { readyState: 'complete' }, + writable: true, + }); + document.body.appendChild(iframe); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles iframes with load event', async () => { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + // Simulate iframe load event + iframe.dispatchEvent(new Event('load')); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles images that are already loaded', async () => { + const img = document.createElement('img'); + Object.defineProperty(img, 'complete', { + value: true, + writable: true, + }); + document.body.appendChild(img); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles images with load event', async () => { + const img = document.createElement('img'); + Object.defineProperty(img, 'complete', { + value: false, + writable: true, + }); + document.body.appendChild(img); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + // Simulate image load event + img.dispatchEvent(new Event('load')); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles images with error event', async () => { + const img = document.createElement('img'); + Object.defineProperty(img, 'complete', { + value: false, + writable: true, + }); + document.body.appendChild(img); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + // Simulate image error event + img.dispatchEvent(new Event('error')); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles plotly charts and waits for them to load', async () => { + // Create plotly chart containers + const chart1 = document.createElement('div'); + chart1.className = 'embed-visualization'; + const plotlyDiv1 = document.createElement('div'); + plotlyDiv1.className = 'js-plotly-plot'; + chart1.appendChild(plotlyDiv1); + document.body.appendChild(chart1); + + const chart2 = document.createElement('div'); + chart2.className = 'plotly-component'; + const plotlyDiv2 = document.createElement('div'); + plotlyDiv2.className = 'js-plotly-plot'; + chart2.appendChild(plotlyDiv2); + document.body.appendChild(chart2); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 15; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles plotly charts that are still loading', async () => { + // Create plotly chart containers without loaded charts + const chart1 = document.createElement('div'); + chart1.className = 'plotly-chart'; + document.body.appendChild(chart1); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + // Simulate chart loading after some time + setTimeout(() => { + const plotlyDiv = document.createElement('div'); + plotlyDiv.className = 'js-plotly-plot'; + chart1.appendChild(plotlyDiv); + }, 1000); + + for (let i = 0; i < 20; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles page with #page-document element for scrolling', async () => { + const pageDocument = document.createElement('div'); + pageDocument.id = 'page-document'; + Object.defineProperty(pageDocument, 'scrollHeight', { + value: 5000, + writable: true, + }); + document.body.appendChild(pageDocument); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 15; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.scrollTo).toHaveBeenCalled(); + expect(window.print).toHaveBeenCalled(); + }); + + it('handles print media query change event', async () => { + const dispatch = jest.fn(); + let changeHandler; + + matchMediaMock.addEventListener = jest.fn((event, handler) => { + if (event === 'change') { + changeHandler = handler; + } + }); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + + // Simulate print dialog closing (media query becomes false) + if (changeHandler) { + changeHandler({ matches: false }); + jest.runAllTimers(); + } + }); + + expect(window.print).toHaveBeenCalled(); + // Should reset isPrint state after print dialog closes + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_IS_PRINT' }); + }); + + it('handles afterprint event', async () => { + const dispatch = jest.fn(); + let afterPrintHandler; + + const originalAddEventListener = window.addEventListener; + window.addEventListener = jest.fn((event, handler, options) => { + if (event === 'afterprint') { + afterPrintHandler = handler; + } + originalAddEventListener.call(window, event, handler, options); + }); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + + // Simulate afterprint event + if (afterPrintHandler) { + afterPrintHandler(); + jest.runAllTimers(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles window focus event after print', async () => { + const dispatch = jest.fn(); + let focusHandler; + + const originalAddEventListener = window.addEventListener; + window.addEventListener = jest.fn((event, handler, options) => { + if (event === 'focus') { + focusHandler = handler; + } + originalAddEventListener.call(window, event, handler, options); + }); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + + // Simulate window focus event + if (focusHandler) { + focusHandler(); + jest.runAllTimers(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles safety timeout for resetting print state', async () => { + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + + // Fast-forward to trigger the 30-second safety timeout + jest.advanceTimersByTime(30000); + }); + + expect(window.print).toHaveBeenCalled(); + // Should reset isPrint state after timeout + expect(dispatch).toHaveBeenCalledWith({ type: 'SET_IS_PRINT' }); + }); + + it('handles errors during content loading', async () => { + const dispatch = jest.fn(); + + // Create an iframe that will cause an error + const iframe = document.createElement('iframe'); + // Mock contentDocument to be null to trigger error path + Object.defineProperty(iframe, 'contentDocument', { + value: null, + writable: true, + }); + document.body.appendChild(iframe); + + // Mock the iframe to never fire load event, causing timeout + const originalSetTimeout = global.setTimeout; + let timeoutCallback; + global.setTimeout = jest.fn((callback, delay) => { + if (delay === 5000) { + // This is the iframe timeout + timeoutCallback = callback; + } + return originalSetTimeout(callback, delay); + }); + + await act(async () => { + setupPrintView(dispatch); + + // Trigger the iframe timeout to simulate error condition + if (timeoutCallback) { + timeoutCallback(); + } + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + // Should still call print even with timeout + expect(window.print).toHaveBeenCalled(); + + // Restore original setTimeout + global.setTimeout = originalSetTimeout; + }); + + it('prevents multiple resets of print state', async () => { + const dispatch = jest.fn(); + let changeHandler; + + matchMediaMock.addEventListener = jest.fn((event, handler) => { + if (event === 'change') { + changeHandler = handler; + } + }); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + + // Simulate print dialog closing multiple times + if (changeHandler) { + changeHandler({ matches: false }); + jest.runAllTimers(); + changeHandler({ matches: false }); + jest.runAllTimers(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles case with no iframes', async () => { + // Remove all iframes + const iframes = document.getElementsByTagName('iframe'); + while (iframes.length > 0) { + iframes[0].remove(); + } + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles case with no images', async () => { + // Remove all images + const images = document.getElementsByTagName('img'); + while (images.length > 0) { + images[0].remove(); + } + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 10; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('scrolls iframe into view on load', async () => { + const iframe = document.createElement('iframe'); + iframe.scrollIntoView = jest.fn(); + + // Make sure iframe is not considered already loaded + Object.defineProperty(iframe, 'contentDocument', { + value: { readyState: 'loading' }, + writable: true, + }); + + document.body.appendChild(iframe); + + const dispatch = jest.fn(); + let eventDispatched = false; + + await act(async () => { + setupPrintView(dispatch); + + for (let i = 0; i < 15; i++) { + jest.runAllTimers(); + await Promise.resolve(); + + // Dispatch the load event after the first timer run to ensure listener is attached + if (i === 2 && !eventDispatched) { + iframe.dispatchEvent(new Event('load')); + eventDispatched = true; + } + } + }); + + expect(iframe.scrollIntoView).toHaveBeenCalledWith({ + behavior: 'instant', + block: 'nearest', + inline: 'center', + }); + expect(window.print).toHaveBeenCalled(); + }); + + it('continues checking charts until timeout', async () => { + // Create plotly chart containers without loaded charts (will timeout) + const chart1 = document.createElement('div'); + chart1.className = 'plotly-chart'; + document.body.appendChild(chart1); + + const dispatch = jest.fn(); + + await act(async () => { + setupPrintView(dispatch); + + // Run enough iterations to hit the timeout (40 checks) + for (let i = 0; i < 50; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + expect(window.print).toHaveBeenCalled(); + }); + + it('handles actual error in Promise.all', async () => { + const dispatch = jest.fn(); + + // Create an iframe that will reject + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + // Mock scrollIntoView to throw an error + iframe.scrollIntoView = jest.fn(() => { + throw new Error('scrollIntoView error'); + }); + + await act(async () => { + setupPrintView(dispatch); + + // Trigger iframe load which will call scrollIntoView and throw + try { + iframe.dispatchEvent(new Event('load')); + } catch (e) { + // Expected error + } + + for (let i = 0; i < 15; i++) { + jest.runAllTimers(); + await Promise.resolve(); + } + }); + + // Should still call print + expect(window.print).toHaveBeenCalled(); + }); }); From cb23dc86286a0e965b9c915ad05bbbb638028064 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Wed, 22 Oct 2025 20:03:48 +0300 Subject: [PATCH 12/15] test(user-select-widget): add helpers and expand unit tests Add createMockStore, defaultProps and renderWidget test helpers and extend coverage for UserSelectWidget. New tests cover normalization variants (normalizeChoices / normalizeSingleSelectOption), rendering with initial vocabulary state, choice updates, async search interactions (focus/change, noOptionsMessage, clearing selection), lifecycle behaviors (timeouts/unmount, terms cache update), disabled/placeholder states and MenuList handling for large choice sets. --- .../theme/Widgets/UserSelectWidget.test.jsx | 331 +++++++++++++++++- 1 file changed, 330 insertions(+), 1 deletion(-) diff --git a/src/components/theme/Widgets/UserSelectWidget.test.jsx b/src/components/theme/Widgets/UserSelectWidget.test.jsx index b643175d..c9990786 100644 --- a/src/components/theme/Widgets/UserSelectWidget.test.jsx +++ b/src/components/theme/Widgets/UserSelectWidget.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-intl-redux'; -import { waitFor, render, screen } from '@testing-library/react'; +import { waitFor, render, screen, fireEvent } from '@testing-library/react'; import UserSelectWidget, { normalizeChoices, @@ -10,6 +10,53 @@ import UserSelectWidget, { const mockStore = configureStore(); +// Test helpers +const createMockStore = ( + vocabularyItems = [], + vocabularyId = 'plone.app.vocabularies.Users', + widgetId = 'user-field', +) => { + const vocabularyData = + vocabularyItems.length > 0 + ? { + [vocabularyId]: { + subrequests: { + [`widget-${widgetId}-en`]: { items: vocabularyItems }, + }, + }, + } + : {}; + + return mockStore({ + intl: { locale: 'en', messages: {} }, + vocabularies: vocabularyData, + }); +}; + +const defaultProps = { + getVocabulary: jest.fn(() => Promise.resolve({ items: [] })), + getVocabularyTokenTitle: jest.fn(), + widgetOptions: { vocabulary: { '@id': 'plone.app.vocabularies.Users' } }, + id: 'user-field', + title: 'User field', + fieldSet: 'default', + onChange: jest.fn(), +}; + +const renderWidget = (props = {}, storeItems = []) => { + const store = createMockStore(storeItems); + const mergedProps = { ...defaultProps, ...props }; + + return { + ...render( + + + , + ), + store, + }; +}; + jest.mock('@plone/volto/helpers/Loadable/Loadable'); beforeAll( async () => @@ -253,3 +300,285 @@ test('ignores extra nested data in normalizeChoices', () => { }, ]); }); + +// Test componentDidMount with value - test via Redux state +test('renders with initial value from vocabulary state', async () => { + const storeItems = [ + { token: 'user1', title: 'User One', email: 'user1@test.com' }, + ]; + const { container } = renderWidget({ value: ['user1'] }, storeItems); + + await waitFor(() => screen.getByText('User field')); + + const multiValue = container.querySelector( + '.react-select__multi-value__label', + ); + expect(multiValue).toBeTruthy(); + expect(multiValue.textContent).toBeTruthy(); +}); + +// Test componentDidUpdate - termsPairsCache update +test('updates termsPairsCache when choices become available', async () => { + const store = createMockStore(); + const { rerender } = render( + + + , + ); + + const newChoices = [ + { token: 'user1', title: 'User One', email: 'user1@test.com' }, + ]; + + rerender( + + + , + ); + + await waitFor(() => screen.getByText('User field')); +}); + +// Test handleChange - verify component renders with onChange prop +test('component accepts onChange prop', async () => { + const onChange = jest.fn(); + const { container } = renderWidget({ onChange }); + + await waitFor(() => screen.getByText('User field')); + + const selectContainer = container.querySelector('.react-select-container'); + expect(selectContainer).toBeTruthy(); +}); + +// Test that component renders with async select +test('renders with async select for user search', async () => { + const { container } = renderWidget(); + + await waitFor(() => screen.getByText('User field')); + + const selectInput = container.querySelector('.react-select__input input'); + expect(selectInput).toBeTruthy(); +}); + +// Test component with multiple values +test('renders with multiple selected values', async () => { + const storeItems = [ + { token: 'alice', title: 'Alice', email: 'alice@test.com' }, + { token: 'alex', title: 'Alex', email: 'alex@test.com' }, + ]; + const { container } = renderWidget({ value: ['alice', 'alex'] }, storeItems); + + await waitFor(() => screen.getByText('User field')); + + const multiValues = container.querySelectorAll( + '.react-select__multi-value__label', + ); + expect(multiValues.length).toBe(2); +}); + +// Test component with choices from items.choices +test('renders with choices from items.choices prop', async () => { + const items = { + choices: [ + { token: 'test', title: 'Test User', email: 'test@example.com' }, + { token: 'admin', title: 'Admin User', email: 'admin@example.com' }, + ], + }; + const { container } = renderWidget({ items, value: ['test'] }); + + await waitFor(() => screen.getByText('User field')); + + const multiValue = container.querySelector( + '.react-select__multi-value__label', + ); + expect(multiValue).toBeTruthy(); + expect(multiValue.textContent).toBeTruthy(); +}); + +// Test noOptionsMessage based on searchLength +test('shows correct message based on search length', async () => { + const getVocabulary = jest.fn(() => + Promise.resolve({ + items: [], + }), + ); + + const store = mockStore({ + intl: { + locale: 'en', + messages: {}, + }, + vocabularies: {}, + }); + + const { container } = render( + + {}} + /> + , + ); + + await waitFor(() => screen.getByText('User field')); + + const selectInput = container.querySelector('.react-select__input input'); + if (selectInput) { + // Focus to show menu + fireEvent.focus(selectInput); + fireEvent.change(selectInput, { target: { value: 'a' } }); + } + + // Should show "Type text..." for short queries + await waitFor(() => { + const menu = container.querySelector('.react-select__menu'); + if (menu) { + expect(menu.textContent).toContain('Type text'); + } + }); +}); + +// Test componentWillUnmount clears timeout +test('clears timeout on unmount', async () => { + jest.useFakeTimers(); + const { unmount } = renderWidget(); + + await waitFor(() => screen.getByText('User field')); + + unmount(); + jest.useRealTimers(); +}); + +// Test with disabled prop +test('renders disabled select when isDisabled is true', async () => { + const { container } = renderWidget({ isDisabled: true }); + + await waitFor(() => screen.getByText('User field')); + + const selectContainer = container.querySelector('.react-select-container'); + expect(selectContainer).toBeTruthy(); +}); + +// Test with custom placeholder +test('renders custom placeholder', async () => { + renderWidget({ placeholder: 'Choose users...' }); + + await waitFor(() => screen.getByText('User field')); +}); + +// Test normalizeSingleSelectOption with value field +test('normalizes object with value field', () => { + const result = normalizeSingleSelectOption( + { value: 'val1', title: 'Value One', email: 'val1@test.com' }, + { formatMessage: (msg) => msg.defaultMessage }, + ); + + expect(result).toEqual({ + value: 'val1', + label: 'Value One', + email: 'val1@test.com', + }); +}); + +// Test normalizeSingleSelectOption with UID field +test('normalizes object with UID field', () => { + const result = normalizeSingleSelectOption( + { UID: 'uid123', title: 'UID Item', email: 'uid@test.com' }, + { formatMessage: (msg) => msg.defaultMessage }, + ); + + expect(result).toEqual({ + value: 'uid123', + label: 'UID Item', + email: 'uid@test.com', + }); +}); + +// Test normalizeSingleSelectOption with label field +test('normalizes object with label field instead of title', () => { + const result = normalizeSingleSelectOption( + { token: 'tok1', label: 'Label One', email: 'label@test.com' }, + { formatMessage: (msg) => msg.defaultMessage }, + ); + + expect(result).toEqual({ + value: 'tok1', + label: 'Label One', + email: 'label@test.com', + }); +}); + +// Test array with empty title +test('normalizes array with empty title', () => { + const result = normalizeSingleSelectOption(['user8', ''], { + formatMessage: (msg) => msg.defaultMessage, + }); + + expect(result).toEqual({ + value: 'user8', + label: 'user8', + email: '', + }); +}); + +// Test handleChange with null (clearing selection) +test('component can clear selection', async () => { + const onChange = jest.fn(); + const choices = [ + { token: 'user1', title: 'User One', email: 'user1@test.com' }, + ]; + const { container } = renderWidget({ onChange, value: ['user1'], choices }); + + await waitFor(() => screen.getByText('User field')); + + const clearButton = container.querySelector('.react-select__clear-indicator'); + if (clearButton) { + fireEvent.mouseDown(clearButton); + await waitFor(() => expect(onChange).toHaveBeenCalled()); + } +}); + +// Test componentWillUnmount with pending timeout +test('clears pending timeout on unmount during search', async () => { + jest.useFakeTimers(); + const { container, unmount } = renderWidget(); + + await waitFor(() => screen.getByText('User field')); + + const selectInput = container.querySelector('.react-select__input input'); + if (selectInput) { + fireEvent.focus(selectInput); + fireEvent.change(selectInput, { target: { value: 'test' } }); + } + + unmount(); + jest.advanceTimersByTime(500); + jest.useRealTimers(); +}); + +// Test with MenuList component for large choice sets +test('uses MenuList component when choices exceed 25 items', async () => { + const largeChoices = Array.from({ length: 30 }, (_, i) => ({ + token: `user${i}`, + title: `User ${i}`, + email: `user${i}@test.com`, + })); + + const { container } = renderWidget({ choices: largeChoices }); + + await waitFor(() => screen.getByText('User field')); + + const selectContainer = container.querySelector('.react-select-container'); + expect(selectContainer).toBeTruthy(); +}); From c59da2730f2e2eb0fc47edc6d11b0c43e16d8751 Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 23 Oct 2025 16:03:32 +0300 Subject: [PATCH 13/15] move ignoredErrors outside of function to avoid recreation - as suggested by Copilot --- src/customizations/volto/server.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/customizations/volto/server.jsx b/src/customizations/volto/server.jsx index f792ca6a..15d6cff9 100644 --- a/src/customizations/volto/server.jsx +++ b/src/customizations/volto/server.jsx @@ -163,9 +163,9 @@ function setupServer(req, res, next) { * * @param {Error} error - The error object with optional status property */ + const ignoredErrors = [301, 302, 401, 404]; function errorHandler(error) { // Log error details for debugging - const ignoredErrors = [301, 302, 401, 404]; if (!ignoredErrors.includes(error.status)) { console.error('[SSR Error Handler]', { url: req.url, From cddfd3f32b321f8c222691250345d17664cc07bc Mon Sep 17 00:00:00 2001 From: David Ichim Date: Thu, 23 Oct 2025 17:37:05 +0300 Subject: [PATCH 14/15] refactor: reset PDF printing improvements to develop branch, moving that logic to 291995_improve_pdf_printing --- jest-addon.config.js | 427 +------------- .../theme/Widgets/UserSelectWidget.test.jsx | 331 +---------- src/helpers/setupPrintView.js | 188 +----- src/helpers/setupPrintView.test.js | 539 +----------------- 4 files changed, 38 insertions(+), 1447 deletions(-) diff --git a/jest-addon.config.js b/jest-addon.config.js index 44c4d8eb..8c5f28ad 100644 --- a/jest-addon.config.js +++ b/jest-addon.config.js @@ -1,415 +1,10 @@ -/** - * Generic Jest configuration for Volto addons - * - * This configuration automatically: - * - Detects the addon name from the config file path - * - Configures test coverage to focus on the specific test path - * - Handles different ways of specifying test paths: - * - Full paths like src/addons/addon-name/src/components - * - Just filenames like Component.test.jsx - * - Just directory names like components - * - * Usage: - * RAZZLE_JEST_CONFIG=src/addons/addon-name/jest-addon.config.js CI=true yarn test [test-path] --collectCoverage - */ - -require('dotenv').config({ path: __dirname + '/.env' }); - -const path = require('path'); -const fs = require('fs'); -const fg = require('fast-glob'); - -// Get the addon name from the current file path -const pathParts = __filename.split(path.sep); -const addonsIdx = pathParts.lastIndexOf('addons'); -const addonName = - addonsIdx !== -1 && addonsIdx < pathParts.length - 1 - ? pathParts[addonsIdx + 1] - : path.basename(path.dirname(__filename)); // Fallback to folder name -const addonBasePath = `src/addons/${addonName}/src`; - -// --- Performance caches --- -const fileSearchCache = new Map(); -const dirSearchCache = new Map(); -const dirListingCache = new Map(); -const statCache = new Map(); -const implementationCache = new Map(); - -/** - * Cached fs.statSync wrapper to avoid redundant filesystem calls - * @param {string} p - * @returns {fs.Stats|null} - */ -const getStatSync = (p) => { - if (statCache.has(p)) return statCache.get(p); - try { - const s = fs.statSync(p); - statCache.set(p, s); - return s; - } catch { - statCache.set(p, null); - return null; - } -}; - -/** - * Find files that match a specific pattern using fast-glob - * @param {string} baseDir - The base directory to search in - * @param {string} fileName - The name of the file to find - * @param {string} [pathPattern=''] - Optional path pattern to filter results - * @returns {string[]} - Array of matching file paths - */ -const findFilesWithPattern = (baseDir, fileName, pathPattern = '') => { - const cacheKey = `${baseDir}|${fileName}|${pathPattern}`; - if (fileSearchCache.has(cacheKey)) { - return fileSearchCache.get(cacheKey); - } - - let files = []; - try { - const patterns = fileName - ? [`${baseDir}/**/${fileName}`] - : [`${baseDir}/**/*.{js,jsx,ts,tsx}`]; - - files = fg.sync(patterns, { onlyFiles: true }); - - if (pathPattern) { - files = files.filter((file) => file.includes(pathPattern)); - } - } catch { - files = []; - } - - fileSearchCache.set(cacheKey, files); - return files; -}; - -/** - * Find directories that match a specific pattern using fast-glob - * @param {string} baseDir - The base directory to search in - * @param {string} dirName - The name of the directory to find - * @param {string} [pathPattern=''] - Optional path pattern to filter results - * @returns {string[]} - Array of matching directory paths - */ -const findDirsWithPattern = (baseDir, dirName, pathPattern = '') => { - const cacheKey = `${baseDir}|${dirName}|${pathPattern}`; - if (dirSearchCache.has(cacheKey)) { - return dirSearchCache.get(cacheKey); - } - - let dirs = []; - try { - const patterns = dirName - ? [`${baseDir}/**/${dirName}`] - : [`${baseDir}/**/`]; - - dirs = fg.sync(patterns, { onlyDirectories: true }); - - if (pathPattern) { - dirs = dirs.filter((dir) => dir.includes(pathPattern)); - } - } catch { - dirs = []; - } - - dirSearchCache.set(cacheKey, dirs); - return dirs; -}; - -/** - * Find files or directories in the addon using fast-glob - * @param {string} name - The name to search for - * @param {string} type - The type of item to find ('f' for files, 'd' for directories) - * @param {string} [additionalOptions=''] - Additional options for flexible path matching - * @returns {string|null} - The path of the found item or null if not found - */ -const findInAddon = (name, type, additionalOptions = '') => { - const isFile = type === 'f'; - const isDirectory = type === 'd'; - const isFlexiblePathMatch = additionalOptions.includes('-path'); - - let pathPattern = ''; - if (isFlexiblePathMatch) { - const match = additionalOptions.match(/-path "([^"]+)"/); - if (match && match[1]) { - pathPattern = match[1].replace(/\*/g, ''); - } - } - - try { - let results = []; - if (isFile) { - results = findFilesWithPattern(addonBasePath, name, pathPattern); - } else if (isDirectory) { - results = findDirsWithPattern(addonBasePath, name, pathPattern); - } - return results.length > 0 ? results[0] : null; - } catch (error) { - return null; - } -}; - -/** - * Find the implementation file for a test file - * @param {string} testPath - Path to the test file - * @returns {string|null} - Path to the implementation file or null if not found - */ -const findImplementationFile = (testPath) => { - if (implementationCache.has(testPath)) { - return implementationCache.get(testPath); - } - - if (!fs.existsSync(testPath)) { - implementationCache.set(testPath, null); - return null; - } - - const dirPath = path.dirname(testPath); - const fileName = path.basename(testPath); - - // Regex for common test file patterns (e.g., .test.js, .spec.ts) - const TEST_OR_SPEC_FILE_REGEX = /\.(test|spec)\.[jt]sx?$/; - - if (!TEST_OR_SPEC_FILE_REGEX.test(fileName)) { - implementationCache.set(testPath, null); - return null; - } - - const baseFileName = path - .basename(fileName, path.extname(fileName)) - .replace(/\.(test|spec)$/, ''); // Remove .test or .spec - - let dirFiles = dirListingCache.get(dirPath); - if (!dirFiles) { - dirFiles = fs.readdirSync(dirPath); - dirListingCache.set(dirPath, dirFiles); - } - - const exactMatch = dirFiles.find((file) => { - const fileBaseName = path.basename(file, path.extname(file)); - return ( - fileBaseName === baseFileName && !TEST_OR_SPEC_FILE_REGEX.test(file) // Ensure it's not another test/spec file - ); - }); - - if (exactMatch) { - const result = `${dirPath}/${exactMatch}`; - implementationCache.set(testPath, result); - return result; - } - - const similarMatch = dirFiles.find((file) => { - if ( - TEST_OR_SPEC_FILE_REGEX.test(file) || - (getStatSync(`${dirPath}/${file}`)?.isDirectory() ?? false) - ) { - return false; - } - const fileBaseName = path.basename(file, path.extname(file)); - return ( - fileBaseName.toLowerCase().includes(baseFileName.toLowerCase()) || - baseFileName.toLowerCase().includes(fileBaseName.toLowerCase()) - ); - }); - - if (similarMatch) { - const result = `${dirPath}/${similarMatch}`; - implementationCache.set(testPath, result); - return result; - } - - implementationCache.set(testPath, null); - return null; -}; - -/** - * Get the test path from command line arguments - * @returns {string|null} - The resolved test path or null if not found - */ -const getTestPath = () => { - const args = process.argv; - let testPath = null; - const TEST_FILE_REGEX = /\.test\.[jt]sx?$/; // Matches .test.js, .test.jsx, .test.ts, .test.tsx - - testPath = args.find( - (arg) => - arg.includes(addonName) && - !arg.startsWith('--') && - arg !== 'test' && - arg !== 'node', - ); - - if (!testPath) { - const testIndex = args.findIndex((arg) => arg === 'test'); - if (testIndex !== -1 && testIndex < args.length - 1) { - const nextArg = args[testIndex + 1]; - if (!nextArg.startsWith('--')) { - testPath = nextArg; - } - } - } - - if (!testPath) { - testPath = args.find((arg) => TEST_FILE_REGEX.test(arg)); - } - - if (!testPath) { - return null; - } - - if (!testPath.includes(path.sep)) { - if (TEST_FILE_REGEX.test(testPath)) { - const foundTestFile = findInAddon(testPath, 'f'); - if (foundTestFile) { - return foundTestFile; - } - } else { - const foundDir = findInAddon(testPath, 'd'); - if (foundDir) { - return foundDir; - } - const flexibleDir = findInAddon(testPath, 'd', `-path "*${testPath}*"`); - if (flexibleDir) { - return flexibleDir; - } - } - } else if ( - TEST_FILE_REGEX.test(testPath) && // Check if it looks like a test file path - !testPath.startsWith('src/addons/') - ) { - const testFileName = path.basename(testPath); - const foundTestFile = findInAddon(testFileName, 'f'); - if (foundTestFile) { - const relativePath = path.dirname(testPath); - if (foundTestFile.includes(relativePath)) { - return foundTestFile; - } - const similarFiles = findFilesWithPattern( - addonBasePath, - testFileName, - relativePath, - ); - if (similarFiles && similarFiles.length > 0) { - return similarFiles[0]; - } - } - } - - if ( - !path - .normalize(testPath) - .startsWith(path.join('src', 'addons', addonName, 'src')) && - !path.isAbsolute(testPath) // Use path.isAbsolute for robust check - ) { - testPath = path.join(addonBasePath, testPath); // Use path.join for OS-agnostic paths - } - - if (fs.existsSync(testPath)) { - return testPath; - } - - const pathWithoutTrailingSlash = testPath.endsWith(path.sep) - ? testPath.slice(0, -1) - : null; - if (pathWithoutTrailingSlash && fs.existsSync(pathWithoutTrailingSlash)) { - return pathWithoutTrailingSlash; - } - - const pathWithTrailingSlash = !testPath.endsWith(path.sep) - ? testPath + path.sep - : null; - if (pathWithTrailingSlash && fs.existsSync(pathWithTrailingSlash)) { - // Generally, return paths without trailing slashes for consistency, - // unless it's specifically needed for a directory that only exists with it (rare). - return testPath; - } - return testPath; // Return the original path if no variations exist -}; - -/** - * Determine collectCoverageFrom patterns based on test path - * @returns {string[]} - Array of coverage patterns - */ -const getCoveragePatterns = () => { - const excludePatterns = [ - '!src/**/*.d.ts', - '!**/*.test.{js,jsx,ts,tsx}', - '!**/*.stories.{js,jsx,ts,tsx}', - '!**/*.spec.{js,jsx,ts,tsx}', - ]; - - const defaultPatterns = [ - `${addonBasePath}/**/*.{js,jsx,ts,tsx}`, - ...excludePatterns, - ]; - - const ANY_SCRIPT_FILE_REGEX = /\.[jt]sx?$/; - - const directoryArg = process.argv.find( - (arg) => - !arg.includes(path.sep) && - !arg.startsWith('--') && - arg !== 'test' && - arg !== 'node' && - !ANY_SCRIPT_FILE_REGEX.test(arg) && - ![ - 'yarn', - 'npm', - 'npx', - 'collectCoverage', - 'CI', - 'RAZZLE_JEST_CONFIG', - ].some( - (reserved) => - arg === reserved || arg.startsWith(reserved.split('=')[0] + '='), - ) && - process.argv.indexOf(arg) > - process.argv.findIndex((item) => item === 'test'), - ); - - if (directoryArg) { - const foundDir = findInAddon(directoryArg, 'd'); - if (foundDir) { - return [`${foundDir}/**/*.{js,jsx,ts,tsx}`, ...excludePatterns]; - } - } - - let testPath = getTestPath(); - - if (!testPath) { - return defaultPatterns; - } - - if (testPath.endsWith(path.sep)) { - testPath = testPath.slice(0, -1); - } - - const stats = getStatSync(testPath); - - if (stats && stats.isFile()) { - const implFile = findImplementationFile(testPath); - if (implFile) { - return [implFile, '!src/**/*.d.ts']; - } - const dirPath = path.dirname(testPath); - return [`${dirPath}/**/*.{js,jsx,ts,tsx}`, ...excludePatterns]; - } else if (stats && stats.isDirectory()) { - return [`${testPath}/**/*.{js,jsx,ts,tsx}`, ...excludePatterns]; - } - - return defaultPatterns; -}; - -const coverageConfig = getCoveragePatterns(); +require('dotenv').config({ path: __dirname + '/.env' }) module.exports = { testMatch: ['**/src/addons/**/?(*.)+(spec|test).[jt]s?(x)'], - collectCoverageFrom: coverageConfig, - coveragePathIgnorePatterns: [ - '/node_modules/', - 'schema\\.[jt]s?$', - 'index\\.[jt]s?$', - 'config\\.[jt]sx?$', + collectCoverageFrom: [ + 'src/addons/**/src/**/*.{js,jsx,ts,tsx}', + '!src/**/*.d.ts', ], moduleNameMapper: { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', @@ -450,17 +45,7 @@ module.exports = { }, ...(process.env.JEST_USE_SETUP === 'ON' && { setupFilesAfterEnv: [ - fs.existsSync( - path.join( - __dirname, - 'node_modules', - '@eeacms', - addonName, - 'jest.setup.js', - ), - ) - ? `/node_modules/@eeacms/${addonName}/jest.setup.js` - : `/src/addons/${addonName}/jest.setup.js`, + '/node_modules/@eeacms/volto-eea-website-theme/jest.setup.js', ], }), -}; +} diff --git a/src/components/theme/Widgets/UserSelectWidget.test.jsx b/src/components/theme/Widgets/UserSelectWidget.test.jsx index c9990786..b643175d 100644 --- a/src/components/theme/Widgets/UserSelectWidget.test.jsx +++ b/src/components/theme/Widgets/UserSelectWidget.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import configureStore from 'redux-mock-store'; import { Provider } from 'react-intl-redux'; -import { waitFor, render, screen, fireEvent } from '@testing-library/react'; +import { waitFor, render, screen } from '@testing-library/react'; import UserSelectWidget, { normalizeChoices, @@ -10,53 +10,6 @@ import UserSelectWidget, { const mockStore = configureStore(); -// Test helpers -const createMockStore = ( - vocabularyItems = [], - vocabularyId = 'plone.app.vocabularies.Users', - widgetId = 'user-field', -) => { - const vocabularyData = - vocabularyItems.length > 0 - ? { - [vocabularyId]: { - subrequests: { - [`widget-${widgetId}-en`]: { items: vocabularyItems }, - }, - }, - } - : {}; - - return mockStore({ - intl: { locale: 'en', messages: {} }, - vocabularies: vocabularyData, - }); -}; - -const defaultProps = { - getVocabulary: jest.fn(() => Promise.resolve({ items: [] })), - getVocabularyTokenTitle: jest.fn(), - widgetOptions: { vocabulary: { '@id': 'plone.app.vocabularies.Users' } }, - id: 'user-field', - title: 'User field', - fieldSet: 'default', - onChange: jest.fn(), -}; - -const renderWidget = (props = {}, storeItems = []) => { - const store = createMockStore(storeItems); - const mergedProps = { ...defaultProps, ...props }; - - return { - ...render( - - - , - ), - store, - }; -}; - jest.mock('@plone/volto/helpers/Loadable/Loadable'); beforeAll( async () => @@ -300,285 +253,3 @@ test('ignores extra nested data in normalizeChoices', () => { }, ]); }); - -// Test componentDidMount with value - test via Redux state -test('renders with initial value from vocabulary state', async () => { - const storeItems = [ - { token: 'user1', title: 'User One', email: 'user1@test.com' }, - ]; - const { container } = renderWidget({ value: ['user1'] }, storeItems); - - await waitFor(() => screen.getByText('User field')); - - const multiValue = container.querySelector( - '.react-select__multi-value__label', - ); - expect(multiValue).toBeTruthy(); - expect(multiValue.textContent).toBeTruthy(); -}); - -// Test componentDidUpdate - termsPairsCache update -test('updates termsPairsCache when choices become available', async () => { - const store = createMockStore(); - const { rerender } = render( - - - , - ); - - const newChoices = [ - { token: 'user1', title: 'User One', email: 'user1@test.com' }, - ]; - - rerender( - - - , - ); - - await waitFor(() => screen.getByText('User field')); -}); - -// Test handleChange - verify component renders with onChange prop -test('component accepts onChange prop', async () => { - const onChange = jest.fn(); - const { container } = renderWidget({ onChange }); - - await waitFor(() => screen.getByText('User field')); - - const selectContainer = container.querySelector('.react-select-container'); - expect(selectContainer).toBeTruthy(); -}); - -// Test that component renders with async select -test('renders with async select for user search', async () => { - const { container } = renderWidget(); - - await waitFor(() => screen.getByText('User field')); - - const selectInput = container.querySelector('.react-select__input input'); - expect(selectInput).toBeTruthy(); -}); - -// Test component with multiple values -test('renders with multiple selected values', async () => { - const storeItems = [ - { token: 'alice', title: 'Alice', email: 'alice@test.com' }, - { token: 'alex', title: 'Alex', email: 'alex@test.com' }, - ]; - const { container } = renderWidget({ value: ['alice', 'alex'] }, storeItems); - - await waitFor(() => screen.getByText('User field')); - - const multiValues = container.querySelectorAll( - '.react-select__multi-value__label', - ); - expect(multiValues.length).toBe(2); -}); - -// Test component with choices from items.choices -test('renders with choices from items.choices prop', async () => { - const items = { - choices: [ - { token: 'test', title: 'Test User', email: 'test@example.com' }, - { token: 'admin', title: 'Admin User', email: 'admin@example.com' }, - ], - }; - const { container } = renderWidget({ items, value: ['test'] }); - - await waitFor(() => screen.getByText('User field')); - - const multiValue = container.querySelector( - '.react-select__multi-value__label', - ); - expect(multiValue).toBeTruthy(); - expect(multiValue.textContent).toBeTruthy(); -}); - -// Test noOptionsMessage based on searchLength -test('shows correct message based on search length', async () => { - const getVocabulary = jest.fn(() => - Promise.resolve({ - items: [], - }), - ); - - const store = mockStore({ - intl: { - locale: 'en', - messages: {}, - }, - vocabularies: {}, - }); - - const { container } = render( - - {}} - /> - , - ); - - await waitFor(() => screen.getByText('User field')); - - const selectInput = container.querySelector('.react-select__input input'); - if (selectInput) { - // Focus to show menu - fireEvent.focus(selectInput); - fireEvent.change(selectInput, { target: { value: 'a' } }); - } - - // Should show "Type text..." for short queries - await waitFor(() => { - const menu = container.querySelector('.react-select__menu'); - if (menu) { - expect(menu.textContent).toContain('Type text'); - } - }); -}); - -// Test componentWillUnmount clears timeout -test('clears timeout on unmount', async () => { - jest.useFakeTimers(); - const { unmount } = renderWidget(); - - await waitFor(() => screen.getByText('User field')); - - unmount(); - jest.useRealTimers(); -}); - -// Test with disabled prop -test('renders disabled select when isDisabled is true', async () => { - const { container } = renderWidget({ isDisabled: true }); - - await waitFor(() => screen.getByText('User field')); - - const selectContainer = container.querySelector('.react-select-container'); - expect(selectContainer).toBeTruthy(); -}); - -// Test with custom placeholder -test('renders custom placeholder', async () => { - renderWidget({ placeholder: 'Choose users...' }); - - await waitFor(() => screen.getByText('User field')); -}); - -// Test normalizeSingleSelectOption with value field -test('normalizes object with value field', () => { - const result = normalizeSingleSelectOption( - { value: 'val1', title: 'Value One', email: 'val1@test.com' }, - { formatMessage: (msg) => msg.defaultMessage }, - ); - - expect(result).toEqual({ - value: 'val1', - label: 'Value One', - email: 'val1@test.com', - }); -}); - -// Test normalizeSingleSelectOption with UID field -test('normalizes object with UID field', () => { - const result = normalizeSingleSelectOption( - { UID: 'uid123', title: 'UID Item', email: 'uid@test.com' }, - { formatMessage: (msg) => msg.defaultMessage }, - ); - - expect(result).toEqual({ - value: 'uid123', - label: 'UID Item', - email: 'uid@test.com', - }); -}); - -// Test normalizeSingleSelectOption with label field -test('normalizes object with label field instead of title', () => { - const result = normalizeSingleSelectOption( - { token: 'tok1', label: 'Label One', email: 'label@test.com' }, - { formatMessage: (msg) => msg.defaultMessage }, - ); - - expect(result).toEqual({ - value: 'tok1', - label: 'Label One', - email: 'label@test.com', - }); -}); - -// Test array with empty title -test('normalizes array with empty title', () => { - const result = normalizeSingleSelectOption(['user8', ''], { - formatMessage: (msg) => msg.defaultMessage, - }); - - expect(result).toEqual({ - value: 'user8', - label: 'user8', - email: '', - }); -}); - -// Test handleChange with null (clearing selection) -test('component can clear selection', async () => { - const onChange = jest.fn(); - const choices = [ - { token: 'user1', title: 'User One', email: 'user1@test.com' }, - ]; - const { container } = renderWidget({ onChange, value: ['user1'], choices }); - - await waitFor(() => screen.getByText('User field')); - - const clearButton = container.querySelector('.react-select__clear-indicator'); - if (clearButton) { - fireEvent.mouseDown(clearButton); - await waitFor(() => expect(onChange).toHaveBeenCalled()); - } -}); - -// Test componentWillUnmount with pending timeout -test('clears pending timeout on unmount during search', async () => { - jest.useFakeTimers(); - const { container, unmount } = renderWidget(); - - await waitFor(() => screen.getByText('User field')); - - const selectInput = container.querySelector('.react-select__input input'); - if (selectInput) { - fireEvent.focus(selectInput); - fireEvent.change(selectInput, { target: { value: 'test' } }); - } - - unmount(); - jest.advanceTimersByTime(500); - jest.useRealTimers(); -}); - -// Test with MenuList component for large choice sets -test('uses MenuList component when choices exceed 25 items', async () => { - const largeChoices = Array.from({ length: 30 }, (_, i) => ({ - token: `user${i}`, - title: `User ${i}`, - email: `user${i}@test.com`, - })); - - const { container } = renderWidget({ choices: largeChoices }); - - await waitFor(() => screen.getByText('User field')); - - const selectContainer = container.querySelector('.react-select-container'); - expect(selectContainer).toBeTruthy(); -}); diff --git a/src/helpers/setupPrintView.js b/src/helpers/setupPrintView.js index 37e9dd56..87c2aeed 100644 --- a/src/helpers/setupPrintView.js +++ b/src/helpers/setupPrintView.js @@ -88,177 +88,43 @@ export const setupPrintView = (dispatch) => { return Promise.all(imagePromises); }; - // Force load all lazy-loaded blocks by scrolling through the page in steps - const forceLoadLazyBlocks = async () => { - const pageDocument = - document.getElementById('page-document') || document.body; - const scrollHeight = pageDocument.scrollHeight; - const viewportHeight = window.innerHeight; - - // Calculate number of steps needed to scroll through entire page - const steps = Math.ceil(scrollHeight / viewportHeight) + 1; - - // Scroll through the page in steps to trigger lazy loading - for (let i = 0; i <= steps; i++) { - const scrollPosition = (scrollHeight / steps) * i; - window.scrollTo({ top: scrollPosition, behavior: 'instant' }); - - // Small delay to allow IntersectionObserver to trigger - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - // Final scroll to absolute bottom - window.scrollTo({ top: pageDocument.scrollHeight, behavior: 'instant' }); - }; - - // Wait for plotly charts to load and re-render in mobile layout - const waitForPlotlyCharts = async () => { - // Give a small delay for isPrint state to propagate and VisibilitySensor to re-render - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Force load all lazy blocks by scrolling through the page - await forceLoadLazyBlocks(); - - // Now wait a bit for the blocks to start loading - await new Promise((resolve) => setTimeout(resolve, 1000)); - - // Find all plotly chart containers (now they should be loaded) - const plotlyCharts = document.querySelectorAll( - '.embed-visualization, .plotly-component, .plotly-chart, .treemap-chart', + // Wait for plotly charts if they exist + const waitForPlotlyCharts = () => { + const plotlyCharts = document.getElementsByClassName( + 'visualization-wrapper', ); - if (plotlyCharts.length === 0) { - return; + return Promise.resolve(); } - // Check chart loading status periodically - return new Promise((resolve) => { - let checkCount = 0; - const maxChecks = 40; // 40 checks * 250ms = 10 seconds max - const checkInterval = 250; - - const checkChartsLoaded = () => { - checkCount++; - - const allCharts = document.querySelectorAll( - '.embed-visualization, .plotly-component, .plotly-chart, .treemap-chart', - ); - - let loadedCount = 0; - - allCharts.forEach((chart, index) => { - const hasPlotlyDiv = chart.querySelector('.js-plotly-plot'); - - if (hasPlotlyDiv) { - loadedCount++; - } - }); - - // If all charts are loaded or we've reached max checks, resolve - if (loadedCount === allCharts.length || checkCount >= maxChecks) { - resolve(); - } else { - // Continue checking - setTimeout(checkChartsLoaded, checkInterval); - } - }; - - // Start checking after a brief delay to allow initial rendering - setTimeout(checkChartsLoaded, 500); - }); + // Give plotly charts some time to render + return new Promise((resolve) => setTimeout(resolve, 2000)); }; // Wait for all content to load before printing const waitForAllContentToLoad = async () => { - try { - // Wait for iframes, images, and Plotly charts to re-render - await Promise.all([ - waitForIframes(), - waitForImages(), - waitForPlotlyCharts(), - ]); - - // Scroll back to top - window.scrollTo({ top: 0 }); - - // Reset tab display - Array.from(tabs).forEach((tab) => { - tab.style.display = ''; - }); - - // Keep isPrint=true during printing so charts stay in mobile layout - // Only turn off loading indicator - dispatch(setPrintLoading(false)); - - // Use matchMedia to detect when print is actually happening - const printMediaQuery = window.matchMedia('print'); - let printDialogClosed = false; - - // Function to reset isPrint state - const resetPrintState = () => { - if (printDialogClosed) return; // Prevent multiple resets - printDialogClosed = true; + // Wait for iframes, images, and Plotly charts to load + Promise.all([waitForIframes(), waitForImages(), waitForPlotlyCharts()]) + .then(() => { + // Scroll back to top + window.scrollTo({ top: 0 }); + + // Reset tab display + Array.from(tabs).forEach((tab) => { + tab.style.display = ''; + }); + // Update state and trigger print + dispatch(setPrintLoading(false)); dispatch(setIsPrint(false)); - - // Clean up listeners - printMediaQuery.removeEventListener('change', handlePrintMediaChange); - window.removeEventListener('afterprint', handleAfterPrint); - window.removeEventListener('focus', handleWindowFocus); - }; - - // Listen for print media query changes - const handlePrintMediaChange = (e) => { - // When print media query becomes false, the print dialog was closed - if (!e.matches) { - // Add a small delay to ensure the dialog is fully closed - setTimeout(resetPrintState, 100); - } - }; - - // Fallback: afterprint event (unreliable but keep as backup) - const handleAfterPrint = () => { - // Don't reset immediately - wait a bit to see if we're actually done - setTimeout(() => { - // Only reset if print media query is not active - if (!printMediaQuery.matches) { - resetPrintState(); - } - }, 500); - }; - - // Fallback: window focus event (when user cancels or completes print) - const handleWindowFocus = () => { - // Wait a bit to ensure print dialog is closed - setTimeout(() => { - // Only reset if print media query is not active - if (!printMediaQuery.matches) { - resetPrintState(); - } - }, 300); - }; - - // Set up all listeners - printMediaQuery.addEventListener('change', handlePrintMediaChange); - window.addEventListener('afterprint', handleAfterPrint); - // Focus event fires when user returns from print dialog - window.addEventListener('focus', handleWindowFocus, { once: true }); - - // Safety timeout: reset after 30 seconds no matter what - setTimeout(() => { - if (!printDialogClosed) { - resetPrintState(); - } - }, 30000); - - // Trigger print - isPrint remains true during the dialog - window.print(); - } catch (error) { - // Still try to print even if there was an error - dispatch(setPrintLoading(false)); - dispatch(setIsPrint(false)); - window.print(); - } + window.print(); + }) + .catch(() => { + // Still try to print even if there was an error + dispatch(setPrintLoading(false)); + dispatch(setIsPrint(false)); + window.print(); + }); }; // Delay the initial call to ensure everything is rendered diff --git a/src/helpers/setupPrintView.test.js b/src/helpers/setupPrintView.test.js index 56944e27..7a9d5085 100644 --- a/src/helpers/setupPrintView.test.js +++ b/src/helpers/setupPrintView.test.js @@ -12,8 +12,6 @@ jest.mock('@eeacms/volto-eea-website-theme/helpers/loadLazyImages', () => ({ })); describe('setupPrintView', () => { - let matchMediaMock; - beforeEach(() => { document.body.innerHTML = `
@@ -25,15 +23,6 @@ describe('setupPrintView', () => { jest.useFakeTimers(); window.scrollTo = jest.fn(); window.print = jest.fn(); - window.innerHeight = 1000; - - // Mock matchMedia - matchMediaMock = { - matches: false, - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - }; - window.matchMedia = jest.fn(() => matchMediaMock); }); afterEach(() => { @@ -46,535 +35,15 @@ describe('setupPrintView', () => { await act(async () => { setupPrintView(dispatch); - - // Run all timers and flush all promises multiple times - // to handle the async/await chain in forceLoadLazyBlocks and waitForPlotlyCharts - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } + jest.runAllTimers(); + await Promise.resolve(); + await Promise.resolve(); }); expect(dispatch).toHaveBeenCalledWith({ type: 'SET_IS_PRINT' }); expect(dispatch).toHaveBeenCalledWith({ type: 'SET_PRINT_LOADING' }); expect(loadLazyImages).toHaveBeenCalled(); - expect(window.scrollTo).toHaveBeenCalledWith({ - behavior: 'instant', - top: 0, - }); - expect(window.print).toHaveBeenCalled(); - }); - - it('handles iframes that are already loaded', async () => { - const iframe = document.createElement('iframe'); - Object.defineProperty(iframe, 'contentDocument', { - value: { readyState: 'complete' }, - writable: true, - }); - document.body.appendChild(iframe); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles iframes with load event', async () => { - const iframe = document.createElement('iframe'); - document.body.appendChild(iframe); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - // Simulate iframe load event - iframe.dispatchEvent(new Event('load')); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles images that are already loaded', async () => { - const img = document.createElement('img'); - Object.defineProperty(img, 'complete', { - value: true, - writable: true, - }); - document.body.appendChild(img); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles images with load event', async () => { - const img = document.createElement('img'); - Object.defineProperty(img, 'complete', { - value: false, - writable: true, - }); - document.body.appendChild(img); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - // Simulate image load event - img.dispatchEvent(new Event('load')); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles images with error event', async () => { - const img = document.createElement('img'); - Object.defineProperty(img, 'complete', { - value: false, - writable: true, - }); - document.body.appendChild(img); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - // Simulate image error event - img.dispatchEvent(new Event('error')); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles plotly charts and waits for them to load', async () => { - // Create plotly chart containers - const chart1 = document.createElement('div'); - chart1.className = 'embed-visualization'; - const plotlyDiv1 = document.createElement('div'); - plotlyDiv1.className = 'js-plotly-plot'; - chart1.appendChild(plotlyDiv1); - document.body.appendChild(chart1); - - const chart2 = document.createElement('div'); - chart2.className = 'plotly-component'; - const plotlyDiv2 = document.createElement('div'); - plotlyDiv2.className = 'js-plotly-plot'; - chart2.appendChild(plotlyDiv2); - document.body.appendChild(chart2); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 15; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles plotly charts that are still loading', async () => { - // Create plotly chart containers without loaded charts - const chart1 = document.createElement('div'); - chart1.className = 'plotly-chart'; - document.body.appendChild(chart1); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - // Simulate chart loading after some time - setTimeout(() => { - const plotlyDiv = document.createElement('div'); - plotlyDiv.className = 'js-plotly-plot'; - chart1.appendChild(plotlyDiv); - }, 1000); - - for (let i = 0; i < 20; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles page with #page-document element for scrolling', async () => { - const pageDocument = document.createElement('div'); - pageDocument.id = 'page-document'; - Object.defineProperty(pageDocument, 'scrollHeight', { - value: 5000, - writable: true, - }); - document.body.appendChild(pageDocument); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 15; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.scrollTo).toHaveBeenCalled(); - expect(window.print).toHaveBeenCalled(); - }); - - it('handles print media query change event', async () => { - const dispatch = jest.fn(); - let changeHandler; - - matchMediaMock.addEventListener = jest.fn((event, handler) => { - if (event === 'change') { - changeHandler = handler; - } - }); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - - // Simulate print dialog closing (media query becomes false) - if (changeHandler) { - changeHandler({ matches: false }); - jest.runAllTimers(); - } - }); - - expect(window.print).toHaveBeenCalled(); - // Should reset isPrint state after print dialog closes - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_IS_PRINT' }); - }); - - it('handles afterprint event', async () => { - const dispatch = jest.fn(); - let afterPrintHandler; - - const originalAddEventListener = window.addEventListener; - window.addEventListener = jest.fn((event, handler, options) => { - if (event === 'afterprint') { - afterPrintHandler = handler; - } - originalAddEventListener.call(window, event, handler, options); - }); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - - // Simulate afterprint event - if (afterPrintHandler) { - afterPrintHandler(); - jest.runAllTimers(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles window focus event after print', async () => { - const dispatch = jest.fn(); - let focusHandler; - - const originalAddEventListener = window.addEventListener; - window.addEventListener = jest.fn((event, handler, options) => { - if (event === 'focus') { - focusHandler = handler; - } - originalAddEventListener.call(window, event, handler, options); - }); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - - // Simulate window focus event - if (focusHandler) { - focusHandler(); - jest.runAllTimers(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles safety timeout for resetting print state', async () => { - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - - // Fast-forward to trigger the 30-second safety timeout - jest.advanceTimersByTime(30000); - }); - - expect(window.print).toHaveBeenCalled(); - // Should reset isPrint state after timeout - expect(dispatch).toHaveBeenCalledWith({ type: 'SET_IS_PRINT' }); - }); - - it('handles errors during content loading', async () => { - const dispatch = jest.fn(); - - // Create an iframe that will cause an error - const iframe = document.createElement('iframe'); - // Mock contentDocument to be null to trigger error path - Object.defineProperty(iframe, 'contentDocument', { - value: null, - writable: true, - }); - document.body.appendChild(iframe); - - // Mock the iframe to never fire load event, causing timeout - const originalSetTimeout = global.setTimeout; - let timeoutCallback; - global.setTimeout = jest.fn((callback, delay) => { - if (delay === 5000) { - // This is the iframe timeout - timeoutCallback = callback; - } - return originalSetTimeout(callback, delay); - }); - - await act(async () => { - setupPrintView(dispatch); - - // Trigger the iframe timeout to simulate error condition - if (timeoutCallback) { - timeoutCallback(); - } - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - // Should still call print even with timeout - expect(window.print).toHaveBeenCalled(); - - // Restore original setTimeout - global.setTimeout = originalSetTimeout; - }); - - it('prevents multiple resets of print state', async () => { - const dispatch = jest.fn(); - let changeHandler; - - matchMediaMock.addEventListener = jest.fn((event, handler) => { - if (event === 'change') { - changeHandler = handler; - } - }); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - - // Simulate print dialog closing multiple times - if (changeHandler) { - changeHandler({ matches: false }); - jest.runAllTimers(); - changeHandler({ matches: false }); - jest.runAllTimers(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles case with no iframes', async () => { - // Remove all iframes - const iframes = document.getElementsByTagName('iframe'); - while (iframes.length > 0) { - iframes[0].remove(); - } - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles case with no images', async () => { - // Remove all images - const images = document.getElementsByTagName('img'); - while (images.length > 0) { - images[0].remove(); - } - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 10; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('scrolls iframe into view on load', async () => { - const iframe = document.createElement('iframe'); - iframe.scrollIntoView = jest.fn(); - - // Make sure iframe is not considered already loaded - Object.defineProperty(iframe, 'contentDocument', { - value: { readyState: 'loading' }, - writable: true, - }); - - document.body.appendChild(iframe); - - const dispatch = jest.fn(); - let eventDispatched = false; - - await act(async () => { - setupPrintView(dispatch); - - for (let i = 0; i < 15; i++) { - jest.runAllTimers(); - await Promise.resolve(); - - // Dispatch the load event after the first timer run to ensure listener is attached - if (i === 2 && !eventDispatched) { - iframe.dispatchEvent(new Event('load')); - eventDispatched = true; - } - } - }); - - expect(iframe.scrollIntoView).toHaveBeenCalledWith({ - behavior: 'instant', - block: 'nearest', - inline: 'center', - }); - expect(window.print).toHaveBeenCalled(); - }); - - it('continues checking charts until timeout', async () => { - // Create plotly chart containers without loaded charts (will timeout) - const chart1 = document.createElement('div'); - chart1.className = 'plotly-chart'; - document.body.appendChild(chart1); - - const dispatch = jest.fn(); - - await act(async () => { - setupPrintView(dispatch); - - // Run enough iterations to hit the timeout (40 checks) - for (let i = 0; i < 50; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - expect(window.print).toHaveBeenCalled(); - }); - - it('handles actual error in Promise.all', async () => { - const dispatch = jest.fn(); - - // Create an iframe that will reject - const iframe = document.createElement('iframe'); - document.body.appendChild(iframe); - - // Mock scrollIntoView to throw an error - iframe.scrollIntoView = jest.fn(() => { - throw new Error('scrollIntoView error'); - }); - - await act(async () => { - setupPrintView(dispatch); - - // Trigger iframe load which will call scrollIntoView and throw - try { - iframe.dispatchEvent(new Event('load')); - } catch (e) { - // Expected error - } - - for (let i = 0; i < 15; i++) { - jest.runAllTimers(); - await Promise.resolve(); - } - }); - - // Should still call print + expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 }); expect(window.print).toHaveBeenCalled(); }); }); From a51032c376bba0a4863850ce63271508f56efc7d Mon Sep 17 00:00:00 2001 From: David Ichim Date: Mon, 17 Nov 2025 16:07:24 +0200 Subject: [PATCH 15/15] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../manage/UniversalLink/UniversalLink.test.jsx | 9 ++++++--- src/customizations/volto/server.jsx | 9 ++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx index 51d524bf..88693666 100644 --- a/src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx +++ b/src/customizations/volto/components/manage/UniversalLink/UniversalLink.test.jsx @@ -21,6 +21,9 @@ const store = mockStore({ global.console.error = jest.fn(); describe('UniversalLink', () => { + beforeEach(() => { + global.console.error.mockClear(); + }); it('renders a UniversalLink component with internal link', () => { const component = renderer.create( @@ -67,7 +70,7 @@ describe('UniversalLink', () => { expect(json).toMatchSnapshot(); }); - it('check UniversalLink set rel attribute for ext links', () => { + it('checks that UniversalLink sets rel attribute for external links', () => { const { getByTitle } = render( @@ -86,7 +89,7 @@ describe('UniversalLink', () => { ); }); - it('check UniversalLink set target attribute for ext links', () => { + it('checks that UniversalLink sets target attribute for external links', () => { const { getByTitle } = render( @@ -125,7 +128,7 @@ describe('UniversalLink', () => { ); }); - it('check UniversalLink renders ext link for blacklisted urls', () => { + it('checks that UniversalLink renders external link for blacklisted URLs', () => { config.settings.externalRoutes = [ { match: { diff --git a/src/customizations/volto/server.jsx b/src/customizations/volto/server.jsx index 15d6cff9..cb367dcb 100644 --- a/src/customizations/volto/server.jsx +++ b/src/customizations/volto/server.jsx @@ -275,7 +275,9 @@ server.get('/*', (req, res) => { * * The try-catch block catches synchronous errors (e.g., immediate throws from superagent). * The .then(success, errorHandler) catches promise rejections from loadOnServer. - * The .catch(errorHandler) is a safety net for any unhandled rejections. + * Note: If an error occurs inside the success callback (lines 285-385), it will be caught by + * the subsequent .catch(errorHandler) below. Both handlers call the same errorHandler function, + * so ensure errorHandler is idempotent to avoid duplicate error handling. * * This ensures all errors during SSR are caught and handled within the context * of this specific request, without affecting other requests or the process. @@ -387,8 +389,9 @@ server.get('/*', (req, res) => { .catch(errorHandler); } catch (error) { /** - * Catch synchronous errors that occur before the promise chain is established. - * This includes immediate throws from superagent when API calls fail synchronously. + * Catch errors thrown during the initial call to loadOnServer, + * such as syntax errors or immediate throws in its setup. + * This does not catch promise rejections from asynchronous operations (e.g., superagent). * The errorHandler will render an error page and send a proper HTTP response. */ errorHandler(error);