@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 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.0](https://github.com/eea/volto-eea-website-theme/compare/3.6.3...3.7.0) - 6 June 2025
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
- - Release 3.7.0 [Alin Voinea - [`b4c7cc5`](https://github.com/eea/volto-eea-website-theme/commit/b4c7cc5218a5ed04d2a53c75d2dfdac7c7959bd5)]
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.7.0",
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": "*",
@@ -3,7 +3,10 @@
3
3
  * @module actions/print
4
4
  */
5
5
 
6
- import { SET_ISPRINT } from '@eeacms/volto-eea-website-theme/constants/ActionTypes';
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 = ['mobile', 'tablet'].includes(device) ? false : true;
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, Loader } from 'semantic-ui-react';
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 { setIsPrint } from '@eeacms/volto-eea-website-theme/actions/print';
18
- import cx from 'classnames';
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
- <Banner.Action
178
- icon="ri-download-2-fill"
179
- title={intl.formatMessage(messages.download)}
180
- className="download"
181
- onClick={() => {
182
- // set tabs to be visible
183
- const tabs = document.getElementsByClassName('ui tab');
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,12 @@
1
+ #download-print-loader {
2
+ position: fixed;
3
+ z-index: 9999;
4
+ top: 40%;
5
+ right: 40%;
6
+ left: 40%;
7
+ display: flex;
8
+ flex-direction: column;
9
+ align-items: center;
10
+ padding: 1em;
11
+ background-color: #fff;
12
+ }
@@ -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
+ });
@@ -4,3 +4,4 @@
4
4
  */
5
5
 
6
6
  export const SET_ISPRINT = 'SET_ISPRINT';
7
+ export const SET_PRINT_LOADING = 'SET_PRINT_LOADING';
@@ -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);
@@ -3,19 +3,29 @@
3
3
  * @module reducers/print
4
4
  */
5
5
 
6
- import { SET_ISPRINT } from '@eeacms/volto-eea-website-theme/constants/ActionTypes';
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
- if (action.type === SET_ISPRINT) {
14
- return {
15
- ...state,
16
- isPrint: action.payload,
17
- };
18
- } else {
19
- return state;
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
  }