@plone/volto 17.0.0-alpha.3 → 17.0.0-alpha.5

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.
Files changed (45) hide show
  1. package/.changelog.draft +6 -12
  2. package/.yarn/install-state.gz +0 -0
  3. package/CHANGELOG.md +94 -2
  4. package/README.md +5 -8
  5. package/addon-registry.js +34 -0
  6. package/create-theme-addons-loader.js +79 -0
  7. package/locales/ca/LC_MESSAGES/volto.po +2 -2
  8. package/locales/de/LC_MESSAGES/volto.po +4 -4
  9. package/locales/de.json +1 -1
  10. package/locales/en/LC_MESSAGES/volto.po +2 -2
  11. package/locales/en.json +1 -1
  12. package/locales/es/LC_MESSAGES/volto.po +2 -2
  13. package/locales/eu/LC_MESSAGES/volto.po +2 -2
  14. package/locales/fr/LC_MESSAGES/volto.po +2 -2
  15. package/locales/it/LC_MESSAGES/volto.po +2 -2
  16. package/locales/ja/LC_MESSAGES/volto.po +2 -2
  17. package/locales/nl/LC_MESSAGES/volto.po +2 -2
  18. package/locales/pt/LC_MESSAGES/volto.po +2 -2
  19. package/locales/pt_BR/LC_MESSAGES/volto.po +2 -2
  20. package/locales/ro/LC_MESSAGES/volto.po +2 -2
  21. package/locales/volto.pot +3 -3
  22. package/locales/zh_CN/LC_MESSAGES/volto.po +2 -2
  23. package/package.json +1 -1
  24. package/packages/volto-slate/package.json +1 -1
  25. package/pyvenv.cfg +1 -1
  26. package/razzle.config.js +23 -0
  27. package/src/actions/language/language.js +1 -0
  28. package/src/actions/querystringsearch/querystringsearch.js +20 -14
  29. package/src/components/manage/Blocks/Search/SearchBlockEdit.jsx +5 -4
  30. package/src/components/manage/Blocks/Search/hocs/withSearch.jsx +24 -11
  31. package/src/components/manage/Contents/Contents.jsx +14 -11
  32. package/src/components/manage/Widgets/SelectUtils.js +1 -1
  33. package/src/components/manage/Widgets/SelectWidget.jsx +1 -1
  34. package/src/components/theme/PasswordReset/PasswordReset.jsx +1 -1
  35. package/src/components/theme/PasswordReset/RequestPasswordReset.jsx +1 -1
  36. package/src/components/theme/View/DefaultView.jsx +1 -1
  37. package/src/config/Widgets.jsx +1 -0
  38. package/src/config/index.js +1 -0
  39. package/src/express-middleware/sitemap.js +36 -4
  40. package/src/helpers/FormValidation/FormValidation.js +10 -1
  41. package/src/helpers/FormValidation/FormValidation.test.js +41 -0
  42. package/src/helpers/Robots/Robots.js +9 -6
  43. package/src/helpers/Sitemap/Sitemap.js +44 -2
  44. package/styles/Vocab/Plone/accept.txt +2 -0
  45. package/styles/Vocab/Plone/reject.txt +5 -0
@@ -1,5 +1,4 @@
1
- import React from 'react';
2
- import useDeepCompareEffect from 'use-deep-compare-effect';
1
+ import React, { useEffect } from 'react';
3
2
  import { defineMessages } from 'react-intl';
4
3
  import { compose } from 'redux';
5
4
 
@@ -60,9 +59,11 @@ const SearchBlockEdit = (props) => {
60
59
  };
61
60
 
62
61
  const { query = {} } = data || {};
63
- useDeepCompareEffect(() => {
62
+ // We don't need deep compare here, as this is just json serializable data.
63
+ const deepQuery = JSON.stringify(query);
64
+ useEffect(() => {
64
65
  onTriggerSearch();
65
- }, [query, onTriggerSearch]);
66
+ }, [deepQuery, onTriggerSearch]);
66
67
 
