@eeacms/volto-eea-website-theme 3.7.0 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -2
- package/package.json +3 -1
- package/src/actions/print.js +9 -1
- package/src/components/manage/Blocks/ContextNavigation/variations/Accordion.jsx +4 -1
- package/src/components/theme/Banner/View.jsx +11 -92
- package/src/components/theme/PrintLoader/PrintLoader.jsx +56 -0
- package/src/components/theme/PrintLoader/PrintLoader.test.jsx +91 -0
- package/src/components/theme/PrintLoader/style.less +12 -0
- package/src/components/theme/Widgets/ImageViewWidget.test.jsx +26 -0
- package/src/constants/ActionTypes.js +1 -0
- package/src/customizations/volto/components/theme/View/DefaultView.jsx +1 -0
- package/src/customizations/volto/helpers/Html/Html.jsx +212 -0
- package/src/customizations/volto/helpers/Html/Readme.md +1 -0
- package/src/customizations/volto/server.jsx +375 -0
- package/src/helpers/loadLazyImages.js +11 -0
- package/src/helpers/loadLazyImages.test.js +22 -0
- package/src/helpers/setupPrintView.js +134 -0
- package/src/helpers/setupPrintView.test.js +49 -0
- package/src/index.js +5 -0
- package/src/index.test.js +6 -0
- package/src/reducers/print.js +18 -8
package/CHANGELOG.md
CHANGED
@@ -4,11 +4,18 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
4
4
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
6
6
|
|
7
|
-
### [3.
|
7
|
+
### [3.8.0](https://github.com/eea/volto-eea-website-theme/compare/3.7.0...3.8.0) - 3 July 2025
|
8
|
+
|
9
|
+
#### :rocket: New Features
|
10
|
+
|
11
|
+
- feat(side-nav): allow content to be inserted before instead of the default append [David Ichim - [`6e184d2`](https://github.com/eea/volto-eea-website-theme/commit/6e184d2dfff0ee693b2d27527fa538dce8bdede7)]
|
8
12
|
|
9
13
|
#### :hammer_and_wrench: Others
|
10
14
|
|
11
|
-
-
|
15
|
+
- Update package.json [David Ichim - [`af355dd`](https://github.com/eea/volto-eea-website-theme/commit/af355dda36692e048779abc80d968096a0cc3c49)]
|
16
|
+
- add dependency on volto-anchors since we have a require which we catch [David Ichim - [`135db9f`](https://github.com/eea/volto-eea-website-theme/commit/135db9fc4d50ef535ef80a499ddd3c9d87dabac6)]
|
17
|
+
### [3.7.0](https://github.com/eea/volto-eea-website-theme/compare/3.6.3...3.7.0) - 6 June 2025
|
18
|
+
|
12
19
|
### [3.6.3](https://github.com/eea/volto-eea-website-theme/compare/3.6.2...3.6.3) - 2 June 2025
|
13
20
|
|
14
21
|
#### :bug: Bug Fixes
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@eeacms/volto-eea-website-theme",
|
3
|
-
"version": "3.
|
3
|
+
"version": "3.8.0",
|
4
4
|
"description": "@eeacms/volto-eea-website-theme: Volto add-on",
|
5
5
|
"main": "src/index.js",
|
6
6
|
"author": "European Environment Agency: IDM2 A-Team",
|
@@ -14,6 +14,7 @@
|
|
14
14
|
"react"
|
15
15
|
],
|
16
16
|
"addons": [
|
17
|
+
"@eeacms/volto-anchors",
|
17
18
|
"@eeacms/volto-block-toc",
|
18
19
|
"@eeacms/volto-group-block",
|
19
20
|
"@eeacms/volto-eea-design-system",
|
@@ -24,6 +25,7 @@
|
|
24
25
|
"url": "git@github.com:eea/volto-eea-website-theme.git"
|
25
26
|
},
|
26
27
|
"dependencies": {
|
28
|
+
"@eeacms/volto-anchors": "*",
|
27
29
|
"@eeacms/volto-block-style": "*",
|
28
30
|
"@eeacms/volto-block-toc": "*",
|
29
31
|
"@eeacms/volto-eea-design-system": "*",
|
package/src/actions/print.js
CHANGED
@@ -3,7 +3,10 @@
|
|
3
3
|
* @module actions/print
|
4
4
|
*/
|
5
5
|
|
6
|
-
import {
|
6
|
+
import {
|
7
|
+
SET_ISPRINT,
|
8
|
+
SET_PRINT_LOADING,
|
9
|
+
} from '@eeacms/volto-eea-website-theme/constants/ActionTypes';
|
7
10
|
|
8
11
|
export const setIsPrint = (data) => {
|
9
12
|
return {
|
@@ -11,3 +14,8 @@ export const setIsPrint = (data) => {
|
|
11
14
|
payload: data,
|
12
15
|
};
|
13
16
|
};
|
17
|
+
|
18
|
+
export const setPrintLoading = (isLoading) => ({
|
19
|
+
type: SET_PRINT_LOADING,
|
20
|
+
payload: isLoading,
|
21
|
+
});
|
@@ -24,10 +24,12 @@ const AccordionNavigation = ({
|
|
24
24
|
navigation = {},
|
25
25
|
device,
|
26
26
|
isMenuOpenOnOutsideClick,
|
27
|
+
insertBefore,
|
27
28
|
}) => {
|
28
29
|
const { items = [], title, has_custom_name } = navigation;
|
29
30
|
const intl = useIntl();
|
30
|
-
const navOpen =
|
31
|
+
const navOpen =
|
32
|
+
['mobile', 'tablet'].includes(device) || insertBefore ? false : true;
|
31
33
|
const [isNavOpen, setIsNavOpen] = React.useState(navOpen);
|
32
34
|
const [activeItems, setActiveItems] = React.useState({});
|
33
35
|
const contextNavigationListRef = React.useRef(null);
|
@@ -220,6 +222,7 @@ export default compose(
|
|
220
222
|
targetParent: '.eea.header ',
|
221
223
|
fixedVisibilitySwitchTarget: '.main.bar',
|
222
224
|
insertBeforeOnMobile: '.banner',
|
225
|
+
insertBefore: props.insertBefore,
|
223
226
|
shouldRender: props.navigation?.items?.length > 0,
|
224
227
|
}),
|
225
228
|
)(AccordionNavigation);
|
@@ -5,7 +5,7 @@ import { connect, useDispatch } from 'react-redux';
|
|
5
5
|
import { withRouter } from 'react-router';
|
6
6
|
import { defineMessages, injectIntl } from 'react-intl';
|
7
7
|
import startCase from 'lodash/startCase';
|
8
|
-
import { Icon
|
8
|
+
import { Icon } from 'semantic-ui-react';
|
9
9
|
import Popup from '@eeacms/volto-eea-design-system/ui/Popup/Popup';
|
10
10
|
import config from '@plone/volto/registry';
|
11
11
|
import Banner from '@eeacms/volto-eea-design-system/ui/Banner/Banner';
|
@@ -14,8 +14,8 @@ import {
|
|
14
14
|
sharePage,
|
15
15
|
} from '@eeacms/volto-eea-design-system/ui/Banner/Banner';
|
16
16
|
import Copyright from '@eeacms/volto-eea-design-system/ui/Copyright/Copyright';
|
17
|
-
import {
|
18
|
-
|
17
|
+
import { setupPrintView } from '@eeacms/volto-eea-website-theme/helpers/setupPrintView';
|
18
|
+
|
19
19
|
import './styles.less';
|
20
20
|
|
21
21
|
const messages = defineMessages({
|
@@ -173,95 +173,14 @@ const View = (props) => {
|
|
173
173
|
</>
|
174
174
|
)}
|
175
175
|
{!hideDownloadButton && (
|
176
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
Array.from(tabs).forEach((tab) => {
|
185
|
-
tab.style.display = 'block';
|
186
|
-
});
|
187
|
-
|
188
|
-
dispatch(setIsPrint(true));
|
189
|
-
// display loader
|
190
|
-
const printLoader = document.getElementById(
|
191
|
-
'download-print-loader',
|
192
|
-
);
|
193
|
-
printLoader.style.display = 'flex';
|
194
|
-
|
195
|
-
let timeoutValue = 1000;
|
196
|
-
// if we have plotlycharts increase timeout
|
197
|
-
setTimeout(() => {
|
198
|
-
const plotlyCharts = document.getElementsByClassName(
|
199
|
-
'visualization-wrapper',
|
200
|
-
);
|
201
|
-
if (plotlyCharts.length > 0) {
|
202
|
-
timeoutValue = timeoutValue + 1000;
|
203
|
-
}
|
204
|
-
}, timeoutValue);
|
205
|
-
|
206
|
-
// scroll to iframes to make them be in the viewport
|
207
|
-
// use timeout to wait for load
|
208
|
-
setTimeout(() => {
|
209
|
-
const iframes = document.getElementsByTagName('iframe');
|
210
|
-
if (iframes.length > 0) {
|
211
|
-
timeoutValue = timeoutValue + 2000;
|
212
|
-
Array.from(iframes).forEach((element, index) => {
|
213
|
-
setTimeout(() => {
|
214
|
-
element.scrollIntoView({
|
215
|
-
behavior: 'instant',
|
216
|
-
block: 'nearest',
|
217
|
-
inline: 'center',
|
218
|
-
});
|
219
|
-
}, timeoutValue);
|
220
|
-
timeoutValue = timeoutValue + 3000;
|
221
|
-
});
|
222
|
-
timeoutValue = timeoutValue + 1000;
|
223
|
-
}
|
224
|
-
|
225
|
-
setTimeout(() => {
|
226
|
-
window.scrollTo({
|
227
|
-
top: 0,
|
228
|
-
});
|
229
|
-
Array.from(tabs).forEach((tab) => {
|
230
|
-
tab.style.display = '';
|
231
|
-
});
|
232
|
-
printLoader.style.display = 'none';
|
233
|
-
dispatch(setIsPrint(false));
|
234
|
-
window.print();
|
235
|
-
}, timeoutValue);
|
236
|
-
}, timeoutValue);
|
237
|
-
}}
|
238
|
-
/>
|
239
|
-
<div
|
240
|
-
id="download-print-loader"
|
241
|
-
className={cx('ui warning message')}
|
242
|
-
style={{
|
243
|
-
position: 'fixed',
|
244
|
-
left: '40%',
|
245
|
-
right: '40%',
|
246
|
-
backgroundColor: '#fff',
|
247
|
-
padding: '1em',
|
248
|
-
display: 'none',
|
249
|
-
flexDirection: 'column',
|
250
|
-
alignItems: 'center',
|
251
|
-
top: '40%',
|
252
|
-
zIndex: '9999',
|
253
|
-
}}
|
254
|
-
>
|
255
|
-
<Loader
|
256
|
-
disabled={false}
|
257
|
-
indeterminate
|
258
|
-
active
|
259
|
-
inline
|
260
|
-
size="medium"
|
261
|
-
></Loader>
|
262
|
-
<div>Preparing download</div>
|
263
|
-
</div>
|
264
|
-
</>
|
176
|
+
<Banner.Action
|
177
|
+
icon="ri-download-2-fill"
|
178
|
+
title={intl.formatMessage(messages.download)}
|
179
|
+
className="download"
|
180
|
+
onClick={() => {
|
181
|
+
setupPrintView(dispatch);
|
182
|
+
}}
|
183
|
+
/>
|
265
184
|
)}
|
266
185
|
{rssLinks?.map((rssLink, index) => (
|
267
186
|
<>
|
@@ -0,0 +1,56 @@
|
|
1
|
+
import { useEffect } from 'react';
|
2
|
+
import { Loader } from 'semantic-ui-react';
|
3
|
+
import { defineMessages, useIntl } from 'react-intl';
|
4
|
+
import { useDispatch, useSelector } from 'react-redux';
|
5
|
+
import { loadLazyImages } from '@eeacms/volto-eea-website-theme/helpers/loadLazyImages';
|
6
|
+
import { setupPrintView } from '@eeacms/volto-eea-website-theme/helpers/setupPrintView';
|
7
|
+
|
8
|
+
import './style.less';
|
9
|
+
|
10
|
+
const messages = defineMessages({
|
11
|
+
preparingDownload: {
|
12
|
+
id: 'Preparing download',
|
13
|
+
defaultMessage: 'Preparing download',
|
14
|
+
},
|
15
|
+
});
|
16
|
+
|
17
|
+
const PrintLoader = () => {
|
18
|
+
const intl = useIntl();
|
19
|
+
const dispatch = useDispatch();
|
20
|
+
const showLoader = useSelector((state) => state.print.isPrintLoading);
|
21
|
+
|
22
|
+
useEffect(() => {
|
23
|
+
const handleBeforePrint = () => {
|
24
|
+
loadLazyImages();
|
25
|
+
};
|
26
|
+
|
27
|
+
const handleKeyDown = (event) => {
|
28
|
+
if ((event.ctrlKey || event.metaKey) && event.key === 'p') {
|
29
|
+
event.preventDefault();
|
30
|
+
setupPrintView(dispatch);
|
31
|
+
}
|
32
|
+
};
|
33
|
+
|
34
|
+
window.addEventListener('keydown', handleKeyDown);
|
35
|
+
window.addEventListener('beforeprint', handleBeforePrint);
|
36
|
+
|
37
|
+
return () => {
|
38
|
+
window.removeEventListener('keydown', handleKeyDown);
|
39
|
+
window.removeEventListener('beforeprint', handleBeforePrint);
|
40
|
+
};
|
41
|
+
}, [dispatch]);
|
42
|
+
|
43
|
+
return showLoader ? (
|
44
|
+
<div
|
45
|
+
id="download-print-loader"
|
46
|
+
className="ui warning message"
|
47
|
+
role="status"
|
48
|
+
aria-live="polite"
|
49
|
+
>
|
50
|
+
<Loader active inline size="medium" />
|
51
|
+
<div>{intl.formatMessage(messages.preparingDownload)}</div>
|
52
|
+
</div>
|
53
|
+
) : null;
|
54
|
+
};
|
55
|
+
|
56
|
+
export default PrintLoader;
|
@@ -0,0 +1,91 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import '@testing-library/jest-dom/extend-expect';
|
3
|
+
import { render, fireEvent } from '@testing-library/react';
|
4
|
+
import { Provider } from 'react-redux';
|
5
|
+
import configureStore from 'redux-mock-store';
|
6
|
+
import PrintLoader from './PrintLoader';
|
7
|
+
import { IntlProvider } from 'react-intl';
|
8
|
+
|
9
|
+
jest.mock('@eeacms/volto-eea-website-theme/helpers/setupPrintView', () => ({
|
10
|
+
setupPrintView: jest.fn(),
|
11
|
+
}));
|
12
|
+
|
13
|
+
jest.mock('@eeacms/volto-eea-website-theme/helpers/loadLazyImages', () => ({
|
14
|
+
loadLazyImages: jest.fn(),
|
15
|
+
}));
|
16
|
+
|
17
|
+
const mockStore = configureStore();
|
18
|
+
|
19
|
+
describe('PrintLoader', () => {
|
20
|
+
it('should render loader when showLoader is true', () => {
|
21
|
+
const store = mockStore({
|
22
|
+
print: { isPrintLoading: true },
|
23
|
+
});
|
24
|
+
|
25
|
+
const { getByText } = render(
|
26
|
+
<Provider store={store}>
|
27
|
+
<IntlProvider locale="en" messages={{}}>
|
28
|
+
<PrintLoader />
|
29
|
+
</IntlProvider>
|
30
|
+
</Provider>,
|
31
|
+
);
|
32
|
+
|
33
|
+
expect(getByText('Preparing download')).toBeInTheDocument();
|
34
|
+
});
|
35
|
+
|
36
|
+
it('should not render anything when showLoader is false', () => {
|
37
|
+
const store = mockStore({
|
38
|
+
print: { isPrintLoading: false },
|
39
|
+
});
|
40
|
+
|
41
|
+
const { container } = render(
|
42
|
+
<Provider store={store}>
|
43
|
+
<IntlProvider locale="en" messages={{}}>
|
44
|
+
<PrintLoader />
|
45
|
+
</IntlProvider>
|
46
|
+
</Provider>,
|
47
|
+
);
|
48
|
+
|
49
|
+
expect(container.firstChild).toBeNull();
|
50
|
+
});
|
51
|
+
|
52
|
+
it('should call setupPrintView when Ctrl+P is pressed', () => {
|
53
|
+
const store = mockStore({
|
54
|
+
print: { isPrintLoading: false },
|
55
|
+
});
|
56
|
+
|
57
|
+
render(
|
58
|
+
<Provider store={store}>
|
59
|
+
<IntlProvider locale="en" messages={{}}>
|
60
|
+
<PrintLoader />
|
61
|
+
</IntlProvider>
|
62
|
+
</Provider>,
|
63
|
+
);
|
64
|
+
|
65
|
+
fireEvent.keyDown(window, { key: 'p', ctrlKey: true });
|
66
|
+
expect(
|
67
|
+
require('@eeacms/volto-eea-website-theme/helpers/setupPrintView')
|
68
|
+
.setupPrintView,
|
69
|
+
).toHaveBeenCalled();
|
70
|
+
});
|
71
|
+
|
72
|
+
it('should call loadLazyImages before printing', () => {
|
73
|
+
const store = mockStore({
|
74
|
+
print: { isPrintLoading: false },
|
75
|
+
});
|
76
|
+
|
77
|
+
render(
|
78
|
+
<Provider store={store}>
|
79
|
+
<IntlProvider locale="en" messages={{}}>
|
80
|
+
<PrintLoader />
|
81
|
+
</IntlProvider>
|
82
|
+
</Provider>,
|
83
|
+
);
|
84
|
+
|
85
|
+
window.dispatchEvent(new Event('beforeprint'));
|
86
|
+
expect(
|
87
|
+
require('@eeacms/volto-eea-website-theme/helpers/loadLazyImages')
|
88
|
+
.loadLazyImages,
|
89
|
+
).toHaveBeenCalled();
|
90
|
+
});
|
91
|
+
});
|
@@ -0,0 +1,26 @@
|
|
1
|
+
import '@testing-library/jest-dom';
|
2
|
+
import { render, screen } from '@testing-library/react';
|
3
|
+
import ImageViewWidget from './ImageViewWidget';
|
4
|
+
|
5
|
+
describe('ImageViewWidget', () => {
|
6
|
+
it('renders img with correct src and alt', () => {
|
7
|
+
const mockValue = {
|
8
|
+
download: 'https://example.com/image.jpg',
|
9
|
+
filename: 'example.jpg',
|
10
|
+
};
|
11
|
+
|
12
|
+
render(<ImageViewWidget value={mockValue} />);
|
13
|
+
|
14
|
+
const img = screen.getByRole('img');
|
15
|
+
expect(img).toHaveAttribute('src', mockValue.download);
|
16
|
+
expect(img).toHaveAttribute('alt', mockValue.filename);
|
17
|
+
});
|
18
|
+
|
19
|
+
it('does not render src or alt when value is null', () => {
|
20
|
+
render(<ImageViewWidget value={null} />);
|
21
|
+
|
22
|
+
const img = screen.getByRole('img');
|
23
|
+
expect(img).not.toHaveAttribute('src');
|
24
|
+
expect(img).not.toHaveAttribute('alt');
|
25
|
+
});
|
26
|
+
});
|
@@ -112,6 +112,7 @@ const DefaultView = (props) => {
|
|
112
112
|
</Container>
|
113
113
|
{hasLightLayout && matchingNavigationPath && (
|
114
114
|
<AccordionContextNavigation
|
115
|
+
insertBefore={matchingNavigationPath.insertBefore}
|
115
116
|
params={{
|
116
117
|
name: matchingNavigationPath.title,
|
117
118
|
no_thumbs: matchingNavigationPath.no_thumbs || true,
|
@@ -0,0 +1,212 @@
|
|
1
|
+
/**
|
2
|
+
* Html helper.
|
3
|
+
* @module helpers/Html
|
4
|
+
*/
|
5
|
+
|
6
|
+
import React, { Component } from 'react';
|
7
|
+
import PropTypes from 'prop-types';
|
8
|
+
import Helmet from '@plone/volto/helpers/Helmet/Helmet';
|
9
|
+
import serialize from 'serialize-javascript';
|
10
|
+
import { join } from 'lodash';
|
11
|
+
import BodyClass from '@plone/volto/helpers/BodyClass/BodyClass';
|
12
|
+
import { runtimeConfig } from '@plone/volto/runtime_config';
|
13
|
+
import config from '@plone/volto/registry';
|
14
|
+
|
15
|
+
const CRITICAL_CSS_TEMPLATE = `function alter() {
|
16
|
+
document.querySelectorAll("head link[rel='prefetch']").forEach(function(el) { el.rel = 'stylesheet'});
|
17
|
+
}
|
18
|
+
if (window.addEventListener) {
|
19
|
+
window.addEventListener('DOMContentLoaded', alter, false)
|
20
|
+
} else {
|
21
|
+
window.onload=alter
|
22
|
+
}`;
|
23
|
+
|
24
|
+
export const loadReducers = (state = {}) => {
|
25
|
+
const { settings } = config;
|
26
|
+
return Object.assign(
|
27
|
+
{},
|
28
|
+
...Object.keys(state).map((name) =>
|
29
|
+
settings.initialReducersBlacklist.includes(name)
|
30
|
+
? {}
|
31
|
+
: { [name]: state[name] },
|
32
|
+
),
|
33
|
+
);
|
34
|
+
};
|
35
|
+
|
36
|
+
/**
|
37
|
+
* Html class.
|
38
|
+
* Wrapper component containing HTML metadata and boilerplate tags.
|
39
|
+
* Used in server-side code only to wrap the string output of the
|
40
|
+
* rendered route component.
|
41
|
+
*
|
42
|
+
* The only thing this component doesn't (and can't) include is the
|
43
|
+
* HTML doctype declaration, which is added to the rendered output
|
44
|
+
* by the server.js file.
|
45
|
+
*
|
46
|
+
* Critical.css behaviour: when a file `public/critical.css` is present, the
|
47
|
+
* loading of stylesheets is changed. The styles in critical.css are inlined in
|
48
|
+
* the generated HTML, and the whole story needs to change completely: instead
|
49
|
+
* of treating stylesheets as priority for rendering, we want to defer their
|
50
|
+
* loading as much as possible. So we change the stylesheets to be prefetched
|
51
|
+
* and we switch their rel back to stylesheets at document ready event.
|
52
|
+
*
|
53
|
+
* @function Html
|
54
|
+
* @param {Object} props Component properties.
|
55
|
+
* @param {Object} props.assets Assets to be rendered.
|
56
|
+
* @param {Object} props.component Content to be rendered as child node.
|
57
|
+
* @param {Object} props.store Store object.
|
58
|
+
* @returns {string} Markup of the not found page.
|
59
|
+
*/
|
60
|
+
|
61
|
+
/**
|
62
|
+
* Html class.
|
63
|
+
* @class Html
|
64
|
+
* @extends Component
|
65
|
+
*/
|
66
|
+
class Html extends Component {
|
67
|
+
/**
|
68
|
+
* Property types.
|
69
|
+
* @property {Object} propTypes Property types.
|
70
|
+
* @static
|
71
|
+
*/
|
72
|
+
static propTypes = {
|
73
|
+
extractor: PropTypes.shape({
|
74
|
+
getLinkElements: PropTypes.func.isRequired,
|
75
|
+
getScriptElements: PropTypes.func.isRequired,
|
76
|
+
getStyleElements: PropTypes.func.isRequired,
|
77
|
+
}).isRequired,
|
78
|
+
markup: PropTypes.string.isRequired,
|
79
|
+
store: PropTypes.shape({
|
80
|
+
getState: PropTypes.func,
|
81
|
+
}).isRequired,
|
82
|
+
nonce: PropTypes.string,
|
83
|
+
};
|
84
|
+
|
85
|
+
/**
|
86
|
+
* Render method.
|
87
|
+
* @method render
|
88
|
+
* @returns {string} Markup for the component.
|
89
|
+
*/
|
90
|
+
render() {
|
91
|
+
const { extractor, markup, store, criticalCss, apiPath, publicURL, nonce } =
|
92
|
+
this.props;
|
93
|
+
const head = Helmet.rewind();
|
94
|
+
const bodyClass = join(BodyClass.rewind(), ' ');
|
95
|
+
const htmlAttributes = head.htmlAttributes.toComponent();
|
96
|
+
|
97
|
+
return (
|
98
|
+
<html lang={htmlAttributes.lang}>
|
99
|
+
<head>
|
100
|
+
<meta charSet="utf-8" />
|
101
|
+
{head.base.toComponent()}
|
102
|
+
{head.title.toComponent()}
|
103
|
+
{head.meta.toComponent()}
|
104
|
+
{head.link.toComponent()}
|
105
|
+
{head.script.toComponent()}
|
106
|
+
|
107
|
+
{React.createElement('script', {
|
108
|
+
nonce: nonce,
|
109
|
+
dangerouslySetInnerHTML: {
|
110
|
+
__html: `window.env = ${serialize(
|
111
|
+
{
|
112
|
+
...runtimeConfig,
|
113
|
+
// Seamless mode requirement, the client need to know where the API is located
|
114
|
+
// if not set in the API_PATH
|
115
|
+
...(apiPath && {
|
116
|
+
apiPath,
|
117
|
+
}),
|
118
|
+
...(publicURL && {
|
119
|
+
publicURL,
|
120
|
+
}),
|
121
|
+
},
|
122
|
+
{ space: 2 },
|
123
|
+
)};`,
|
124
|
+
},
|
125
|
+
})}
|
126
|
+
|
127
|
+
<link rel="icon" href="/favicon.ico" sizes="any" />
|
128
|
+
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
129
|
+
<link
|
130
|
+
rel="apple-touch-icon"
|
131
|
+
sizes="180x180"
|
132
|
+
href="/apple-touch-icon.png"
|
133
|
+
/>
|
134
|
+
<link rel="manifest" href="/site.webmanifest" />
|
135
|
+
<meta name="generator" content="Plone 6 - https://plone.org" />
|
136
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
137
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
138
|
+
{process.env.NODE_ENV === 'production' && criticalCss && (
|
139
|
+
<style
|
140
|
+
dangerouslySetInnerHTML={{ __html: this.props.criticalCss }}
|
141
|
+
/>
|
142
|
+
)}
|
143
|
+
{/* Add the crossorigin while in development */}
|
144
|
+
{extractor.getLinkElements().map((elem) =>
|
145
|
+
React.cloneElement(elem, {
|
146
|
+
crossOrigin:
|
147
|
+
process.env.NODE_ENV === 'production' ? undefined : 'true',
|
148
|
+
rel: !criticalCss
|
149
|
+
? elem.props.rel
|
150
|
+
: elem.props.as === 'style'
|
151
|
+
? 'prefetch'
|
152
|
+
: elem.props.rel,
|
153
|
+
}),
|
154
|
+
)}
|
155
|
+
{/* Styles in development are loaded with Webpack's style-loader, in production,
|
156
|
+
they need to be static*/}
|
157
|
+
{process.env.NODE_ENV === 'production' ? (
|
158
|
+
criticalCss ? (
|
159
|
+
<>
|
160
|
+
<script
|
161
|
+
dangerouslySetInnerHTML={{
|
162
|
+
__html: CRITICAL_CSS_TEMPLATE,
|
163
|
+
}}
|
164
|
+
></script>
|
165
|
+
{extractor.getStyleElements().map((elem) => (
|
166
|
+
<noscript>
|
167
|
+
{React.cloneElement(elem, {
|
168
|
+
rel: 'stylesheet',
|
169
|
+
crossOrigin:
|
170
|
+
process.env.NODE_ENV === 'production'
|
171
|
+
? undefined
|
172
|
+
: 'true',
|
173
|
+
})}
|
174
|
+
</noscript>
|
175
|
+
))}
|
176
|
+
</>
|
177
|
+
) : (
|
178
|
+
extractor.getStyleElements()
|
179
|
+
)
|
180
|
+
) : undefined}
|
181
|
+
</head>
|
182
|
+
<body className={bodyClass}>
|
183
|
+
<div role="navigation" aria-label="Toolbar" id="toolbar" />
|
184
|
+
<div id="main" dangerouslySetInnerHTML={{ __html: markup }} />
|
185
|
+
<div role="complementary" aria-label="Sidebar" id="sidebar" />
|
186
|
+
{React.createElement('script', {
|
187
|
+
nonce: nonce,
|
188
|
+
dangerouslySetInnerHTML: {
|
189
|
+
__html: `window.__data=${serialize(
|
190
|
+
loadReducers(store.getState()),
|
191
|
+
{ space: 2 },
|
192
|
+
)};`,
|
193
|
+
},
|
194
|
+
charSet: 'UTF-8',
|
195
|
+
})}
|
196
|
+
{/* Add the crossorigin while in development */}
|
197
|
+
{this.props.extractScripts !== false
|
198
|
+
? extractor.getScriptElements().map((elem) =>
|
199
|
+
React.cloneElement(elem, {
|
200
|
+
nonce: nonce,
|
201
|
+
crossOrigin:
|
202
|
+
process.env.NODE_ENV === 'production' ? undefined : 'true',
|
203
|
+
}),
|
204
|
+
)
|
205
|
+
: ''}
|
206
|
+
</body>
|
207
|
+
</html>
|
208
|
+
);
|
209
|
+
}
|
210
|
+
}
|
211
|
+
|
212
|
+
export default Html;
|
@@ -0,0 +1 @@
|
|
1
|
+
Customized for CSP support. Copy of https://github.com/plone/volto/blob/8b0f2af98ce0362f6975ce9c50137f6bd7cc3bb8/src/helpers/Html/Html.jsx Volto 17.x.x branch
|
@@ -0,0 +1,375 @@
|
|
1
|
+
/* eslint no-console: 0 */
|
2
|
+
import '@plone/volto/config'; // This is the bootstrap for the global config - server side
|
3
|
+
import { existsSync, lstatSync, readFileSync } from 'fs';
|
4
|
+
import React from 'react';
|
5
|
+
import { StaticRouter } from 'react-router-dom';
|
6
|
+
import { Provider } from 'react-intl-redux';
|
7
|
+
import express from 'express';
|
8
|
+
import { renderToString } from 'react-dom/server';
|
9
|
+
import { createMemoryHistory } from 'history';
|
10
|
+
import { parse as parseUrl } from 'url';
|
11
|
+
import { keys } from 'lodash';
|
12
|
+
import locale from 'locale';
|
13
|
+
import { detect } from 'detect-browser';
|
14
|
+
import path from 'path';
|
15
|
+
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';
|
16
|
+
import { resetServerContext } from 'react-beautiful-dnd';
|
17
|
+
import { CookiesProvider } from 'react-cookie';
|
18
|
+
import cookiesMiddleware from 'universal-cookie-express';
|
19
|
+
import debug from 'debug';
|
20
|
+
import crypto from 'crypto';
|
21
|
+
|
22
|
+
import routes from '@plone/volto/routes';
|
23
|
+
import config from '@plone/volto/registry';
|
24
|
+
|
25
|
+
import {
|
26
|
+
flattenToAppURL,
|
27
|
+
Html,
|
28
|
+
Api,
|
29
|
+
persistAuthToken,
|
30
|
+
toBackendLang,
|
31
|
+
toGettextLang,
|
32
|
+
toReactIntlLang,
|
33
|
+
} from '@plone/volto/helpers';
|
34
|
+
import { changeLanguage } from '@plone/volto/actions';
|
35
|
+
|
36
|
+
import userSession from '@plone/volto/reducers/userSession/userSession';
|
37
|
+
|
38
|
+
import ErrorPage from '@plone/volto/error';
|
39
|
+
|
40
|
+
import languages from '@plone/volto/constants/Languages';
|
41
|
+
|
42
|
+
import configureStore from '@plone/volto/store';
|
43
|
+
import {
|
44
|
+
ReduxAsyncConnect,
|
45
|
+
loadOnServer,
|
46
|
+
} from '@plone/volto/helpers/AsyncConnect';
|
47
|
+
|
48
|
+
let locales = {};
|
49
|
+
const isCSP = process.env.CSP_HEADER || config.settings.serverConfig.csp;
|
50
|
+
|
51
|
+
if (config.settings) {
|
52
|
+
config.settings.supportedLanguages.forEach((lang) => {
|
53
|
+
const langFileName = toGettextLang(lang);
|
54
|
+
import('@root/../locales/' + langFileName + '.json').then((locale) => {
|
55
|
+
locales = { ...locales, [toReactIntlLang(lang)]: locale.default };
|
56
|
+
});
|
57
|
+
});
|
58
|
+
}
|
59
|
+
|
60
|
+
function reactIntlErrorHandler(error) {
|
61
|
+
debug('i18n')(error);
|
62
|
+
}
|
63
|
+
|
64
|
+
const supported = new locale.Locales(keys(languages), 'en');
|
65
|
+
|
66
|
+
const server = express()
|
67
|
+
.disable('x-powered-by')
|
68
|
+
.set('etag', false)
|
69
|
+
.head('/*', function (req, res) {
|
70
|
+
// Support for HEAD requests. Required by start-test utility in CI.
|
71
|
+
res.send('');
|
72
|
+
})
|
73
|
+
.use(cookiesMiddleware());
|
74
|
+
|
75
|
+
const middleware = (config.settings.expressMiddleware || []).filter((m) => m);
|
76
|
+
|
77
|
+
server.all('*', setupServer);
|
78
|
+
if (middleware.length) server.use('/', middleware);
|
79
|
+
|
80
|
+
server.use(function (err, req, res, next) {
|
81
|
+
if (err) {
|
82
|
+
const { store } = res.locals;
|
83
|
+
const errorPage = (
|
84
|
+
<Provider store={store} onError={reactIntlErrorHandler}>
|
85
|
+
<StaticRouter context={{}} location={req.url}>
|
86
|
+
<ErrorPage message={err.message} />
|
87
|
+
</StaticRouter>
|
88
|
+
</Provider>
|
89
|
+
);
|
90
|
+
|
91
|
+
res.set({
|
92
|
+
'Cache-Control': 'public, max-age=60, no-transform',
|
93
|
+
});
|
94
|
+
|
95
|
+
/* Displays error in console
|
96
|
+
* TODO:
|
97
|
+
* - get ignored codes from Plone error_log
|
98
|
+
*/
|
99
|
+
const ignoredErrors = [301, 302, 401, 404];
|
100
|
+
if (!ignoredErrors.includes(err.status)) console.error(err);
|
101
|
+
|
102
|
+
res
|
103
|
+
.status(err.status || 500) // If error happens in Volto code itself error status is undefined
|
104
|
+
.send(`<!doctype html> ${renderToString(errorPage)}`);
|
105
|
+
}
|
106
|
+
});
|
107
|
+
|
108
|
+
function buildCSPHeader(opts, nonce) {
|
109
|
+
if (typeof opts === 'string') {
|
110
|
+
//CSP_HEADER
|
111
|
+
return opts.replaceAll('{nonce}', `'nonce-${nonce}'`);
|
112
|
+
}
|
113
|
+
return Object.keys(opts)
|
114
|
+
.sort()
|
115
|
+
.reduce((acc, key) => {
|
116
|
+
return [
|
117
|
+
...acc,
|
118
|
+
`${key} ${opts[key].replaceAll('{nonce}', `'nonce-${nonce}'`)}`,
|
119
|
+
];
|
120
|
+
}, [])
|
121
|
+
.join('; ');
|
122
|
+
}
|
123
|
+
|
124
|
+
function setupServer(req, res, next) {
|
125
|
+
if (isCSP) {
|
126
|
+
const nonce = crypto.randomBytes(16).toString('base64');
|
127
|
+
res.locals.nonce = nonce;
|
128
|
+
}
|
129
|
+
|
130
|
+
const api = new Api(req);
|
131
|
+
|
132
|
+
const lang = toReactIntlLang(
|
133
|
+
new locale.Locales(
|
134
|
+
req.universalCookies.get('I18N_LANGUAGE') ||
|
135
|
+
config.settings.defaultLanguage ||
|
136
|
+
req.headers['accept-language'],
|
137
|
+
)
|
138
|
+
.best(supported)
|
139
|
+
.toString(),
|
140
|
+
);
|
141
|
+
|
142
|
+
// Minimum initial state for the fake Redux store instance
|
143
|
+
const initialState = {
|
144
|
+
intl: {
|
145
|
+
defaultLocale: 'en',
|
146
|
+
locale: lang,
|
147
|
+
messages: locales[lang],
|
148
|
+
},
|
149
|
+
};
|
150
|
+
|
151
|
+
const history = createMemoryHistory({
|
152
|
+
initialEntries: [req.url],
|
153
|
+
});
|
154
|
+
|
155
|
+
// Create a fake Redux store instance for the `errorHandler` to render
|
156
|
+
// and for being used by the rest of the middlewares, if required
|
157
|
+
const store = configureStore(initialState, history, api);
|
158
|
+
|
159
|
+
function errorHandler(error) {
|
160
|
+
const errorPage = (
|
161
|
+
<Provider store={store} onError={reactIntlErrorHandler}>
|
162
|
+
<StaticRouter context={{}} location={req.url}>
|
163
|
+
<ErrorPage message={error.message} />
|
164
|
+
</StaticRouter>
|
165
|
+
</Provider>
|
166
|
+
);
|
167
|
+
|
168
|
+
res.set({
|
169
|
+
'Cache-Control': 'public, max-age=60, no-transform',
|
170
|
+
});
|
171
|
+
|
172
|
+
/* Displays error in console
|
173
|
+
* TODO:
|
174
|
+
* - get ignored codes from Plone error_log
|
175
|
+
*/
|
176
|
+
const ignoredErrors = [301, 302, 401, 404];
|
177
|
+
if (!ignoredErrors.includes(error.status)) console.error(error);
|
178
|
+
|
179
|
+
res
|
180
|
+
.status(error.status || 500) // If error happens in Volto code itself error status is undefined
|
181
|
+
.send(`<!doctype html> ${renderToString(errorPage)}`);
|
182
|
+
}
|
183
|
+
|
184
|
+
if (!process.env.RAZZLE_API_PATH && req.headers.host) {
|
185
|
+
res.locals.detectedHost = `${
|
186
|
+
req.headers['x-forwarded-proto'] || req.protocol
|
187
|
+
}://${req.headers.host}`;
|
188
|
+
config.settings.apiPath = res.locals.detectedHost;
|
189
|
+
config.settings.publicURL = res.locals.detectedHost;
|
190
|
+
}
|
191
|
+
|
192
|
+
res.locals = {
|
193
|
+
...res.locals,
|
194
|
+
store,
|
195
|
+
api,
|
196
|
+
errorHandler,
|
197
|
+
};
|
198
|
+
|
199
|
+
next();
|
200
|
+
}
|
201
|
+
|
202
|
+
server.get('/*', (req, res) => {
|
203
|
+
const { errorHandler, nonce } = res.locals;
|
204
|
+
|
205
|
+
if (isCSP) {
|
206
|
+
res.setHeader('Content-Security-Policy', buildCSPHeader(isCSP, nonce));
|
207
|
+
}
|
208
|
+
|
209
|
+
const api = new Api(req);
|
210
|
+
|
211
|
+
const browserdetect = detect(req.headers['user-agent']);
|
212
|
+
|
213
|
+
const lang = toReactIntlLang(
|
214
|
+
new locale.Locales(
|
215
|
+
req.universalCookies.get('I18N_LANGUAGE') ||
|
216
|
+
config.settings.defaultLanguage ||
|
217
|
+
req.headers['accept-language'],
|
218
|
+
)
|
219
|
+
.best(supported)
|
220
|
+
.toString(),
|
221
|
+
);
|
222
|
+
|
223
|
+
const authToken = req.universalCookies.get('auth_token');
|
224
|
+
const initialState = {
|
225
|
+
userSession: { ...userSession(), token: authToken },
|
226
|
+
form: req.body,
|
227
|
+
intl: {
|
228
|
+
defaultLocale: 'en',
|
229
|
+
locale: lang,
|
230
|
+
messages: locales[lang],
|
231
|
+
},
|
232
|
+
browserdetect,
|
233
|
+
};
|
234
|
+
|
235
|
+
const history = createMemoryHistory({
|
236
|
+
initialEntries: [req.url],
|
237
|
+
});
|
238
|
+
|
239
|
+
// Create a new Redux store instance
|
240
|
+
const store = configureStore(initialState, history, api);
|
241
|
+
|
242
|
+
persistAuthToken(store, req);
|
243
|
+
|
244
|
+
// @loadable/server extractor
|
245
|
+
const buildDir = process.env.BUILD_DIR || 'build';
|
246
|
+
const extractor = new ChunkExtractor({
|
247
|
+
statsFile: path.resolve(path.join(buildDir, 'loadable-stats.json')),
|
248
|
+
entrypoints: ['client'],
|
249
|
+
});
|
250
|
+
|
251
|
+
const url = req.originalUrl || req.url;
|
252
|
+
const location = parseUrl(url);
|
253
|
+
|
254
|
+
loadOnServer({ store, location, routes, api })
|
255
|
+
.then(() => {
|
256
|
+
const initialLang =
|
257
|
+
req.universalCookies.get('I18N_LANGUAGE') ||
|
258
|
+
config.settings.defaultLanguage ||
|
259
|
+
req.headers['accept-language'];
|
260
|
+
|
261
|
+
// The content info is in the store at this point thanks to the asynconnect
|
262
|
+
// features, then we can force the current language info into the store when
|
263
|
+
// coming from an SSR request
|
264
|
+
|
265
|
+
// TODO: there is a bug here with content that, for any reason, doesn't
|
266
|
+
// present the language token field, for some reason. In this case, we
|
267
|
+
// should follow the cookie rather then switching the language
|
268
|
+
const contentLang = store.getState().content.get?.error
|
269
|
+
? initialLang
|
270
|
+
: store.getState().content.data?.language?.token ||
|
271
|
+
config.settings.defaultLanguage;
|
272
|
+
|
273
|
+
if (toBackendLang(initialLang) !== contentLang && url !== '/') {
|
274
|
+
const newLang = toReactIntlLang(
|
275
|
+
new locale.Locales(contentLang).best(supported).toString(),
|
276
|
+
);
|
277
|
+
store.dispatch(changeLanguage(newLang, locales[newLang], req));
|
278
|
+
}
|
279
|
+
|
280
|
+
const context = {};
|
281
|
+
resetServerContext();
|
282
|
+
const markup = renderToString(
|
283
|
+
<ChunkExtractorManager extractor={extractor}>
|
284
|
+
<CookiesProvider cookies={req.universalCookies}>
|
285
|
+
<Provider store={store} onError={reactIntlErrorHandler}>
|
286
|
+
<StaticRouter context={context} location={req.url}>
|
287
|
+
<ReduxAsyncConnect routes={routes} helpers={api} />
|
288
|
+
</StaticRouter>
|
289
|
+
</Provider>
|
290
|
+
</CookiesProvider>
|
291
|
+
</ChunkExtractorManager>,
|
292
|
+
);
|
293
|
+
|
294
|
+
const readCriticalCss =
|
295
|
+
config.settings.serverConfig.readCriticalCss || defaultReadCriticalCss;
|
296
|
+
|
297
|
+
// If we are showing an "old browser" warning,
|
298
|
+
// make sure it doesn't get cached in a shared cache
|
299
|
+
const browserdetect = store.getState().browserdetect;
|
300
|
+
if (config.settings.notSupportedBrowsers.includes(browserdetect?.name)) {
|
301
|
+
res.set({
|
302
|
+
'Cache-Control': 'private',
|
303
|
+
});
|
304
|
+
}
|
305
|
+
|
306
|
+
if (context.url) {
|
307
|
+
res.redirect(flattenToAppURL(context.url));
|
308
|
+
} else if (context.error_code) {
|
309
|
+
res.set({
|
310
|
+
'Cache-Control': 'no-cache',
|
311
|
+
});
|
312
|
+
|
313
|
+
res.status(context.error_code).send(
|
314
|
+
`<!doctype html>
|
315
|
+
${renderToString(
|
316
|
+
<Html
|
317
|
+
extractor={extractor}
|
318
|
+
nonce={nonce}
|
319
|
+
markup={markup}
|
320
|
+
store={store}
|
321
|
+
extractScripts={
|
322
|
+
config.settings.serverConfig.extractScripts?.errorPages ||
|
323
|
+
process.env.NODE_ENV !== 'production'
|
324
|
+
}
|
325
|
+
criticalCss={readCriticalCss(req)}
|
326
|
+
apiPath={res.locals.detectedHost || config.settings.apiPath}
|
327
|
+
publicURL={
|
328
|
+
res.locals.detectedHost || config.settings.publicURL
|
329
|
+
}
|
330
|
+
/>,
|
331
|
+
)}
|
332
|
+
`,
|
333
|
+
);
|
334
|
+
} else {
|
335
|
+
res.status(200).send(
|
336
|
+
`<!doctype html>
|
337
|
+
${renderToString(
|
338
|
+
<Html
|
339
|
+
extractor={extractor}
|
340
|
+
nonce={nonce}
|
341
|
+
markup={markup}
|
342
|
+
store={store}
|
343
|
+
criticalCss={readCriticalCss(req)}
|
344
|
+
apiPath={res.locals.detectedHost || config.settings.apiPath}
|
345
|
+
publicURL={
|
346
|
+
res.locals.detectedHost || config.settings.publicURL
|
347
|
+
}
|
348
|
+
/>,
|
349
|
+
)}
|
350
|
+
`,
|
351
|
+
);
|
352
|
+
}
|
353
|
+
}, errorHandler)
|
354
|
+
.catch(errorHandler);
|
355
|
+
});
|
356
|
+
|
357
|
+
export const defaultReadCriticalCss = () => {
|
358
|
+
const { criticalCssPath } = config.settings.serverConfig;
|
359
|
+
|
360
|
+
const e = existsSync(criticalCssPath);
|
361
|
+
if (!e) return;
|
362
|
+
|
363
|
+
const f = lstatSync(criticalCssPath);
|
364
|
+
if (!f.isFile()) return;
|
365
|
+
|
366
|
+
return readFileSync(criticalCssPath, { encoding: 'utf-8' });
|
367
|
+
};
|
368
|
+
|
369
|
+
// Exposed for the console bootstrap info messages
|
370
|
+
server.apiPath = config.settings.apiPath;
|
371
|
+
server.devProxyToApiPath = config.settings.devProxyToApiPath;
|
372
|
+
server.proxyRewriteTarget = config.settings.proxyRewriteTarget;
|
373
|
+
server.publicURL = config.settings.publicURL;
|
374
|
+
|
375
|
+
export default server;
|
@@ -0,0 +1,11 @@
|
|
1
|
+
/**
|
2
|
+
* Forces all lazy-loaded images to load immediately by setting the loading attribute to 'eager'.
|
3
|
+
*/
|
4
|
+
export const loadLazyImages = () => {
|
5
|
+
const lazyImages = document.querySelectorAll('img[loading="lazy"]');
|
6
|
+
lazyImages.forEach((img) => {
|
7
|
+
if (img.src) {
|
8
|
+
img.setAttribute('loading', 'eager');
|
9
|
+
}
|
10
|
+
});
|
11
|
+
};
|
@@ -0,0 +1,22 @@
|
|
1
|
+
import { loadLazyImages } from './loadLazyImages';
|
2
|
+
|
3
|
+
describe('loadLazyImages', () => {
|
4
|
+
beforeEach(() => {
|
5
|
+
document.body.innerHTML = `
|
6
|
+
<img loading="lazy" src="image1.jpg" />
|
7
|
+
<img loading="lazy" src="image2.jpg" />
|
8
|
+
<img loading="lazy" />
|
9
|
+
<img loading="eager" src="image3.jpg" />
|
10
|
+
`;
|
11
|
+
});
|
12
|
+
|
13
|
+
it('sets loading="eager" only on lazy-loaded images with a src', () => {
|
14
|
+
loadLazyImages();
|
15
|
+
|
16
|
+
const images = document.querySelectorAll('img');
|
17
|
+
expect(images[0].getAttribute('loading')).toBe('eager'); // had src
|
18
|
+
expect(images[1].getAttribute('loading')).toBe('eager'); // had src
|
19
|
+
expect(images[2].getAttribute('loading')).toBe('lazy'); // no src
|
20
|
+
expect(images[3].getAttribute('loading')).toBe('eager'); // already eager
|
21
|
+
});
|
22
|
+
});
|
@@ -0,0 +1,134 @@
|
|
1
|
+
/**
|
2
|
+
* Sets up the document for printing by handling iframes
|
3
|
+
* and lazy-loaded images, and triggering the print dialog.
|
4
|
+
* Uses proper event listeners to ensure all content is loaded before printing.
|
5
|
+
*/
|
6
|
+
|
7
|
+
import {
|
8
|
+
setIsPrint,
|
9
|
+
setPrintLoading,
|
10
|
+
} from '@eeacms/volto-eea-website-theme/actions/print';
|
11
|
+
import { loadLazyImages } from '@eeacms/volto-eea-website-theme/helpers/loadLazyImages';
|
12
|
+
|
13
|
+
export const setupPrintView = (dispatch) => {
|
14
|
+
// Set tabs to be visible
|
15
|
+
const tabs = document.getElementsByClassName('ui tab');
|
16
|
+
Array.from(tabs).forEach((tab) => {
|
17
|
+
tab.style.display = 'block';
|
18
|
+
});
|
19
|
+
|
20
|
+
dispatch(setIsPrint(true));
|
21
|
+
dispatch(setPrintLoading(true));
|
22
|
+
|
23
|
+
// Load all lazy images
|
24
|
+
loadLazyImages();
|
25
|
+
|
26
|
+
// Create a promise that resolves when all iframes are loaded
|
27
|
+
const waitForIframes = () => {
|
28
|
+
const iframes = document.getElementsByTagName('iframe');
|
29
|
+
if (iframes.length === 0) {
|
30
|
+
return Promise.resolve();
|
31
|
+
}
|
32
|
+
|
33
|
+
const iframePromises = Array.from(iframes).map((iframe) => {
|
34
|
+
return new Promise((resolve) => {
|
35
|
+
// If iframe is already loaded, resolve immediately
|
36
|
+
if (iframe.contentDocument?.readyState === 'complete') {
|
37
|
+
resolve();
|
38
|
+
return;
|
39
|
+
}
|
40
|
+
|
41
|
+
// Otherwise wait for load event
|
42
|
+
iframe.addEventListener(
|
43
|
+
'load',
|
44
|
+
() => {
|
45
|
+
// Scroll iframe into view to ensure content loads
|
46
|
+
iframe.scrollIntoView({
|
47
|
+
behavior: 'instant',
|
48
|
+
block: 'nearest',
|
49
|
+
inline: 'center',
|
50
|
+
});
|
51
|
+
resolve();
|
52
|
+
},
|
53
|
+
{ once: true },
|
54
|
+
);
|
55
|
+
|
56
|
+
// Set a timeout as a fallback in case the load event doesn't fire
|
57
|
+
setTimeout(resolve, 5000);
|
58
|
+
});
|
59
|
+
});
|
60
|
+
|
61
|
+
return Promise.all(iframePromises);
|
62
|
+
};
|
63
|
+
|
64
|
+
// Create a promise that resolves when all images are loaded
|
65
|
+
const waitForImages = () => {
|
66
|
+
const images = document.getElementsByTagName('img');
|
67
|
+
if (images.length === 0) {
|
68
|
+
return Promise.resolve();
|
69
|
+
}
|
70
|
+
|
71
|
+
const imagePromises = Array.from(images).map((img) => {
|
72
|
+
return new Promise((resolve) => {
|
73
|
+
// If image is already loaded, resolve immediately
|
74
|
+
if (img.complete) {
|
75
|
+
resolve();
|
76
|
+
return;
|
77
|
+
}
|
78
|
+
|
79
|
+
// Otherwise wait for load event
|
80
|
+
img.addEventListener('load', resolve, { once: true });
|
81
|
+
img.addEventListener('error', resolve, { once: true }); // Also handle error case
|
82
|
+
|
83
|
+
// Set a timeout as a fallback
|
84
|
+
setTimeout(resolve, 3000);
|
85
|
+
});
|
86
|
+
});
|
87
|
+
|
88
|
+
return Promise.all(imagePromises);
|
89
|
+
};
|
90
|
+
|
91
|
+
// Wait for plotly charts if they exist
|
92
|
+
const waitForPlotlyCharts = () => {
|
93
|
+
const plotlyCharts = document.getElementsByClassName(
|
94
|
+
'visualization-wrapper',
|
95
|
+
);
|
96
|
+
if (plotlyCharts.length === 0) {
|
97
|
+
return Promise.resolve();
|
98
|
+
}
|
99
|
+
|
100
|
+
// Give plotly charts some time to render
|
101
|
+
return new Promise((resolve) => setTimeout(resolve, 2000));
|
102
|
+
};
|
103
|
+
|
104
|
+
// Wait for all content to load before printing
|
105
|
+
const waitForAllContentToLoad = async () => {
|
106
|
+
// Wait for iframes, images, and Plotly charts to load
|
107
|
+
Promise.all([waitForIframes(), waitForImages(), waitForPlotlyCharts()])
|
108
|
+
.then(() => {
|
109
|
+
// Scroll back to top
|
110
|
+
window.scrollTo({ top: 0 });
|
111
|
+
|
112
|
+
// Reset tab display
|
113
|
+
Array.from(tabs).forEach((tab) => {
|
114
|
+
tab.style.display = '';
|
115
|
+
});
|
116
|
+
|
117
|
+
// Update state and trigger print
|
118
|
+
dispatch(setPrintLoading(false));
|
119
|
+
dispatch(setIsPrint(false));
|
120
|
+
window.print();
|
121
|
+
})
|
122
|
+
.catch(() => {
|
123
|
+
// Still try to print even if there was an error
|
124
|
+
dispatch(setPrintLoading(false));
|
125
|
+
dispatch(setIsPrint(false));
|
126
|
+
window.print();
|
127
|
+
});
|
128
|
+
};
|
129
|
+
|
130
|
+
// Delay the initial call to ensure everything is rendered
|
131
|
+
setTimeout(() => {
|
132
|
+
waitForAllContentToLoad();
|
133
|
+
}, 1000);
|
134
|
+
};
|
@@ -0,0 +1,49 @@
|
|
1
|
+
import { setupPrintView } from './setupPrintView';
|
2
|
+
import { loadLazyImages } from '@eeacms/volto-eea-website-theme/helpers/loadLazyImages';
|
3
|
+
import { act } from '@testing-library/react';
|
4
|
+
|
5
|
+
jest.mock('@eeacms/volto-eea-website-theme/actions/print', () => ({
|
6
|
+
setIsPrint: jest.fn(() => ({ type: 'SET_IS_PRINT' })),
|
7
|
+
setPrintLoading: jest.fn(() => ({ type: 'SET_PRINT_LOADING' })),
|
8
|
+
}));
|
9
|
+
|
10
|
+
jest.mock('@eeacms/volto-eea-website-theme/helpers/loadLazyImages', () => ({
|
11
|
+
loadLazyImages: jest.fn(),
|
12
|
+
}));
|
13
|
+
|
14
|
+
describe('setupPrintView', () => {
|
15
|
+
beforeEach(() => {
|
16
|
+
document.body.innerHTML = `
|
17
|
+
<div class="ui tab"></div>
|
18
|
+
<img />
|
19
|
+
<iframe></iframe>
|
20
|
+
<div class="visualization-wrapper"></div>
|
21
|
+
`;
|
22
|
+
|
23
|
+
jest.useFakeTimers();
|
24
|
+
window.scrollTo = jest.fn();
|
25
|
+
window.print = jest.fn();
|
26
|
+
});
|
27
|
+
|
28
|
+
afterEach(() => {
|
29
|
+
jest.clearAllMocks();
|
30
|
+
jest.useRealTimers();
|
31
|
+
});
|
32
|
+
|
33
|
+
it('dispatches actions and triggers print', async () => {
|
34
|
+
const dispatch = jest.fn();
|
35
|
+
|
36
|
+
await act(async () => {
|
37
|
+
setupPrintView(dispatch);
|
38
|
+
jest.runAllTimers();
|
39
|
+
await Promise.resolve();
|
40
|
+
await Promise.resolve();
|
41
|
+
});
|
42
|
+
|
43
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_IS_PRINT' });
|
44
|
+
expect(dispatch).toHaveBeenCalledWith({ type: 'SET_PRINT_LOADING' });
|
45
|
+
expect(loadLazyImages).toHaveBeenCalled();
|
46
|
+
expect(window.scrollTo).toHaveBeenCalledWith({ top: 0 });
|
47
|
+
expect(window.print).toHaveBeenCalled();
|
48
|
+
});
|
49
|
+
});
|
package/src/index.js
CHANGED
@@ -14,6 +14,7 @@ import HomePageInverseView from '@eeacms/volto-eea-website-theme/components/them
|
|
14
14
|
import HomePageView from '@eeacms/volto-eea-website-theme/components/theme/Homepage/HomePageView';
|
15
15
|
import WebReportSectionView from '@eeacms/volto-eea-website-theme/components/theme/WebReport/WebReportSectionView';
|
16
16
|
import NotFound from '@eeacms/volto-eea-website-theme/components/theme/NotFound/NotFound';
|
17
|
+
import PrintLoader from '@eeacms/volto-eea-website-theme/components/theme/PrintLoader/PrintLoader';
|
17
18
|
import { TokenWidget } from '@eeacms/volto-eea-website-theme/components/theme/Widgets/TokenWidget';
|
18
19
|
import { TopicsWidget } from '@eeacms/volto-eea-website-theme/components/theme/Widgets/TopicsWidget';
|
19
20
|
import { DateWidget } from '@eeacms/volto-eea-website-theme/components/theme/Widgets/DateWidget';
|
@@ -420,6 +421,10 @@ const applyConfig = (config) => {
|
|
420
421
|
match: '',
|
421
422
|
component: BaseTag,
|
422
423
|
},
|
424
|
+
{
|
425
|
+
match: '',
|
426
|
+
component: PrintLoader,
|
427
|
+
},
|
423
428
|
];
|
424
429
|
|
425
430
|
// Install slate
|
package/src/index.test.js
CHANGED
@@ -33,6 +33,10 @@ jest.mock(
|
|
33
33
|
TopicsWidget: 'MockedThemesWidget',
|
34
34
|
}),
|
35
35
|
);
|
36
|
+
jest.mock(
|
37
|
+
'@eeacms/volto-eea-website-theme/components/theme/PrintLoader/PrintLoader',
|
38
|
+
() => 'MockedPrintLoader',
|
39
|
+
);
|
36
40
|
jest.mock('./components/theme/SubsiteClass', () => 'MockedSubsiteClass');
|
37
41
|
jest.mock(
|
38
42
|
'@eeacms/volto-eea-website-theme/components/theme/Homepage/HomePageView',
|
@@ -149,6 +153,7 @@ describe('applyConfig', () => {
|
|
149
153
|
{ match: '', component: 'MockedDraftBackground' },
|
150
154
|
{ match: '', component: 'MockedSubsiteClass' },
|
151
155
|
{ match: '', component: BaseTag },
|
156
|
+
{ match: '', component: 'MockedPrintLoader' },
|
152
157
|
]);
|
153
158
|
expect(config.settings.available_colors).toEqual(eea.colors);
|
154
159
|
expect(config.settings.hasLanguageDropdown).toBe(false);
|
@@ -315,6 +320,7 @@ describe('applyConfig', () => {
|
|
315
320
|
{ match: '', component: 'MockedDraftBackground' },
|
316
321
|
{ match: '', component: 'MockedSubsiteClass' },
|
317
322
|
{ match: '', component: BaseTag },
|
323
|
+
{ match: '', component: 'MockedPrintLoader' },
|
318
324
|
]);
|
319
325
|
expect(config.settings.available_colors).toEqual(eea.colors);
|
320
326
|
expect(config.settings.hasLanguageDropdown).toBe(false);
|
package/src/reducers/print.js
CHANGED
@@ -3,19 +3,29 @@
|
|
3
3
|
* @module reducers/print
|
4
4
|
*/
|
5
5
|
|
6
|
-
import {
|
6
|
+
import {
|
7
|
+
SET_ISPRINT,
|
8
|
+
SET_PRINT_LOADING,
|
9
|
+
} from '@eeacms/volto-eea-website-theme/constants/ActionTypes';
|
7
10
|
|
8
11
|
const initialState = {
|
9
12
|
isPrint: false,
|
13
|
+
isPrintLoading: false,
|
10
14
|
};
|
11
15
|
|
12
16
|
export default function print(state = initialState, action) {
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
switch (action.type) {
|
18
|
+
case SET_ISPRINT:
|
19
|
+
return {
|
20
|
+
...state,
|
21
|
+
isPrint: action.payload,
|
22
|
+
};
|
23
|
+
case SET_PRINT_LOADING:
|
24
|
+
return {
|
25
|
+
...state,
|
26
|
+
isPrintLoading: action.payload,
|
27
|
+
};
|
28
|
+
default:
|
29
|
+
return state;
|
20
30
|
}
|
21
31
|
}
|