@plone/volto 17.0.0-alpha.4 → 17.0.0-alpha.6

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 (38) hide show
  1. package/.changelog.draft +20 -20
  2. package/.yarn/install-state.gz +0 -0
  3. package/CHANGELOG.md +98 -13
  4. package/CONTRIBUTING.md +1 -1
  5. package/README.md +9 -12
  6. package/locales/de/LC_MESSAGES/volto.po +17 -17
  7. package/locales/de.json +1 -1
  8. package/package.json +3 -2
  9. package/packages/volto-slate/package.json +1 -1
  10. package/src/components/manage/Blocks/Listing/Edit.jsx +0 -14
  11. package/src/components/manage/Blocks/Listing/ListingBody.test.jsx +0 -20
  12. package/src/components/manage/Blocks/Listing/getAsyncData.js +10 -2
  13. package/src/components/manage/Blocks/Listing/withQuerystringResults.jsx +20 -14
  14. package/src/components/manage/Blocks/Search/SearchBlockEdit.jsx +5 -4
  15. package/src/components/manage/Blocks/Search/SearchBlockView.jsx +2 -1
  16. package/src/components/manage/Blocks/Search/hocs/withSearch.jsx +28 -19
  17. package/src/components/manage/Contents/Contents.jsx +29 -24
  18. package/src/components/manage/Controlpanels/Controlpanels.jsx +190 -224
  19. package/src/components/manage/Controlpanels/Controlpanels.test.jsx +46 -7
  20. package/src/components/manage/Form/InlineForm.jsx +39 -9
  21. package/src/components/manage/Form/InlineFormState.js +8 -0
  22. package/src/components/manage/Widgets/ObjectListWidget.jsx +3 -8
  23. package/src/components/theme/Icon/Icon.jsx +2 -2
  24. package/src/components/theme/Login/Login.jsx +1 -0
  25. package/src/config/index.js +1 -0
  26. package/src/express-middleware/sitemap.js +36 -3
  27. package/src/helpers/Robots/Robots.js +24 -6
  28. package/src/helpers/Sitemap/Sitemap.js +44 -2
  29. package/src/helpers/Url/Url.js +8 -3
  30. package/src/helpers/Url/Url.test.js +14 -0
  31. package/src/helpers/Utils/Utils.js +17 -4
  32. package/src/helpers/Utils/usePagination.js +14 -48
  33. package/src/helpers/index.js +2 -0
  34. package/src/middleware/Api.test.js +54 -0
  35. package/src/middleware/api.js +1 -1
  36. package/test-setup-config.js +1 -0
  37. package/theme/themes/pastanaga/extras/sidebar.less +4 -0
  38. package/src/helpers/Utils/usePagination.test.js +0 -115
@@ -1,11 +1,11 @@
1
1
  import React from 'react';
2
- import renderer from 'react-test-renderer';
3
- import configureStore from 'redux-mock-store';
4
2
  import { Provider } from 'react-intl-redux';
5
3
  import { MemoryRouter } from 'react-router-dom';
4
+ import renderer from 'react-test-renderer';
5
+ import configureStore from 'redux-mock-store';
6
6
 
7
- import Controlpanels from './Controlpanels';
8
7
  import config from '@plone/volto/registry';
8
+ import Controlpanels from './Controlpanels';
9
9
 
10
10
  const mockStore = configureStore();
11
11
 