67
68
  return (
68
69
  <>
@@ -114,15 +114,19 @@ function normalizeState({
114
114
  block: id,
115
115
  };
116
116
 
117
- // TODO: need to check if SearchableText facet is not already in the query
118
- // Ideally the searchtext functionality should be restructured as being just
119
- // another facet
120
- params.query = params.query.reduce(
121
- // Remove SearchableText from query
122
- (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]),
123
- [],
124
- );
117
+ // Note Ideally the searchtext functionality should be restructured as being just
118
+ // another facet. But right now it's the same. This means that if a searchText
119
+ // is provided, it will override the SearchableText facet.
120
+ // If there is no searchText, the SearchableText in the query remains in effect.
121
+ // TODO eventually the searchText should be a distinct facet from SearchableText, and
122
+ // the two conditions could be combined, in comparison to the current state, when
123
+ // one overrides the other.
125
124
  if (searchText) {
125
+ params.query = params.query.reduce(
126
+ // Remove SearchableText from query
127
+ (acc, kvp) => (kvp.i === 'SearchableText' ? acc : [...acc, kvp]),
128
+ [],
129
+ );
126
130
  params.query.push({
127
131
  i: 'SearchableText',
128
132
  o: 'plone.app.querystring.operation.string.contains',
@@ -278,8 +282,14 @@ const withSearch = (options) => (WrappedComponent) => {
278
282
  const timeoutRef = React.useRef();
279
283
  const facetSettings = data?.facets;
280
284
 
285
+ const deepQuery = JSON.stringify(data.query);
281
286
  const onTriggerSearch = React.useCallback(
282
- (toSearchText, toSearchFacets, toSortOn, toSortOrder) => {
287
+ (
288
+ toSearchText = undefined,
289
+ toSearchFacets = undefined,
290
+ toSortOn = undefined,
291
+ toSortOrder = undefined,
292
+ ) => {
283
293
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
284
294
  timeoutRef.current = setTimeout(
285
295
  () => {
@@ -287,7 +297,7 @@ const withSearch = (options) => (WrappedComponent) => {
287
297
  id,
288
298
  query: data.query || {},
289
299
  facets: toSearchFacets || facets,
290
- searchText: toSearchText,
300
+ searchText: toSearchText || searchText,
291
301
  sortOn: toSortOn || sortOn,
292
302
  sortOrder: toSortOrder || sortOrder,
293
303
  facetSettings,
@@ -301,11 +311,14 @@ const withSearch = (options) => (WrappedComponent) => {
301
311
  toSearchFacets ? inputDelay / 3 : inputDelay,
302
312
  );
303
313
  },
314
+ // eslint-disable-next-line react-hooks/exhaustive-deps
304
315
  [
305
- data.query,
316
+ // Use deep comparison of data.query
317
+ deepQuery,
306
318
  facets,
307
319
  id,
308
320
  setLocationSearchData,
321
+ searchText,
309
322
  sortOn,
310
323
  sortOrder,
311
324
  facetSettings,
@@ -797,17 +797,20 @@ class Contents extends Component {
797
797
  onMoveToTop(event, { value }) {
798
798
  const id = this.state.items[value]['@id'];
799
799
  value = this.state.currentPage * this.state.pageSize + value;
800
- this.props.orderContent(
801
- getBaseUrl(this.props.pathname),
802
- id.replace(/^.*\//, ''),
803
- -value,
804
- );
805
- this.setState(
806
- {
807
- currentPage: 0,
808
- },
809
- () => this.fetchContents(),
810
- );
800
+ this.props
801
+ .orderContent(
802
+ getBaseUrl(this.props.pathname),
803
+ id.replace(/^.*\//, ''),
804
+ -value,
805
+ )
806
+ .then(() => {
807
+ this.setState(
808
+ {
809
+ currentPage: 0,
810
+ },
811
+ () => this.fetchContents(),
812
+ );
813
+ });
811
814
  }
812
815
 
813
816
  /**
@@ -54,7 +54,7 @@ export function normalizeSingleSelectOption(value, intl) {
54
54
  throw new Error(`Unknown value type of select widget: ${value}`);
55
55
  }
56
56
 
57
- const token = value.token ?? value.value ?? 'no-value';
57
+ const token = value.token ?? value.value ?? value.UID ?? 'no-value';
58
58
  const label =
59
59
  (value.title && value.title !== 'None' ? value.title : undefined) ??
60
60
  value.label ??
@@ -202,7 +202,7 @@ class SelectWidget extends Component {
202
202
 
203
203
  const isMulti = this.props.isMulti
204
204
  ? this.props.isMulti
205
- : id === 'roles' || id === 'groups';
205
+ : id === 'roles' || id === 'groups' || this.props.type === 'array';
206
206
 
207
207
  return (
208
208
  <FormFieldWrapper {...this.props}>
@@ -31,7 +31,7 @@ const messages = defineMessages({
31
31
  },
32
32
  usernameTitle: {
33
33
  id: 'My username is',
34
- defaultMessage: 'My username is',
34
+ defaultMessage: 'My user name is',
35
35
  },
36
36
  emailTitle: {
37
37
  id: 'My email is',
@@ -32,7 +32,7 @@ const messages = defineMessages({
32
32
  },
33
33
  usernameTitle: {
34
34
  id: 'label_my_username_is',
35
- defaultMessage: 'My username is',
35
+ defaultMessage: 'My user name is',
36
36
  },
37
37
  emailTitle: {
38
38
  id: 'label_my_email_is',
@@ -87,7 +87,7 @@ const DefaultView = (props) => {
87
87
  return f !== 'title' ? (
88
88
  <Grid celled="internally" key={key}>
89
89
  <Grid.Row>
90
- <Label>{field.title}:</Label>
90
+ <Label title={field.id}>{field.title}:</Label>
91
91
  </Grid.Row>
92
92
  <Grid.Row>
93
93
  <Segment basic>
@@ -98,6 +98,7 @@ export const widgetMapping = {
98
98
  select_querystring_field: SelectMetadataWidget,
99
99
  autocomplete: SelectAutoComplete,
100
100
  color_picker: ColorPickerWidget,
101
+ select: SelectWidget,
101
102
  },
102
103
  vocabulary: {
103
104
  'plone.app.vocabularies.Catalog': ObjectBrowserWidget,
@@ -180,6 +180,7 @@ let config = {
180
180
  styleClassNameConverters,
181
181
  hashLinkSmoothScroll: false,
182
182
  styleClassNameExtenders,
183
+ querystringSearchGet: false,
183
184
  },
184
185
  experimental: {
185
186
  addBlockButton: {
@@ -1,12 +1,34 @@
1
1
  import express from 'express';
2
- import { generateSitemap } from '@plone/volto/helpers/Sitemap/Sitemap';
2
+ import {
3
+ generateSitemap,
4
+ generateSitemapIndex,
5
+ SITEMAP_BATCH_SIZE,
6
+ } from '@plone/volto/helpers/Sitemap/Sitemap';
3
7
 
4
8
  export const sitemap = function (req, res, next) {
5
- generateSitemap(req).then((sitemap) => {
9
+ let start = 0;
10
+ let size = undefined;
11
+ const { batch: batchStr } = req.params;
12
+ if (batchStr !== undefined) {
13
+ const batch = parseInt(batchStr);
14
+ if (isNaN(batch) || batch === 0 || '' + batch !== batchStr) {
15
+ res.status(404);
16
+ // Some data, such as the internal API address, may be sensitive to be published
17
+ res.send(
18
+ `Invalid sitemap name, use sitemap.xml.gz, or batched sitemapN.xml.gz where N is a positive integer.`,
19
+ );
20
+ return;
21
+ }
22
+ start = SITEMAP_BATCH_SIZE * (batch - 1);
23
+ size = SITEMAP_BATCH_SIZE;
24
+ }
25
+ generateSitemap(req, start, size).then((sitemap) => {
6
26
  if (Buffer.isBuffer(sitemap)) {
7
27
  res.set('Content-Type', 'application/x-gzip');
8
- res.set('Content-Encoding', 'gzip');
9
- res.set('Content-Disposition', 'attachment; filename="sitemap.xml.gz"');
28
+ res.set(
29
+ 'Content-Disposition',
30
+ `attachment; filename="sitemap${batchStr || ''}.xml.gz"`,
31
+ );
10
32
  res.send(sitemap);
11
33
  } else {
12
34
  // {"errno":-111, "code":"ECONNREFUSED", "host": ...}
@@ -17,10 +39,20 @@ export const sitemap = function (req, res, next) {
17
39
  });
18
40
  };
19
41
 
42
+ export const sitemapIndex = function (req, res, next) {
43
+ generateSitemapIndex(req).then((sitemapIndex) => {
44
+ res.set('Content-Type', 'application/xml');
45
+ res.set('Content-Disposition', 'attachment; filename="sitemap-index.xml"');
46
+ res.send(sitemapIndex);
47
+ });
48
+ };
49
+
20
50
  export default function () {
21
51
  const middleware = express.Router();
22
52
 
23
53
  middleware.all('**/sitemap.xml.gz', sitemap);
54
+ middleware.all('**/sitemap:batch.xml.gz', sitemap);
55
+ middleware.all('**/sitemap-index.xml', sitemapIndex);
24
56
  middleware.id = 'sitemap.xml.gz';
25
57
  return middleware;
26
58
  }
@@ -65,7 +65,16 @@ const widgetValidation = {
65
65
  },
66
66
  url: {
67
67
  isValidURL: (urlValue, urlObj, intlFunc) => {
68
- const urlRegex = /^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^(http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?[a-z0-9]+([_.]{1}[a-z0-9]+)*\.[a-z]{2,5}(:[0-9]{1,5})?(\/.*)?|^((http:\/\/www\.|https:\/\/www\.|http:\/\/|https:\/\/)?([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/gm;
68
+ var urlRegex = new RegExp(
69
+ '^(https?:\\/\\/)?' + // validate protocol
70
+ '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // validate domain name
71
+ '((\\d{1,3}\\.){3}\\d{1,3}))|' + // validate OR ip (v4) address
72
+ '(localhost)' + // validate OR localhost address
73
+ '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*' + // validate port and path
74
+ '(\\?[;&a-z\\d%_.~+=-]*)?' + // validate query string
75
+ '(\\#[-a-z\\d_]*)?$', // validate fragment locator
76
+ 'i',
77
+ );
69
78
  const isValid = urlRegex.test(urlValue);
70
79
  return !isValid ? intlFunc(messages.isValidURL) : null;
71
80
  },
@@ -5,6 +5,7 @@ const schema = {
5
5
  properties: {
6
6
  username: { title: 'Username', type: 'string', description: '' },
7
7
  email: { title: 'Email', type: 'string', widget: 'email', description: '' },
8
+ url: { title: 'url', type: 'string', widget: 'url', description: '' },
8
9
  },
9
10
  fieldsets: [
10
11
  { id: 'default', title: 'FIXME: User Data', fields: ['username'] },
@@ -87,5 +88,45 @@ describe('FormValidation', () => {
87
88
  }),
88
89
  ).toEqual({});
89
90
  });
91
+ it('validates incorrect url', () => {
92
+ formData.url = 'foo';
93
+ expect(
94
+ FormValidation.validateFieldsPerFieldset({
95
+ schema,
96
+ formData,
97
+ formatMessage,
98
+ }),
99
+ ).toEqual({ url: [messages.isValidURL.defaultMessage] });
100
+ });
101
+ it('validates url', () => {
102
+ formData.url = 'https://plone.org/';
103
+ expect(
104
+ FormValidation.validateFieldsPerFieldset({
105
+ schema,
106
+ formData,
107
+ formatMessage,
108
+ }),
109
+ ).toEqual({});
110
+ });
111
+ it('validates url with ip', () => {
112
+ formData.url = 'http://127.0.0.1:8080/Plone';
113
+ expect(
114
+ FormValidation.validateFieldsPerFieldset({
115
+ schema,
116
+ formData,
117
+ formatMessage,
118
+ }),
119
+ ).toEqual({});
120
+ });
121
+ it('validates url with localhost', () => {
122
+ formData.url = 'http://localhost:8080/Plone';
123
+ expect(
124
+ FormValidation.validateFieldsPerFieldset({
125
+ schema,
126
+ formData,
127
+ formatMessage,
128
+ }),
129
+ ).toEqual({});
130
+ });
90
131
  });
91
132
  });
@@ -15,12 +15,9 @@ import { addHeadersFactory } from '@plone/volto/helpers/Proxy/Proxy';
15
15
  */
16
16
  export const generateRobots = (req) =>
17
17
  new Promise((resolve) => {
18
- //const url = `${req.protocol}://${req.get('Host')}`;
19
- const request = superagent.get(
20
- `${
21
- config.settings.internalApiPath ?? config.settings.apiPath
22
- }/robots.txt`,
23
- );
18
+ const internalUrl =
19
+ config.settings.internalApiPath ?? config.settings.apiPath;
20
+ const request = superagent.get(`${internalUrl}/robots.txt`);
24
21
  request.set('Accept', 'text/plain');
25
22
  const authToken = req.universalCookies.get('auth_token');
26
23
  if (authToken) {
@@ -31,6 +28,12 @@ export const generateRobots = (req) =>
31
28
  if (error) {
32
29
  resolve(text || error);
33
30
  } else {
31
+ // Plone has probably returned the sitemap link with the internal url.
32
+ // If so, let's replace it with the current one.
33
+ const url = `${req.protocol}://${req.get('Host')}`;
34
+ text = text.replace(internalUrl, url);
35
+ // Replace the sitemap with the sitemap index.
36
+ text = text.replace('sitemap.xml.gz', 'sitemap-index.xml');
34
37
  resolve(text);
35
38
  }
36
39
  });
@@ -11,19 +11,23 @@ import { addHeadersFactory } from '@plone/volto/helpers/Proxy/Proxy';
11
11
 
12
12
  import config from '@plone/volto/registry';
13
13
 
14
+ export const SITEMAP_BATCH_SIZE = 5000;
15
+
14
16
  /**
15
17
  * Generate sitemap
16
18
  * @function generateSitemap
17
19
  * @param {Object} _req Request object
18
20
  * @return {string} Generated sitemap
19
21
  */
20
- export const generateSitemap = (_req) =>
22
+ export const generateSitemap = (_req, start = 0, size = undefined) =>
21
23
  new Promise((resolve) => {
22
24
  const { settings } = config;
23
25
  const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
24
26
  const apiPath = settings.internalApiPath ?? settings.apiPath;
25
27
  const request = superagent.get(
26
- `${apiPath}${APISUFIX}/@search?metadata_fields=modified&b_size=100000000&use_site_search_settings=1`,
28
+ `${apiPath}${APISUFIX}/@search?metadata_fields=modified&b_start=${start}&b_size=${
29
+ size !== undefined ? size : 100000000
30
+ }&use_site_search_settings=1`,
27
31
  );
28
32
  request.set('Accept', 'application/json');
29
33
  request.use(addHeadersFactory(_req));
@@ -50,3 +54,41 @@ export const generateSitemap = (_req) =>
50
54
  }
51
55
  });
52
56
  });
57
+
58
+ /**
59
+ * Generate sitemap
60
+ * @function generateSitemapIndex
61
+ * @param {Object} _req Request object
62
+ * @return {string} Generated sitemap index
63
+ */
64
+ export const generateSitemapIndex = (_req) =>
65
+ new Promise((resolve) => {
66
+ const { settings } = config;
67
+ const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
68
+ const apiPath = settings.internalApiPath ?? settings.apiPath;
69
+ const request = superagent.get(
70
+ `${apiPath}${APISUFIX}/@search?metadata_fields=modified&b_size=0&use_site_search_settings=1`,
71
+ );
72
+ request.set('Accept', 'application/json');
73
+ const authToken = _req.universalCookies.get('auth_token');
74
+ if (authToken) {
75
+ request.set('Authorization', `Bearer ${authToken}`);
76
+ }
77
+ request.end((error, { body } = {}) => {
78
+ if (error) {
79
+ resolve(body || error);
80
+ } else {
81
+ const items = Array.from(
82
+ { length: Math.ceil(body.items_total / SITEMAP_BATCH_SIZE) },
83
+ (_, i) =>
84
+ ` <sitemap>
85
+ <loc>${toPublicURL('/sitemap' + (i + 1) + '.xml.gz')}</loc>
86
+ </sitemap>`,
87
+ );
88
+ const result = `<?xml version="1.0" encoding="UTF-8"?>
89
+ <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
90
+ ${items.join('\n')}\n</sitemapindex>`;
91
+ resolve(result);
92
+ }
93
+ });
94
+ });
@@ -6,3 +6,5 @@ Razzle
6
6
  RichText
7
7
  Volto
8
8
  Zope
9
+ JavaScript
10
+ NodeJS
@@ -0,0 +1,5 @@
1
+ node
2
+ nodejs
3
+ javascript
4
+ js
5
+ Javascript