@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.
- package/.changelog.draft +20 -20
- package/.yarn/install-state.gz +0 -0
- package/CHANGELOG.md +98 -13
- package/CONTRIBUTING.md +1 -1
- package/README.md +9 -12
- package/locales/de/LC_MESSAGES/volto.po +17 -17
- package/locales/de.json +1 -1
- package/package.json +3 -2
- package/packages/volto-slate/package.json +1 -1
- package/src/components/manage/Blocks/Listing/Edit.jsx +0 -14
- package/src/components/manage/Blocks/Listing/ListingBody.test.jsx +0 -20
- package/src/components/manage/Blocks/Listing/getAsyncData.js +10 -2
- package/src/components/manage/Blocks/Listing/withQuerystringResults.jsx +20 -14
- package/src/components/manage/Blocks/Search/SearchBlockEdit.jsx +5 -4
- package/src/components/manage/Blocks/Search/SearchBlockView.jsx +2 -1
- package/src/components/manage/Blocks/Search/hocs/withSearch.jsx +28 -19
- package/src/components/manage/Contents/Contents.jsx +29 -24
- package/src/components/manage/Controlpanels/Controlpanels.jsx +190 -224
- package/src/components/manage/Controlpanels/Controlpanels.test.jsx +46 -7
- package/src/components/manage/Form/InlineForm.jsx +39 -9
- package/src/components/manage/Form/InlineFormState.js +8 -0
- package/src/components/manage/Widgets/ObjectListWidget.jsx +3 -8
- package/src/components/theme/Icon/Icon.jsx +2 -2
- package/src/components/theme/Login/Login.jsx +1 -0
- package/src/config/index.js +1 -0
- package/src/express-middleware/sitemap.js +36 -3
- package/src/helpers/Robots/Robots.js +24 -6
- package/src/helpers/Sitemap/Sitemap.js +44 -2
- package/src/helpers/Url/Url.js +8 -3
- package/src/helpers/Url/Url.test.js +14 -0
- package/src/helpers/Utils/Utils.js +17 -4
- package/src/helpers/Utils/usePagination.js +14 -48
- package/src/helpers/index.js +2 -0
- package/src/middleware/Api.test.js +54 -0
- package/src/middleware/api.js +1 -1
- package/test-setup-config.js +1 -0
- package/theme/themes/pastanaga/extras/sidebar.less +4 -0
- 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: '
|
|
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
|
-
|
|
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] =
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
169
|
+
active={currentActiveFieldset.includes(index)}
|
|
140
170
|
index={index}
|
|
141
171
|
onClick={handleCurrentActiveFieldset}
|
|
142
172
|
>
|
|
143
173
|
{fieldset.title && <>{fieldset.title}</>}
|
|
144
|
-
{currentActiveFieldset
|
|
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
|
|
180
|
+
<Accordion.Content active={currentActiveFieldset.includes(index)}>
|
|
151
181
|
<AnimateHeight
|
|
152
182
|
animateOpacity
|
|
153
183
|
duration={500}
|
|
154
|
-
height={currentActiveFieldset
|
|
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
|
-
|
|
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
|
|
48
|
-
viewBox={name
|
|
47
|
+
xmlns={name?.attributes?.xmlns}
|
|
48
|
+
viewBox={name?.attributes?.viewBox}
|
|
49
49
|
style={{
|
|
50
50
|
height: size,
|
|
51
51
|
width: 'auto',
|
package/src/config/index.js
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
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
|
+
});
|
package/src/helpers/Url/Url.js
CHANGED
|
@@ -280,14 +280,14 @@ export function isTelephone(text) {
|
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
export function normaliseMail(email) {
|
|
283
|
-
if (email
|
|
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
|
|
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 (
|
|
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
|
-
*
|
|
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
|
|
264
|
-
* @param {number} target Index of item to be
|
|
265
|
-
* @returns {Array}
|
|
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
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import
|
|
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 = (
|
|
31
|
-
const
|
|
32
|
-
const
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
};
|
package/src/helpers/index.js
CHANGED
|
@@ -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
|
{
|
package/src/middleware/api.js
CHANGED
|
@@ -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])
|