@@ -20,7 +20,30 @@ jest.mock('./VersionOverview', () =>
20
20
  describe('Controlpanels', () => {
21
21
  it('renders a controlpanels component', () => {
22
22
  const store = mockStore({
23
- controlpanels: {
23
+ controlpanels: [
24
+ {
25
+ '@id': 'http://localhost:8080/Plone/@controlpanels/date-and-time',
26
+ group: 'General',
27
+ title: 'Date and Time',
28
+ },
29
+ {
30
+ '@id': 'http://localhost:8080/Plone/@controlpanels/lang',
31
+ group: 'General',
32
+ title: 'Language',
33
+ },
34
+ {
35
+ '@id': 'http://localhost:8080/Plone/@controlpanels/editing',
36
+ group: 'Content',
37
+ title: 'Editing',
38
+ },
39
+ {
40
+ '@id': 'http://localhost:8080/Plone/@controlpanels/security',
41
+ group: 'Security',
42
+ title: 'test',
43
+ },
44
+ ],
45
+ reduxAsyncConnect: {
46
+ // Mocked in redux async connect as it isn't fetch client-side.
24
47
  controlpanels: [
25
48
  {
26
49
  '@id': 'http://localhost:8080/Plone/@controlpanels/date-and-time',
@@ -40,10 +63,11 @@ describe('Controlpanels', () => {
40
63
  {
41
64
  '@id': 'http://localhost:8080/Plone/@controlpanels/security',
42
65
  group: 'Security',
43
- title: 'Security',
66
+ title: 'test',
44
67
  },
45
68
  ],
46
69
  },
70
+ router: { location: '/blog' },
47
71
  intl: {
48
72
  locale: 'en',
49
73
  messages: {},
@@ -62,9 +86,24 @@ describe('Controlpanels', () => {
62
86
 
63
87
  it('renders an additional control panel', () => {
64
88
  const store = mockStore({
65
- controlpanels: {
66
- controlpanels: [],
89
+ controlpanels: [
90
+ {
91
+ '@id': 'http://localhost:8080/Plone/@controlpanels/security',
92
+ group: 'Security',
93
+ title: 'test',
94
+ },
95
+ ],
96
+ reduxAsyncConnect: {
97
+ // Mocked in redux async connect as it isn't fetch client-side.
98
+ controlpanels: [
99
+ {
100
+ '@id': 'http://localhost:8080/Plone/@controlpanels/security',
101
+ group: 'Security',
102
+ title: 'test',
103
+ },
104
+ ],
67
105
  },
106
+ router: { location: '/blog' },
68
107
  intl: {
69
108
  locale: 'en',
70
109
  messages: {},
@@ -4,13 +4,21 @@ import { Accordion, Segment, Message } from 'semantic-ui-react';
4
4
  import { defineMessages, injectIntl } from 'react-intl';
5
5
  import AnimateHeight from 'react-animate-height';
6
6
  import { keys, map, isEqual } from 'lodash';
7
-
7
+ import { useAtom } from 'jotai';
8
+ import { inlineFormFieldsetsState } from './InlineFormState';
9
+ import {
10
+ insertInArray,
11
+ removeFromArray,
12
+ arrayRange,
13
+ } from '@plone/volto/helpers/Utils/Utils';
8
14
  import { Field, Icon } from '@plone/volto/components';
9
15
  import { applySchemaDefaults } from '@plone/volto/helpers';
10
16
 
11
17
  import upSVG from '@plone/volto/icons/up-key.svg';
12
18
  import downSVG from '@plone/volto/icons/down-key.svg';
13
19
 
20
+ import config from '@plone/volto/registry';
21
+
14
22
  const messages = defineMessages({
15
23
  editValues: {
16
24
  id: 'Edit values',
@@ -70,12 +78,34 @@ const InlineForm = (props) => {
70
78
  // eslint-disable-next-line react-hooks/exhaustive-deps
71
79
  }, []);
72
80
 
73
- const [currentActiveFieldset, setCurrentActiveFieldset] = React.useState(0);
81
+ const [currentActiveFieldset, setCurrentActiveFieldset] = useAtom(
82
+ inlineFormFieldsetsState({
83
+ name: block,
84
+ fielsetList: other,
85
+ initialState: config.settings.blockSettingsTabFieldsetsInitialStateOpen
86
+ ? arrayRange(0, other.length - 1, 1)
87
+ : [],
88
+ }),
89
+ );
90
+
74
91
  function handleCurrentActiveFieldset(e, blockProps) {
75
92
  const { index } = blockProps;
76
- const newIndex = currentActiveFieldset === index ? -1 : index;
77
-
78
- setCurrentActiveFieldset(newIndex);
93
+ if (currentActiveFieldset.includes(index)) {
94
+ setCurrentActiveFieldset(
95
+ removeFromArray(
96
+ currentActiveFieldset,
97
+ currentActiveFieldset.indexOf(index),
98
+ ),
99
+ );
100
+ } else {
101
+ setCurrentActiveFieldset(
102
+ insertInArray(
103
+ currentActiveFieldset,
104
+ index,
105
+ currentActiveFieldset.length + 1,
106
+ ),
107
+ );
108
+ }
79
109
  }
80
110
 
81
111
  return (
@@ -136,22 +166,22 @@ const InlineForm = (props) => {
136
166
  <Accordion fluid styled className="form" key={fieldset.id}>
137
167
  <div key={fieldset.id} id={`blockform-fieldset-${fieldset.id}`}>
138
168
  <Accordion.Title
139
- active={currentActiveFieldset === index}
169
+ active={currentActiveFieldset.includes(index)}
140
170
  index={index}
141
171
  onClick={handleCurrentActiveFieldset}
142
172
  >
143
173
  {fieldset.title && <>{fieldset.title}</>}
144
- {currentActiveFieldset === index ? (
174
+ {currentActiveFieldset.includes(index) ? (
145
175
  <Icon name={upSVG} size="20px" />
146
176
  ) : (
147
177
  <Icon name={downSVG} size="20px" />
148
178
  )}
149
179
  </Accordion.Title>
150
- <Accordion.Content active={currentActiveFieldset === index}>
180
+ <Accordion.Content active={currentActiveFieldset.includes(index)}>
151
181
  <AnimateHeight
152
182
  animateOpacity
153
183
  duration={500}
154
- height={currentActiveFieldset === index ? 'auto' : 0}
184
+ height={currentActiveFieldset.includes(index) ? 'auto' : 0}
155
185
  >
156
186
  <Segment className="attached">
157
187
  {map(fieldset.fields, (field) => (
@@ -0,0 +1,8 @@
1
+ import { atom } from 'jotai';
2
+ import { atomFamily } from 'jotai/utils';
3
+ import { isEqual } from 'lodash';
4
+
5
+ export const inlineFormFieldsetsState = atomFamily(
6
+ ({ name, initialState }) => atom(initialState),
7
+ (a, b) => a.name === b.name && isEqual(a.fielsetList, b.fielsetList),
8
+ );
@@ -2,7 +2,7 @@ import React from 'react';
2
2
  import { defineMessages, useIntl } from 'react-intl';
3
3
  import { Accordion, Button, Segment } from 'semantic-ui-react';
4
4
  import { DragDropList, FormFieldWrapper, Icon } from '@plone/volto/components';
5
- import { applySchemaDefaults } from '@plone/volto/helpers';
5
+ import { applySchemaDefaults, reorderArray } from '@plone/volto/helpers';
6
6
  import ObjectWidget from '@plone/volto/components/manage/Widgets/ObjectWidget';
7
7
 
8
8
  import upSVG from '@plone/volto/icons/up-key.svg';
@@ -164,13 +164,8 @@ const ObjectListWidget = (props) => {
164
164
  if (!destination) {
165
165
  return;
166
166
  }
167
-
168
- const first = value[source.index];
169
- const second = value[destination.index];
170
- value[destination.index] = first;
171
- value[source.index] = second;
172
-
173
- onChange(id, value);
167
+ const newValue = reorderArray(value, source.index, destination.index);
168
+ onChange(id, newValue);
174
169
  return true;
175
170
  }}
176
171
  >
@@ -44,8 +44,8 @@ const Icon = ({
44
44
  ariaHidden,
45
45
  }) => (
46
46
  <svg
47
- xmlns={name.attributes && name.attributes.xmlns}
48
- viewBox={name.attributes && name.attributes.viewBox}
47
+ xmlns={name?.attributes?.xmlns}
48
+ viewBox={name?.attributes?.viewBox}
49
49
  style={{
50
50
  height: size,
51
51
  width: 'auto',
@@ -239,6 +239,7 @@ class Login extends Component {
239
239
  <Input
240
240
  type="password"
241
241
  id="password"
242
+ autocomplete="current-password"
242
243
  name="password"
243
244
  placeholder={this.props.intl.formatMessage(
244
245
  messages.password,
@@ -181,6 +181,7 @@ let config = {
181
181
  hashLinkSmoothScroll: false,
182
182
  styleClassNameExtenders,
183
183
  querystringSearchGet: false,
184
+ blockSettingsTabFieldsetsInitialStateOpen: true,
184
185
  },
185
186
  experimental: {
186
187
  addBlockButton: {
@@ -1,11 +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-Disposition', 'attachment; filename="sitemap.xml.gz"');
28
+ res.set(
29
+ 'Content-Disposition',
30
+ `attachment; filename="sitemap${batchStr || ''}.xml.gz"`,
31
+ );
9
32
  res.send(sitemap);
10
33
  } else {
11
34
  // {"errno":-111, "code":"ECONNREFUSED", "host": ...}
@@ -16,10 +39,20 @@ export const sitemap = function (req, res, next) {
16
39
  });
17
40
  };
18
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
+
19
50
  export default function () {
20
51
  const middleware = express.Router();
21
52
 
22
53
  middleware.all('**/sitemap.xml.gz', sitemap);
54
+ middleware.all('**/sitemap:batch.xml.gz', sitemap);
55
+ middleware.all('**/sitemap-index.xml', sitemapIndex);
23
56
  middleware.id = 'sitemap.xml.gz';
24
57
  return middleware;
25
58
  }
@@ -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,27 @@ export const generateRobots = (req) =>
31
28
  if (error) {
32
29
  resolve(text || error);
33
30
  } else {
31
+ // It appears that express does not take the x-forwarded headers into
32
+ // consideration, so we do it ourselves.
33
+ const {
34
+ 'x-forwarded-proto': forwardedProto,
35
+ 'x-forwarded-host': forwardedHost,
36
+ 'x-forwarded-port': forwardedPort,
37
+ } = req.headers;
38
+ const proto = forwardedProto ?? req.protocol;
39
+ const host = forwardedHost ?? req.get('Host');
40
+ const portNum = forwardedPort ?? req.get('Port');
41
+ const port =
42
+ (proto === 'https' && '' + portNum === '443') ||
43
+ (proto === 'http' && '' + portNum === '80')
44
+ ? ''
45
+ : `:${portNum}`;
46
+ // Plone has probably returned the sitemap link with the internal url.
47
+ // If so, let's replace it with the current one.
48
+ const url = `${proto}://${host}${port}`;
49
+ text = text.replace(internalUrl, url);
50
+ // Replace the sitemap with the sitemap index.
51
+ text = text.replace('sitemap.xml.gz', 'sitemap-index.xml');
34
52
  resolve(text);
35
53
  }
36
54
  });
@@ -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
+ });
@@ -280,14 +280,14 @@ export function isTelephone(text) {
280
280
  }
281
281
 
282
282
  export function normaliseMail(email) {
283
- if (email.toLowerCase().startsWith('mailto:')) {
283
+ if (email?.toLowerCase()?.startsWith('mailto:')) {
284
284
  return email;
285
285
  }
286
286
  return `mailto:${email}`;
287
287
  }
288
288
 
289
289
  export function normalizeTelephone(tel) {
290
- if (tel.toLowerCase().startsWith('tel:')) {
290
+ if (tel?.toLowerCase()?.startsWith('tel:')) {
291
291
  return tel;
292
292
  }
293
293
  return `tel:${tel}`;
@@ -310,12 +310,17 @@ export function checkAndNormalizeUrl(url) {
310
310
  res.url = URLUtils.normalizeTelephone(url);
311
311
  } else {
312
312
  //url
313
- if (!res.url.startsWith('/') && !res.url.startsWith('#')) {
313
+ if (
314
+ res.url?.length >= 0 &&
315
+ !res.url.startsWith('/') &&
316
+ !res.url.startsWith('#')
317
+ ) {
314
318
  res.url = URLUtils.normalizeUrl(url);
315
319
  if (!URLUtils.isUrl(res.url)) {
316
320
  res.isValid = false;
317
321
  }
318
322
  }
323
+ if (res.url === undefined || res.url === null) res.isValid = false;
319
324
  }
320
325
  return res;
321
326
  }
@@ -14,6 +14,9 @@ import {
14
14
  removeProtocol,
15
15
  addAppURL,
16
16
  expandToBackendURL,
17
+ checkAndNormalizeUrl,
18
+ normaliseMail,
19
+ normalizeTelephone,
17
20
  } from './Url';
18
21
 
19
22
  beforeEach(() => {
@@ -61,6 +64,17 @@ describe('Url', () => {
61
64
  it('return empty string if no url is empty string', () => {
62
65
  expect(getBaseUrl('')).toBe('');
63
66
  });
67
+ it('return a null/undefined mailto adress ', () => {
68
+ expect(normaliseMail(null)).toBe('mailto:null');
69
+ expect(normaliseMail(undefined)).toBe('mailto:undefined');
70
+ });
71
+ it('return a null/undefined telephone number', () => {
72
+ expect(normalizeTelephone(null)).toBe('tel:null');
73
+ expect(normalizeTelephone(undefined)).toBe('tel:undefined');
74
+ });
75
+ it('null returns an invalid link', () => {
76
+ expect(checkAndNormalizeUrl(null).isValid).toBe(false);
77
+ });
64
78
  });
65
79
 
66
80
  describe('getView', () => {
@@ -258,11 +258,11 @@ export const removeFromArray = (array, index) => {
258
258
  };
259
259
 
260
260
  /**
261
- * Reorder array
261
+ * Moves an item from origin to target inside an array in an immutable way
262
262
  * @param {Array} array Array with data
263
- * @param {number} origin Index of item to be reordered
264
- * @param {number} target Index of item to be reordered to
265
- * @returns {Array} Array with reordered elements
263
+ * @param {number} origin Index of item to be moved from
264
+ * @param {number} target Index of item to be moved to
265
+ * @returns {Array} Resultant array
266
266
  */
267
267
  export const reorderArray = (array, origin, target) => {
268
268
  const result = Array.from(array);
@@ -299,3 +299,16 @@ export const cloneDeepSchema = (object) => {
299
299
  }
300
300
  });
301
301
  };
302
+
303
+ /**
304
+ * Creates an array given a range of numbers
305
+ * @param {number} start start number from
306
+ * @param {number} stop stop number at
307
+ * @param {number} step step every each number in the sequence
308
+ * @returns {array} The result, eg. [0, 1, 2, 3, 4]
309
+ */
310
+ export const arrayRange = (start, stop, step) =>
311
+ Array.from(
312
+ { length: (stop - start) / step + 1 },
313
+ (value, index) => start + index * step,
314
+ );
@@ -1,59 +1,25 @@
1
- import React, { useRef, useEffect } from 'react';
2
- import { useHistory, useLocation } from 'react-router-dom';
3
- import qs from 'query-string';
4
- import { useSelector } from 'react-redux';
5
- import { slugify } from '@plone/volto/helpers/Utils/Utils';
6
-
7
- /**
8
- * @function useCreatePageQueryStringKey
9
- * @description A hook that creates a key with an id if there are multiple blocks with pagination.
10
- * @returns {string} Example: page || page_012345678
11
- */
12
- const useCreatePageQueryStringKey = (id) => {
13
- const blockTypesWithPagination = ['search', 'listing'];
14
- const blocks = useSelector((state) => state?.content?.data?.blocks) || [];
15
- const blocksLayout =
16
- useSelector((state) => state?.content?.data?.blocks_layout?.items) || [];
17
- const displayedBlocks = blocksLayout?.map((item) => blocks[item]);
18
- const hasMultiplePaginations =
19
- displayedBlocks.filter((item) =>
20
- blockTypesWithPagination.includes(item['@type']),
21
- ).length > 1 || false;
22
-
23
- return hasMultiplePaginations ? slugify(`page-${id}`) : 'page';
24
- };
1
+ import React from 'react';
2
+ import { isEqual } from 'lodash';
3
+ import { usePrevious } from './usePrevious';
4
+ import useDeepCompareEffect from 'use-deep-compare-effect';
25
5
 
26
6
  /**
27
7
  * A pagination helper that tracks the query and resets pagination in case the
28
8
  * query changes.
29
9
  */
30
- export const usePagination = (id = null, defaultPage = 1) => {
31
- const location = useLocation();
32
- const history = useHistory();
33
- const pageQueryStringKey = useCreatePageQueryStringKey(id);
34
- const pageQueryParam =
35
- qs.parse(location.search)[pageQueryStringKey] || defaultPage;
36
- const [currentPage, setCurrentPage] = React.useState(
37
- parseInt(pageQueryParam),
38
- );
39
- const queryRef = useRef(qs.parse(location.search)?.query);
10
+ export const usePagination = (query, defaultPage = 1) => {
11
+ const previousQuery = usePrevious(query);
12
+ const [currentPage, setCurrentPage] = React.useState(defaultPage);
40
13
 
41
- useEffect(() => {
42
- if (queryRef.current !== qs.parse(location.search)?.query) {
43
- setCurrentPage(defaultPage);
44
- queryRef.current = qs.parse(location.search)?.query;
45
- }
46
- const newParams = {
47
- ...qs.parse(location.search),
48
- [pageQueryStringKey]: currentPage,
49
- };
50
- history.replace({
51
- search: qs.stringify(newParams),
52
- });
53
- }, [currentPage, defaultPage, location.search, history, pageQueryStringKey]);
14
+ useDeepCompareEffect(() => {
15
+ setCurrentPage(defaultPage);
16
+ }, [query, previousQuery, defaultPage]);
54
17
 
55
18
  return {
56
- currentPage,
19
+ currentPage:
20
+ previousQuery && !isEqual(previousQuery, query)
21
+ ? defaultPage
22
+ : currentPage,
57
23
  setCurrentPage,
58
24
  };
59
25
  };
@@ -85,6 +85,8 @@ export {
85
85
  hasApiExpander,
86
86
  replaceItemOfArray,
87
87
  cloneDeepSchema,
88
+ arrayRange,
89
+ reorderArray,
88
90
  } from '@plone/volto/helpers/Utils/Utils';
89
91
  export { messages } from './MessageLabels/MessageLabels';
90
92
  export {
@@ -53,6 +53,60 @@ describe('api middleware helpers', () => {
53
53
  );
54
54
  expect(result).toEqual('/de/mypage/@navigation?expand.navigation.depth=3');
55
55
  });
56
+ it('addExpandersToPath - Path matching, preserve query', () => {
57
+ config.settings.apiExpanders = [
58
+ {
59
+ match: '/de/mypage',
60
+ GET_CONTENT: ['mycustomexpander', 'mycustomexpander2'],
61
+ },
62
+ ];
63
+
64
+ const result = addExpandersToPath(
65
+ '/de/mypage/@navigation?expand.navigation.depth=3',
66
+ GET_CONTENT,
67
+ );
68
+ expect(result).toEqual(
69
+ '/de/mypage/@navigation?expand=mycustomexpander,mycustomexpander2&expand.navigation.depth=3',
70
+ );
71
+ });
72
+ it('addExpandersToPath - Path matching, preserve query with multiple', () => {
73
+ config.settings.apiExpanders = [
74
+ {
75
+ match: '/de/mypage',
76
+ GET_CONTENT: ['mycustomexpander', 'mycustomexpander2'],
77
+ },
78
+ ];
79
+
80
+ const result = addExpandersToPath(
81
+ '/de/mypage/@navigation?expand.navigation.depth=3&expand.other=2',
82
+ GET_CONTENT,
83
+ );
84
+ expect(result).toEqual(
85
+ '/de/mypage/@navigation?expand=mycustomexpander,mycustomexpander2&expand.navigation.depth=3&expand.other=2',
86
+ );
87
+ });
88
+ it('addExpandersToPath - Path not matching, preserve encoded query', () => {
89
+ config.settings.apiExpanders = [
90
+ {
91
+ match: '/de/otherpath',
92
+ GET_CONTENT: ['mycustomexpander'],
93
+ },
94
+ ];
95
+
96
+ const result = addExpandersToPath('/de/mypage?query=a%26b', GET_CONTENT);
97
+ expect(result).toEqual('/de/mypage?query=a%26b');
98
+ });
99
+ it('addExpandersToPath - Path matching, preserve encoded query', () => {
100
+ config.settings.apiExpanders = [
101
+ {
102
+ match: '/de/mypage',
103
+ GET_CONTENT: ['mycustomexpander'],
104
+ },
105
+ ];
106
+
107
+ const result = addExpandersToPath('/de/mypage?query=a%26b', GET_CONTENT);
108
+ expect(result).toEqual('/de/mypage?expand=mycustomexpander&query=a%26b');
109
+ });
56
110
  it('addExpandersToPath - Two custom expanders from settings', () => {
57
111
  config.settings.apiExpanders = [
58
112
  {
@@ -43,7 +43,7 @@ export function addExpandersToPath(path, type, isAnonymous) {
43
43
  const {
44
44
  url,
45
45
  query: { expand, ...query },
46
- } = qs.parseUrl(path);
46
+ } = qs.parseUrl(path, { decode: false });
47
47
 
48
48
  const expandersFromConfig = apiExpanders
49
49
  .filter((expand) => matchPath(url, expand.match) && expand[type])
@@ -82,6 +82,7 @@ config.set('settings', {
82
82
  viewableInBrowserObjects: [],
83
83
  styleClassNameConverters,
84
84
  styleClassNameExtenders,
85
+ blockSettingsTabFieldsetsInitialStateOpen: true,
85
86
  });
86
87
  config.set('blocks', {
87
88
  blocksConfig: {