@eeacms/volto-cca-policy 0.3.120 → 0.3.121

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,6 +4,25 @@ 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
+ ### [0.3.121](https://github.com/eea/volto-cca-policy/compare/0.3.120...0.3.121) - 7 May 2026
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat: improve RSSFeedView [kreafox - [`a425682`](https://github.com/eea/volto-cca-policy/commit/a4256825ede073eda95707f9e09916177b426716)]
12
+ - feat: add original file of RSSFeedView.jsx [kreafox - [`581a27f`](https://github.com/eea/volto-cca-policy/commit/581a27fd79191d6df70f94163cb511397b4445d9)]
13
+ - feat: add volto-rss-provider dependency to package.json [kreafox - [`218c1da`](https://github.com/eea/volto-cca-policy/commit/218c1da54e019bee346535c893131e07b0bcba64)]
14
+ - feat: sort_order handling in volto-rss-provider middleware [kreafox - [`bc6cf86`](https://github.com/eea/volto-cca-policy/commit/bc6cf86696b4798b9939e0615f3025dfe1ac742a)]
15
+ - feat: add original file of volto-rss-provider middleware [kreafox - [`ddf4e22`](https://github.com/eea/volto-cca-policy/commit/ddf4e2256f8e75a8944fa416e07722ecd963907b)]
16
+
17
+ #### :bug: Bug Fixes
18
+
19
+ - fix(eslint): remove unused import and clean up useClipboard [kreafox - [`9abda41`](https://github.com/eea/volto-cca-policy/commit/9abda419ff91c3344b113dd2a8289d23f3512520)]
20
+ - fix: remove unused import [kreafox - [`597075d`](https://github.com/eea/volto-cca-policy/commit/597075d0dde7dfe2b8ec9fe9edb0de08503e1ad6)]
21
+
22
+ #### :nail_care: Enhancements
23
+
24
+ - refactor: RSSFeedView [kreafox - [`a32e7ff`](https://github.com/eea/volto-cca-policy/commit/a32e7ff7c8dba96a8ab7b87f219b6f3988072b5e)]
25
+
7
26
  ### [0.3.120](https://github.com/eea/volto-cca-policy/compare/0.3.119...0.3.120) - 30 April 2026
8
27
 
9
28
  #### :bug: Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-cca-policy",
3
- "version": "0.3.120",
3
+ "version": "0.3.121",
4
4
  "description": "@eeacms/volto-cca-policy: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
@@ -41,6 +41,7 @@
41
41
  "@eeacms/volto-tabs-block": "^9.0.3",
42
42
  "@elastic/search-ui": "1.21.2",
43
43
  "@plone-collective/volto-authomatic": "2.0.1",
44
+ "@plone-collective/volto-rss-provider": "1.0.1",
44
45
  "@tanstack/react-table": "8.19.3",
45
46
  "d3-array": "^2.12.1",
46
47
  "jotai": "^1.6.0",
@@ -0,0 +1,3 @@
1
+ ## RSS sort_order customization
2
+
3
+ This customization patches the `volto-rss-provider` middleware to correctly handle reversed sorting in RSS Listing block queries.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Document view component.
3
+ * @module components/theme/View/DefaultView
4
+ */
5
+
6
+ import React from 'react';
7
+ import PropTypes from 'prop-types';
8
+ import { isEqual } from 'lodash';
9
+ import { compose } from 'redux';
10
+ import { useDispatch, useSelector } from 'react-redux';
11
+ import { defineMessages, injectIntl } from 'react-intl';
12
+ import {
13
+ Container as SemanticContainer,
14
+ Segment,
15
+ Button,
16
+ Grid,
17
+ Label,
18
+ Icon,
19
+ } from 'semantic-ui-react';
20
+ import config from '@plone/volto/registry';
21
+ import { getSchema } from '@plone/volto/actions';
22
+ import { getWidget } from '@plone/volto/helpers/Widget/utils';
23
+ import RenderBlocks from '@plone/volto/components/theme/View/RenderBlocks';
24
+ import useClipboard from '@plone/volto/hooks/clipboard/useClipboard';
25
+ import {
26
+ hasBlocksData,
27
+ getBaseUrl,
28
+ addAppURL,
29
+ Helmet,
30
+ } from '@plone/volto/helpers';
31
+
32
+ const messages = defineMessages({
33
+ rssFeed: {
34
+ id: 'rssFeed',
35
+ defaultMessage: 'RSS Feed',
36
+ },
37
+ });
38
+
39
+ /**
40
+ * Component to display the default view.
41
+ * @function DefaultView
42
+ * @param {Object} content Content object.
43
+ * @returns {string} Markup of the component.
44
+ */
45
+ const DefaultView = (props) => {
46
+ const dispatch = useDispatch();
47
+ const { content, location } = props;
48
+ const { views } = config.widgets;
49
+ const path = getBaseUrl(location?.pathname || '');
50
+ const contentSchema = useSelector((state) => state.schema?.schema);
51
+ const [visible, setVisible] = React.useState(false);
52
+ const [, copy] = useClipboard(`${addAppURL(path)}/rss.xml`);
53
+ const fieldsetsToExclude = [
54
+ 'categorization',
55
+ 'dates',
56
+ 'ownership',
57
+ 'settings',
58
+ ];
59
+ const fieldsets = contentSchema?.fieldsets.filter(
60
+ (fs) => !fieldsetsToExclude.includes(fs.id),
61
+ );
62
+
63
+ // TL;DR: There is a flash of the non block-based view because of the reset
64
+ // of the content on route change. Subscribing to the content change at this
65
+ // level has nasty implications, so we can't watch the Redux state for loaded
66
+ // content flag here (because it forces an additional component update)
67
+ // Instead, we can watch if the content is "empty", but this has a drawback
68
+ // since the locking mechanism inserts a `lock` key before the content is there.
69
+ // So "empty" means `content` is present, but only with a `lock` key, thus the next
70
+ // ugly condition comes to life
71
+ const contentLoaded = content && !isEqual(Object.keys(content), ['lock']);
72
+
73
+ React.useEffect(() => {
74
+ content?.['@type'] &&
75
+ !hasBlocksData(content) &&
76
+ dispatch(getSchema(content['@type'], location.pathname));
77
+ // eslint-disable-next-line react-hooks/exhaustive-deps
78
+ }, []);
79
+
80
+ const Container =
81
+ config.getComponent({ name: 'Container' }).component || SemanticContainer;
82
+
83
+ // If the content is not yet loaded, then do not show anything
84
+ return contentLoaded ? (
85
+ hasBlocksData(content) ? (
86
+ <Container id="page-document">
87
+ <Helmet
88
+ link={[
89
+ {
90
+ rel: 'alternate',
91
+ title: props.intl.formatMessage(messages.rssFeed),
92
+ href: `${addAppURL(path)}/rss.xml`,
93
+ type: 'application/rss+xml',
94
+ },
95
+ ]}
96
+ />
97
+ <Segment>
98
+ <Button
99
+ primary
100
+ onClick={() => {
101
+ copy();
102
+ setVisible(true);
103
+ }}
104
+ >
105
+ <Icon className="ri-rss-fill" />
106
+ RSS Feed
107
+ </Button>
108
+ {visible && (
109
+ <p>
110
+ Copied! The RSS link is{' '}
111
+ <a
112
+ href={`${addAppURL(path)}/rss.xml`}
113
+ target="_blank"
114
+ rel="noopener"
115
+ >
116
+ {' '}
117
+ {`${addAppURL(path)}/rss.xml`}
118
+ </a>
119
+ </p>
120
+ )}
121
+ <div style={{ marginTop: '2em' }}>
122
+ <RenderBlocks {...props} path={path} />
123
+ </div>
124
+ </Segment>
125
+ </Container>
126
+ ) : (
127
+ <Container id="page-document">
128
+ {fieldsets?.map((fs) => {
129
+ return (
130
+ <div className="fieldset" key={fs.id}>
131
+ {fs.id !== 'default' && <h2>{fs.title}</h2>}
132
+ {fs.fields?.map((f, key) => {
133
+ let field = {
134
+ ...contentSchema?.properties[f],
135
+ id: f,
136
+ widget: getWidget(f, contentSchema?.properties[f]),
137
+ };
138
+ let Widget = views?.getWidget(field);
139
+ return f !== 'title' ? (
140
+ <Grid celled="internally" key={key}>
141
+ <Grid.Row>
142
+ <Label title={field.id}>{field.title}:</Label>
143
+ </Grid.Row>
144
+ <Grid.Row>
145
+ <Segment basic>
146
+ <Widget value={content[f]} />
147
+ </Segment>
148
+ </Grid.Row>
149
+ </Grid>
150
+ ) : (
151
+ <Widget key={key} value={content[f]} />
152
+ );
153
+ })}
154
+ </div>
155
+ );
156
+ })}
157
+ </Container>
158
+ )
159
+ ) : null;
160
+ };
161
+
162
+ /**
163
+ * Property types.
164
+ * @property {Object} propTypes Property types.
165
+ * @static
166
+ */
167
+ DefaultView.propTypes = {
168
+ /**
169
+ * Content of the object
170
+ */
171
+ content: PropTypes.shape({
172
+ /**
173
+ * Title of the object
174
+ */
175
+ title: PropTypes.string,
176
+ /**
177
+ * Description of the object
178
+ */
179
+ description: PropTypes.string,
180
+ /**
181
+ * Text of the object
182
+ */
183
+ text: PropTypes.shape({
184
+ /**
185
+ * Data of the text of the object
186
+ */
187
+ data: PropTypes.string,
188
+ }),
189
+ }).isRequired,
190
+ };
191
+
192
+ export default compose(injectIntl)(DefaultView);
@@ -0,0 +1,283 @@
1
+ import express from 'express';
2
+ import superagent from 'superagent';
3
+ import RSS from 'rss';
4
+ import { findBlocks, toPublicURL } from '@plone/volto/helpers';
5
+ import config from '@plone/volto/registry';
6
+
7
+ /**
8
+ * Retrieves the query data (search criteria) used by the listing block of the rss_feed content type
9
+ * as well as the language, description, title, subjects (tags), date, max_description_length, and
10
+ * max_title_length of the rss_feed.
11
+ *
12
+ * The returned query object will have the following format:
13
+ * {
14
+ * query: [
15
+ * {
16
+ * i: <index>,
17
+ * o: <operator>,
18
+ * v: <value of the search criteria>
19
+ * }
20
+ * ],
21
+ * sort_order: 'ascending',
22
+ * b_size: 25,
23
+ * metadata_fields: '_all',
24
+ * b_start: 0
25
+ * }
26
+ *
27
+ * @function getRssFeedData
28
+ * @param {string} apiPath - The base path for the API requests.
29
+ * @param {string} APISUFIX - The suffix added to the API path depending on the environment.
30
+ * @param {Object} req - The incoming Express request object.
31
+ * @param {Object} settings - Configuration settings for the application.
32
+ * @return {Object} An object containing the query data, language, description, and title of the rss_feed.
33
+ * @throws Will throw an error if no query data is found in the listing block or if the request fails.
34
+ */
35
+ async function getRssFeedData(apiPath, APISUFIX, req, settings) {
36
+ try {
37
+ const request = superagent
38
+ .get(
39
+ `${apiPath}${__DEVELOPMENT__ ? '' : APISUFIX}${req.path.replace(
40
+ '/rss.xml',
41
+ '',
42
+ )}`,
43
+ )
44
+ .accept('json');
45
+
46
+ const authToken = req.universalCookies.get('auth_token');
47
+ if (authToken) {
48
+ request.set('Authorization', `Bearer ${authToken}`);
49
+ }
50
+
51
+ const response = await request;
52
+ const json = JSON.parse(JSON.stringify(response.body));
53
+ const listingBlock = findBlocks(json.blocks, 'listing');
54
+ let queryData = json.blocks[listingBlock]?.querystring;
55
+ let language = json.language.token ?? 'en';
56
+ let description = json.description ?? 'A Volto RSS Feed';
57
+ let title = json.title;
58
+ let subjects = json.subjects;
59
+ let date = json.effective;
60
+ let max_description_length = json.max_description_length;
61
+ let max_title_length = json.max_title_length;
62
+ if (!queryData) {
63
+ throw new Error('No query data found in listing block');
64
+ }
65
+
66
+ if (queryData?.sort_order !== null) {
67
+ if (typeof queryData.sort_order === 'boolean') {
68
+ queryData.sort_order = queryData.sort_order ? 'reverse' : 'ascending';
69
+ }
70
+
71
+ if (queryData.sort_order === 'descending') {
72
+ queryData.sort_order = 'reverse';
73
+ }
74
+ }
75
+
76
+ let query = {
77
+ ...queryData,
78
+ ...(!queryData.b_size && {
79
+ b_size: settings.defaultPageSize,
80
+ }),
81
+ query: queryData?.query,
82
+ metadata_fields: '_all',
83
+ b_start: 0,
84
+ };
85
+ return {
86
+ query,
87
+ language,
88
+ description,
89
+ title,
90
+ subjects,
91
+ date,
92
+ max_description_length,
93
+ max_title_length,
94
+ };
95
+ } catch (err) {
96
+ throw err;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Fetches the listing block items based on the provided query data.
102
+ *
103
+ * The function sends a POST request to the @querystring-search endpoint with the query data
104
+ * and returns the items that match the search criteria.
105
+ * ref: https://6.docs.plone.org/plone.restapi/docs/source/endpoints/querystringsearch.html
106
+ *
107
+ * @function fetchListingItems
108
+ * @param {Object} query - The query data used for fetching items.
109
+ * @param {string} apiPath - The base path for the API requests.
110
+ * @param {string} APISUFIX - The suffix added to the API path depending on the environment.
111
+ * @param {string} authToken - The authentication token for authorized requests.
112
+ * @return {Array} An array of items that match the query criteria.
113
+ * @throws Will throw an error if the request fails.
114
+ */
115
+ async function fetchListingItems(query, apiPath, APISUFIX, authToken) {
116
+ try {
117
+ const request = superagent
118
+ .post(`${apiPath}${__DEVELOPMENT__ ? '' : APISUFIX}/@querystring-search`)
119
+ .send(query)
120
+ .accept('json');
121
+
122
+ if (authToken) {
123
+ request.set('Authorization', `Bearer ${authToken}`);
124
+ }
125
+
126
+ const response = await request;
127
+ return response.body.items;
128
+ } catch (err) {
129
+ throw err;
130
+ }
131
+ }
132
+
133
+ /** Truncates the text to the specified length.
134
+ * If the text is longer than the specified length, it will be truncated and '...' will be appended.
135
+ * @function truncateText
136
+ * @param {string} text - The text to be truncated.
137
+ * @param {number} maxLength - The maximum length of the text.
138
+ * @return {string} The truncated text.
139
+ */
140
+ function truncateText(text, maxLength) {
141
+ return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
142
+ }
143
+
144
+ /**
145
+ * Creates an Express middleware for generating an RSS feed using the listing block of the
146
+ * rss_feed content type.
147
+ *
148
+ * The middleware fetches the query data of the listing block, retrieves the matching items,
149
+ * and generates an RSS feed in XML format, which is sent as the response.
150
+ *
151
+ * @function make_rssMiddleware
152
+ * @return {Function} An Express middleware function for generating the RSS feed.
153
+ */
154
+ function make_rssMiddleware() {
155
+ const { settings } = config;
156
+ const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
157
+ let apiPath = '';
158
+ const host = process.env.HOST || 'localhost';
159
+ const port = process.env.PORT || '3000';
160
+ const ProdapiPath = `http://${host}:${port}`;
161
+ if (settings.internalApiPath && __SERVER__) {
162
+ apiPath = settings.internalApiPath;
163
+ } else if (__DEVELOPMENT__ && settings.devProxyToApiPath) {
164
+ apiPath = settings.devProxyToApiPath;
165
+ } else {
166
+ apiPath = settings.apiPath;
167
+ }
168
+ if (apiPath === '') {
169
+ apiPath = ProdapiPath;
170
+ }
171
+
172
+ async function rssMiddleware(req, res, next) {
173
+ try {
174
+ const {
175
+ query,
176
+ language,
177
+ description,
178
+ title,
179
+ subjects,
180
+ date,
181
+ max_description_length,
182
+ max_title_length,
183
+ } = await getRssFeedData(apiPath, APISUFIX, req, settings);
184
+ const items = await fetchListingItems(
185
+ query,
186
+ apiPath,
187
+ APISUFIX,
188
+ req.universalCookies.get('auth_token'),
189
+ );
190
+ const feedOptions = {
191
+ title: truncateText(title, max_title_length),
192
+ description: truncateText(description, max_description_length),
193
+ feed_url: `${settings.publicURL}${req.path}`,
194
+ site_url: settings.publicURL,
195
+ generator: 'RSS Feed Generator',
196
+ language: language,
197
+ pubDate: new Date(date),
198
+ };
199
+ if (subjects) {
200
+ for (let i = 0; i < subjects.length; i++) {
201
+ feedOptions.categories = subjects;
202
+ }
203
+ }
204
+ const feed = new RSS(feedOptions);
205
+
206
+ items.forEach((item) => {
207
+ let link = toPublicURL(item['getURL']);
208
+ let enclosure = undefined;
209
+ if (
210
+ item.image_field &&
211
+ item.image_scales &&
212
+ item.image_scales[item.image_field]
213
+ ) {
214
+ const imageData = item.image_scales[item.image_field][0];
215
+ const imageUrl = `${link
216
+ .concat('/')
217
+ .concat(
218
+ item.image_scales[item.image_field][0].scales.preview.download,
219
+ )}`;
220
+ const mimeType = item['content-type'];
221
+ const originalSize = imageData.size;
222
+ enclosure = {
223
+ url: imageUrl,
224
+ type: mimeType,
225
+ size: originalSize,
226
+ };
227
+ }
228
+ feed.item({
229
+ title: truncateText(item.title, max_title_length),
230
+ description: truncateText(item.description, max_description_length),
231
+ url: link,
232
+ guid: item.UID,
233
+ date: new Date(item.effective),
234
+ author: item['listCreators']
235
+ ? item['listCreators'].map((creator) => creator).join(', ')
236
+ : undefined,
237
+ categories: item.Subject ? item.Subject : [],
238
+ enclosure: enclosure || undefined,
239
+ });
240
+ });
241
+
242
+ const xml = feed.xml({ indent: true });
243
+ res.setHeader('content-type', 'application/atom+xml');
244
+ res.send(xml);
245
+ } catch (err) {
246
+ if (err.response && err.response.status === 401) {
247
+ // Handle unauthorized errors
248
+ res.status(401).json({
249
+ error: 'Unauthorized',
250
+ message:
251
+ 'You are not authorized to access the requested RSS feed. Please log in and try again.',
252
+ });
253
+ } else if (err.response && err.response.status === 404) {
254
+ // Handle not found errors
255
+ res.status(404).json({
256
+ error: 'Not Found',
257
+ message:
258
+ 'The requested RSS feed could not be found. Please verify the URL and try again.',
259
+ });
260
+ } else {
261
+ // Handle other errors
262
+ res.status(500).json({
263
+ error: 'Internal Server Error',
264
+ message:
265
+ 'An unexpected error occurred while generating the RSS feed. Please try again later.',
266
+ });
267
+ }
268
+ return;
269
+ }
270
+ }
271
+
272
+ return rssMiddleware;
273
+ }
274
+
275
+ export default function makeMiddlewares() {
276
+ const middleware = express.Router();
277
+ middleware.use(express.urlencoded({ extended: true }));
278
+ middleware.all('**/rss.xml', make_rssMiddleware());
279
+
280
+ middleware.id = 'rss-middleware';
281
+
282
+ return middleware;
283
+ }