@plone/volto 19.1.6 → 19.3.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 +24 -0
- package/locales/af/LC_MESSAGES/volto.po +5 -0
- package/locales/af.json +1 -1
- package/locales/ar/LC_MESSAGES/volto.po +5 -0
- package/locales/ar.json +1 -1
- package/locales/bg/LC_MESSAGES/volto.po +5 -0
- package/locales/bg.json +1 -1
- package/locales/bn/LC_MESSAGES/volto.po +5 -0
- package/locales/bn.json +1 -1
- package/locales/ca/LC_MESSAGES/volto.po +5 -0
- package/locales/ca.json +1 -1
- package/locales/cs/LC_MESSAGES/volto.po +5 -0
- package/locales/cs.json +1 -1
- package/locales/cy/LC_MESSAGES/volto.po +5 -0
- package/locales/cy.json +1 -1
- package/locales/da/LC_MESSAGES/volto.po +5 -0
- package/locales/da.json +1 -1
- package/locales/de/LC_MESSAGES/volto.po +5 -0
- package/locales/de.json +1 -1
- package/locales/el/LC_MESSAGES/volto.po +5 -0
- package/locales/el.json +1 -1
- package/locales/en/LC_MESSAGES/volto.po +5 -0
- package/locales/en.json +1 -1
- package/locales/en_AU/LC_MESSAGES/volto.po +5 -0
- package/locales/en_AU.json +1 -1
- package/locales/en_GB/LC_MESSAGES/volto.po +5 -0
- package/locales/en_GB.json +1 -1
- package/locales/eo/LC_MESSAGES/volto.po +5 -0
- package/locales/eo.json +1 -1
- package/locales/es/LC_MESSAGES/volto.po +5 -0
- package/locales/es.json +1 -1
- package/locales/et/LC_MESSAGES/volto.po +5 -0
- package/locales/et.json +1 -1
- package/locales/eu/LC_MESSAGES/volto.po +5 -0
- package/locales/eu.json +1 -1
- package/locales/fa/LC_MESSAGES/volto.po +5 -0
- package/locales/fa.json +1 -1
- package/locales/fi/LC_MESSAGES/volto.po +5 -0
- package/locales/fi.json +1 -1
- package/locales/fr/LC_MESSAGES/volto.po +5 -0
- package/locales/fr.json +1 -1
- package/locales/fu/LC_MESSAGES/volto.po +5 -0
- package/locales/fu.json +1 -1
- package/locales/gl/LC_MESSAGES/volto.po +5 -0
- package/locales/gl.json +1 -1
- package/locales/he/LC_MESSAGES/volto.po +5 -0
- package/locales/he.json +1 -1
- package/locales/hi/LC_MESSAGES/volto.po +5 -0
- package/locales/hi.json +1 -1
- package/locales/hr/LC_MESSAGES/volto.po +5 -0
- package/locales/hr.json +1 -1
- package/locales/hu/LC_MESSAGES/volto.po +5 -0
- package/locales/hu.json +1 -1
- package/locales/hy/LC_MESSAGES/volto.po +5 -0
- package/locales/hy.json +1 -1
- package/locales/id/LC_MESSAGES/volto.po +5 -0
- package/locales/id.json +1 -1
- package/locales/it/LC_MESSAGES/volto.po +5 -0
- package/locales/it.json +1 -1
- package/locales/ja/LC_MESSAGES/volto.po +5 -0
- package/locales/ja.json +1 -1
- package/locales/ka/LC_MESSAGES/volto.po +5 -0
- package/locales/ka.json +1 -1
- package/locales/kn/LC_MESSAGES/volto.po +5 -0
- package/locales/kn.json +1 -1
- package/locales/ko/LC_MESSAGES/volto.po +5 -0
- package/locales/ko.json +1 -1
- package/locales/lt/LC_MESSAGES/volto.po +5 -0
- package/locales/lt.json +1 -1
- package/locales/lv/LC_MESSAGES/volto.po +5 -0
- package/locales/lv.json +1 -1
- package/locales/mi/LC_MESSAGES/volto.po +5 -0
- package/locales/mi.json +1 -1
- package/locales/mk/LC_MESSAGES/volto.po +5 -0
- package/locales/mk.json +1 -1
- package/locales/my/LC_MESSAGES/volto.po +5 -0
- package/locales/my.json +1 -1
- package/locales/nb_NO/LC_MESSAGES/volto.po +5 -0
- package/locales/nb_NO.json +1 -1
- package/locales/nl/LC_MESSAGES/volto.po +5 -0
- package/locales/nl.json +1 -1
- package/locales/nn/LC_MESSAGES/volto.po +5 -0
- package/locales/nn.json +1 -1
- package/locales/pl/LC_MESSAGES/volto.po +5 -0
- package/locales/pl.json +1 -1
- package/locales/pt/LC_MESSAGES/volto.po +5 -0
- package/locales/pt.json +1 -1
- package/locales/pt_BR/LC_MESSAGES/volto.po +5 -0
- package/locales/pt_BR.json +1 -1
- package/locales/rm/LC_MESSAGES/volto.po +5 -0
- package/locales/rm.json +1 -1
- package/locales/ro/LC_MESSAGES/volto.po +5 -0
- package/locales/ro.json +1 -1
- package/locales/ru/LC_MESSAGES/volto.po +5 -0
- package/locales/ru.json +1 -1
- package/locales/sk/LC_MESSAGES/volto.po +5 -0
- package/locales/sk.json +1 -1
- package/locales/sl/LC_MESSAGES/volto.po +5 -0
- package/locales/sl.json +1 -1
- package/locales/sm/LC_MESSAGES/volto.po +5 -0
- package/locales/sm.json +1 -1
- package/locales/sq/LC_MESSAGES/volto.po +5 -0
- package/locales/sq.json +1 -1
- package/locales/sr/LC_MESSAGES/volto.po +5 -0
- package/locales/sr.json +1 -1
- package/locales/sr@cyrl/LC_MESSAGES/volto.po +5 -0
- package/locales/sr@cyrl.json +1 -1
- package/locales/sr@latn/LC_MESSAGES/volto.po +5 -0
- package/locales/sr@latn.json +1 -1
- package/locales/sv/LC_MESSAGES/volto.po +5 -0
- package/locales/sv.json +1 -1
- package/locales/ta/LC_MESSAGES/volto.po +5 -0
- package/locales/ta.json +1 -1
- package/locales/te/LC_MESSAGES/volto.po +5 -0
- package/locales/te.json +1 -1
- package/locales/th/LC_MESSAGES/volto.po +5 -0
- package/locales/th.json +1 -1
- package/locales/to/LC_MESSAGES/volto.po +5 -0
- package/locales/to.json +1 -1
- package/locales/tr/LC_MESSAGES/volto.po +5 -0
- package/locales/tr.json +1 -1
- package/locales/uk/LC_MESSAGES/volto.po +5 -0
- package/locales/uk.json +1 -1
- package/locales/vi/LC_MESSAGES/volto.po +5 -0
- package/locales/vi.json +1 -1
- package/locales/volto.pot +6 -1
- package/locales/zh_CN/LC_MESSAGES/volto.po +5 -0
- package/locales/zh_CN.json +1 -1
- package/locales/zh_Hant/LC_MESSAGES/volto.po +5 -0
- package/locales/zh_Hant.json +1 -1
- package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +5 -0
- package/locales/zh_Hant_HK.json +1 -1
- package/package.json +6 -7
- package/src/actions/querystringsearch/querystringsearch.test.js +107 -0
- package/src/actions/querystringsearch/querystringsearch.ts +135 -0
- package/src/components/manage/Blocks/Listing/getAsyncData.js +5 -0
- package/src/components/manage/Blocks/Listing/withQuerystringResults.jsx +18 -5
- package/src/components/manage/Widgets/QuerystringWidget.jsx +18 -1
- package/src/components/manage/Widgets/QuerystringWidget.test.jsx +11 -0
- package/src/config/index.js +2 -0
- package/src/config/server.js +2 -0
- package/src/express-middleware/devproxy.js +3 -3
- package/src/express-middleware/images.js +0 -1
- package/src/express-middleware/portraits.js +34 -0
- package/src/helpers/Api/APIResourceWithAuth.js +2 -2
- package/src/helpers/Api/Api.js +2 -1
- package/src/helpers/Api/Api.plone.rest.test.js +7 -0
- package/src/helpers/Api/PloneBackendAPIResourceWithAuth.js +39 -0
- package/src/helpers/Api/PloneBackendAPIResourceWithAuth.test.js +72 -0
- package/src/helpers/Pagination/Pagination.test.ts +259 -0
- package/src/helpers/Pagination/Pagination.ts +151 -0
- package/src/helpers/Sitemap/Sitemap.js +3 -3
- package/src/helpers/Url/Url.js +12 -1
- package/src/helpers/Url/Url.test.js +15 -0
- package/src/server.jsx +19 -2
- package/src/start-client.jsx +3 -0
- package/types/actions/querystringsearch/querystringsearch.d.ts +51 -3
- package/types/components/manage/Widgets/QuerystringWidget.d.ts +6 -0
- package/types/express-middleware/portraits.d.ts +1 -0
- package/types/helpers/Api/PloneBackendAPIResourceWithAuth.d.ts +1 -0
- package/types/helpers/Pagination/Pagination.d.ts +74 -0
- package/types/helpers/Url/Url.d.ts +7 -0
- package/src/actions/querystringsearch/querystringsearch.js +0 -63
|
@@ -38,10 +38,21 @@ describe('objectSchema', () => {
|
|
|
38
38
|
'sort_on',
|
|
39
39
|
'sort_order_boolean',
|
|
40
40
|
'limit',
|
|
41
|
+
'offset',
|
|
41
42
|
'b_size',
|
|
42
43
|
]);
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
it('offers offset as a number field defaulting to no items skipped', () => {
|
|
47
|
+
const schema = objectSchema({ intl: mockIntl });
|
|
48
|
+
|
|
49
|
+
expect(schema.properties.offset).toMatchObject({
|
|
50
|
+
title: 'Offset',
|
|
51
|
+
type: 'number',
|
|
52
|
+
default: 0,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
45
56
|
it('does not include depth in the schema properties', () => {
|
|
46
57
|
const schema = objectSchema({ intl: mockIntl });
|
|
47
58
|
|
package/src/config/index.js
CHANGED
|
@@ -102,6 +102,8 @@ let config = {
|
|
|
102
102
|
internalApiPath: process.env.RAZZLE_INTERNAL_API_PATH || undefined,
|
|
103
103
|
subpathPrefix: process.env.RAZZLE_SUBPATH_PREFIX || '',
|
|
104
104
|
websockets: process.env.RAZZLE_WEBSOCKETS || false,
|
|
105
|
+
// Overrides the API traversal suffix. An empty string disables it.
|
|
106
|
+
apiSuffix: process.env.RAZZLE_API_SUFFIX,
|
|
105
107
|
// TODO: legacyTraverse to be removed when the use of the legacy traverse is deprecated.
|
|
106
108
|
legacyTraverse: process.env.RAZZLE_LEGACY_TRAVERSE || false,
|
|
107
109
|
cookieExpires: 15552000, //in seconds. Default is 6 month (15552000)
|
package/src/config/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import imagesMiddleware from '@plone/volto/express-middleware/images';
|
|
2
2
|
import filesMiddleware from '@plone/volto/express-middleware/files';
|
|
3
|
+
import portraitsMiddleware from '@plone/volto/express-middleware/portraits';
|
|
3
4
|
import robotstxtMiddleware from '@plone/volto/express-middleware/robotstxt';
|
|
4
5
|
import sitemapMiddleware from '@plone/volto/express-middleware/sitemap';
|
|
5
6
|
import staticsMiddleware from '@plone/volto/express-middleware/static';
|
|
@@ -10,6 +11,7 @@ const settings = {
|
|
|
10
11
|
devProxyMiddleware(),
|
|
11
12
|
filesMiddleware(),
|
|
12
13
|
imagesMiddleware(),
|
|
14
|
+
portraitsMiddleware(),
|
|
13
15
|
robotstxtMiddleware(),
|
|
14
16
|
sitemapMiddleware(),
|
|
15
17
|
staticsMiddleware(),
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
responseInterceptor,
|
|
8
8
|
} from 'http-proxy-middleware';
|
|
9
9
|
import querystring from 'querystring';
|
|
10
|
-
import { parse as parseUrl } from 'url';
|
|
11
10
|
|
|
12
11
|
const filter = function (pathname, req) {
|
|
13
12
|
// Check if pathname is defined, there are some corner cases that pathname is null
|
|
@@ -31,8 +30,9 @@ function getEnv() {
|
|
|
31
30
|
return _env;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
const
|
|
33
|
+
// Use the WHATWG URL API instead of the deprecated `url.parse()` (DEP0169).
|
|
34
|
+
const apiPathURL = new URL(config.settings.apiPath);
|
|
35
|
+
const proxyURL = new URL(config.settings.devProxyToApiPath);
|
|
36
36
|
const serverURL = `${proxyURL.protocol}//${proxyURL.host}`;
|
|
37
37
|
const instancePath = proxyURL.pathname;
|
|
38
38
|
|
|
@@ -29,7 +29,6 @@ export default function imagesMiddleware() {
|
|
|
29
29
|
const middleware = express.Router();
|
|
30
30
|
|
|
31
31
|
middleware.all(['**/@@images/*'], imageMiddlewareFn);
|
|
32
|
-
middleware.all(['/@portrait/*'], imageMiddlewareFn);
|
|
33
32
|
middleware.all(['/@@site-logo/*'], imageMiddlewareFn);
|
|
34
33
|
middleware.id = 'imageResourcesProcessor';
|
|
35
34
|
return middleware;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { getPloneBackendAPIResourceWithAuth } from '@plone/volto/helpers/Api/PloneBackendAPIResourceWithAuth';
|
|
3
|
+
|
|
4
|
+
const HEADERS = [
|
|
5
|
+
'content-type',
|
|
6
|
+
'content-disposition',
|
|
7
|
+
'cache-status',
|
|
8
|
+
'cache-control',
|
|
9
|
+
'x-sendfile',
|
|
10
|
+
'x-accel-redirect',
|
|
11
|
+
'x-robots-tag',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
function portraitMiddlewareFn(req, res, next) {
|
|
15
|
+
getPloneBackendAPIResourceWithAuth(req)
|
|
16
|
+
.then((resource) => {
|
|
17
|
+
// Just forward the headers that we need
|
|
18
|
+
HEADERS.forEach((header) => {
|
|
19
|
+
if (resource.headers[header]) {
|
|
20
|
+
res.set(header, resource.headers[header]);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
res.send(resource.body);
|
|
24
|
+
})
|
|
25
|
+
.catch(next);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function portraitsMiddleware() {
|
|
29
|
+
const middleware = express.Router();
|
|
30
|
+
|
|
31
|
+
middleware.all(['/@portrait/*'], portraitMiddlewareFn);
|
|
32
|
+
middleware.id = 'portraitResourcesProcessor';
|
|
33
|
+
return middleware;
|
|
34
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import superagent from 'superagent';
|
|
7
7
|
import config from '@plone/volto/registry';
|
|
8
8
|
import { addHeadersFactory } from '@plone/volto/helpers/Proxy/Proxy';
|
|
9
|
-
import { stripSubpathPrefix } from '@plone/volto/helpers/Url/Url';
|
|
9
|
+
import { getApiSuffix, stripSubpathPrefix } from '@plone/volto/helpers/Url/Url';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Get a resource image/file with authenticated (if token exist) API headers
|
|
@@ -17,7 +17,7 @@ import { stripSubpathPrefix } from '@plone/volto/helpers/Url/Url';
|
|
|
17
17
|
export const getAPIResourceWithAuth = (req) =>
|
|
18
18
|
new Promise((resolve, reject) => {
|
|
19
19
|
const { settings } = config;
|
|
20
|
-
const apiSuffix =
|
|
20
|
+
const apiSuffix = getApiSuffix();
|
|
21
21
|
let apiPath = '';
|
|
22
22
|
|
|
23
23
|
if (settings.internalApiPath && __SERVER__) {
|
package/src/helpers/Api/Api.js
CHANGED
|
@@ -8,6 +8,7 @@ import Cookies from 'universal-cookie';
|
|
|
8
8
|
import config from '@plone/volto/registry';
|
|
9
9
|
import { addHeadersFactory } from '@plone/volto/helpers/Proxy/Proxy';
|
|
10
10
|
import {
|
|
11
|
+
getApiSuffix,
|
|
11
12
|
stripQuerystring,
|
|
12
13
|
stripSubpathPrefix,
|
|
13
14
|
} from '@plone/volto/helpers/Url/Url';
|
|
@@ -22,7 +23,7 @@ const methods = ['get', 'post', 'put', 'patch', 'del'];
|
|
|
22
23
|
*/
|
|
23
24
|
export function formatUrl(path) {
|
|
24
25
|
const { settings } = config;
|
|
25
|
-
const apiSuffix =
|
|
26
|
+
const apiSuffix = getApiSuffix();
|
|
26
27
|
|
|
27
28
|
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
|
28
29
|
|
|
@@ -15,6 +15,7 @@ vi.mock('superagent', () => ({
|
|
|
15
15
|
}));
|
|
16
16
|
|
|
17
17
|
beforeAll(() => {
|
|
18
|
+
config.settings.apiSuffix = undefined;
|
|
18
19
|
config.settings.legacyTraverse = false;
|
|
19
20
|
});
|
|
20
21
|
|
|
@@ -41,4 +42,10 @@ describe('Api', () => {
|
|
|
41
42
|
const promise = api.get('https://example.com');
|
|
42
43
|
expect(promise.request.url).toBe('https://example.com');
|
|
43
44
|
});
|
|
45
|
+
it('uses the configured API suffix', () => {
|
|
46
|
+
config.settings.apiSuffix = '/custom-api';
|
|
47
|
+
const promise = api.get('/test');
|
|
48
|
+
expect(promise.request.url).toBe(`${settings.apiPath}/custom-api/test`);
|
|
49
|
+
config.settings.apiSuffix = undefined;
|
|
50
|
+
});
|
|
44
51
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import superagent from 'superagent';
|
|
2
|
+
import config from '@plone/volto/registry';
|
|
3
|
+
import { addHeadersFactory } from '@plone/volto/helpers/Proxy/Proxy';
|
|
4
|
+
import { getApiSuffix, stripSubpathPrefix } from '@plone/volto/helpers/Url/Url';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get a resource from Plone Backend (/++api++) with authenticated (if token exist) API headers
|
|
8
|
+
* @function getPloneBackendAPIResourceWithAuth
|
|
9
|
+
* @param {Object} req Request object
|
|
10
|
+
* @return {string} The response with the image
|
|
11
|
+
*/
|
|
12
|
+
export const getPloneBackendAPIResourceWithAuth = (req) =>
|
|
13
|
+
new Promise((resolve, reject) => {
|
|
14
|
+
const { settings } = config;
|
|
15
|
+
const apiSuffix = getApiSuffix();
|
|
16
|
+
let apiPath = '';
|
|
17
|
+
|
|
18
|
+
if (settings.internalApiPath && __SERVER__) {
|
|
19
|
+
apiPath = settings.internalApiPath;
|
|
20
|
+
} else if (__DEVELOPMENT__ && settings.devProxyToApiPath) {
|
|
21
|
+
apiPath = settings.devProxyToApiPath;
|
|
22
|
+
} else {
|
|
23
|
+
apiPath = settings.apiPath;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//strip subpath if any
|
|
27
|
+
const contentPath = stripSubpathPrefix(req.path);
|
|
28
|
+
|
|
29
|
+
const request = superagent
|
|
30
|
+
.get(`${apiPath}${apiSuffix}${contentPath}`)
|
|
31
|
+
.maxResponseSize(settings.maxResponseSize)
|
|
32
|
+
.responseType('blob');
|
|
33
|
+
const authToken = req.universalCookies.get('auth_token');
|
|
34
|
+
if (authToken) {
|
|
35
|
+
request.set('Authorization', `Bearer ${authToken}`);
|
|
36
|
+
}
|
|
37
|
+
request.use(addHeadersFactory(req));
|
|
38
|
+
request.then(resolve).catch(reject);
|
|
39
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import superagent from 'superagent';
|
|
2
|
+
import config from '@plone/volto/registry';
|
|
3
|
+
import { getPloneBackendAPIResourceWithAuth } from './PloneBackendAPIResourceWithAuth';
|
|
4
|
+
|
|
5
|
+
vi.mock('superagent', () => ({
|
|
6
|
+
default: {
|
|
7
|
+
get: vi.fn(() => ({
|
|
8
|
+
maxResponseSize: vi.fn().mockReturnThis(),
|
|
9
|
+
responseType: vi.fn().mockReturnThis(),
|
|
10
|
+
set: vi.fn(),
|
|
11
|
+
use: vi.fn(),
|
|
12
|
+
then: vi.fn((resolve) => resolve()),
|
|
13
|
+
})),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const request = {
|
|
18
|
+
path: '/@portrait/admin',
|
|
19
|
+
universalCookies: { get: vi.fn() },
|
|
20
|
+
headers: {},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe('getPloneBackendAPIResourceWithAuth', () => {
|
|
24
|
+
const originalSettings = { ...config.settings };
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.clearAllMocks();
|
|
28
|
+
vi.stubGlobal('__DEVELOPMENT__', true);
|
|
29
|
+
Object.assign(config.settings, originalSettings, {
|
|
30
|
+
apiPath: 'http://localhost:3000',
|
|
31
|
+
devProxyToApiPath: 'http://localhost:8080/Plone',
|
|
32
|
+
internalApiPath: undefined,
|
|
33
|
+
apiSuffix: undefined,
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(() => {
|
|
38
|
+
vi.unstubAllGlobals();
|
|
39
|
+
Object.assign(config.settings, originalSettings);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('uses the API traversal path for Plone in development', async () => {
|
|
43
|
+
config.settings.legacyTraverse = false;
|
|
44
|
+
|
|
45
|
+
await getPloneBackendAPIResourceWithAuth(request);
|
|
46
|
+
|
|
47
|
+
expect(superagent.get).toHaveBeenCalledWith(
|
|
48
|
+
'http://localhost:8080/Plone/++api++/@portrait/admin',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('omits the API traversal path for legacy backends in development', async () => {
|
|
53
|
+
config.settings.legacyTraverse = true;
|
|
54
|
+
|
|
55
|
+
await getPloneBackendAPIResourceWithAuth(request);
|
|
56
|
+
|
|
57
|
+
expect(superagent.get).toHaveBeenCalledWith(
|
|
58
|
+
'http://localhost:8080/Plone/@portrait/admin',
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('uses apiSuffix in preference to legacyTraverse in development', async () => {
|
|
63
|
+
config.settings.legacyTraverse = true;
|
|
64
|
+
config.settings.apiSuffix = '/custom-api';
|
|
65
|
+
|
|
66
|
+
await getPloneBackendAPIResourceWithAuth(request);
|
|
67
|
+
|
|
68
|
+
expect(superagent.get).toHaveBeenCalledWith(
|
|
69
|
+
'http://localhost:8080/Plone/custom-api/@portrait/admin',
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeBStart,
|
|
3
|
+
computeLimit,
|
|
4
|
+
computeTotalPages,
|
|
5
|
+
computeVisibleTotal,
|
|
6
|
+
type IntegerLike,
|
|
7
|
+
} from './Pagination';
|
|
8
|
+
|
|
9
|
+
describe('computeBStart', () => {
|
|
10
|
+
describe('paging without an offset', () => {
|
|
11
|
+
it('starts the first page at the beginning', () => {
|
|
12
|
+
expect(computeBStart(1, 0, 10, 25)).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('advances by the batch size on each page', () => {
|
|
16
|
+
expect(computeBStart(2, 0, 10, 25)).toBe(10);
|
|
17
|
+
expect(computeBStart(3, 0, 10, 25)).toBe(20);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('falls back to the default page size when b_size is unset', () => {
|
|
21
|
+
expect(computeBStart(2, 0, undefined, 25)).toBe(25);
|
|
22
|
+
expect(computeBStart(3, 0, null, 25)).toBe(50);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('treats a zero b_size as unset', () => {
|
|
26
|
+
expect(computeBStart(2, 0, 0, 25)).toBe(25);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('paging with an offset', () => {
|
|
31
|
+
it('skips the leading items on the first page', () => {
|
|
32
|
+
expect(computeBStart(1, 3, 10, 25)).toBe(3);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('keeps the offset applied on later pages', () => {
|
|
36
|
+
expect(computeBStart(2, 3, 10, 25)).toBe(13);
|
|
37
|
+
expect(computeBStart(3, 3, 10, 25)).toBe(23);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('applies the offset to the default page size too', () => {
|
|
41
|
+
expect(computeBStart(2, 5, undefined, 25)).toBe(30);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('missing page', () => {
|
|
46
|
+
// getQueryStringResults is also called without a page, e.g. by the query
|
|
47
|
+
// widget's own preview. `undefined - 1` would produce NaN, which
|
|
48
|
+
// serialises to a null b_start.
|
|
49
|
+
it('defaults an undefined page to the first page', () => {
|
|
50
|
+
expect(computeBStart(undefined, 0, 1, 25)).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('still honours the offset when the page is missing', () => {
|
|
54
|
+
expect(computeBStart(undefined, 4, 10, 25)).toBe(4);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('integer coercion', () => {
|
|
59
|
+
it('accepts numeric strings from the querystring widget', () => {
|
|
60
|
+
expect(computeBStart('2', '3', '10', 25)).toBe(13);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('truncates floats towards zero', () => {
|
|
64
|
+
expect(computeBStart(1, 3.7, 10, 25)).toBe(3);
|
|
65
|
+
expect(computeBStart(2.9, 0, 10, 25)).toBe(10);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('falls back on values that are not numbers', () => {
|
|
69
|
+
expect(computeBStart('abc', 'xyz', 10, 25)).toBe(0);
|
|
70
|
+
expect(computeBStart(NaN, NaN, 10, 25)).toBe(0);
|
|
71
|
+
expect(computeBStart(Infinity, 0, 10, 25)).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('treats empty strings as unset', () => {
|
|
75
|
+
expect(computeBStart('', '', '', 25)).toBe(0);
|
|
76
|
+
expect(computeBStart(2, '', '', 25)).toBe(25);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('result is always a non-negative integer', () => {
|
|
81
|
+
it('clamps a page below one to the first page', () => {
|
|
82
|
+
expect(computeBStart(0, 0, 10, 25)).toBe(0);
|
|
83
|
+
expect(computeBStart(-5, 0, 10, 25)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('ignores a negative offset', () => {
|
|
87
|
+
expect(computeBStart(1, -3, 10, 25)).toBe(0);
|
|
88
|
+
expect(computeBStart(2, -3, 10, 25)).toBe(10);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns the offset alone when no batch size can be resolved', () => {
|
|
92
|
+
expect(computeBStart(3, 7, undefined, undefined)).toBe(7);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const cases: Array<[IntegerLike, IntegerLike, IntegerLike, IntegerLike]> = [
|
|
96
|
+
[1, 0, 10, 25],
|
|
97
|
+
[undefined, undefined, undefined, undefined],
|
|
98
|
+
['2', '3.9', '10.1', '25'],
|
|
99
|
+
[-1, -1, -1, -1],
|
|
100
|
+
[2.5, 1.5, 10.5, 25.5],
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
it.each(cases)(
|
|
104
|
+
'returns an integer for (%o, %o, %o, %o)',
|
|
105
|
+
(page, offset, bSize, defaultPageSize) => {
|
|
106
|
+
const result = computeBStart(page, offset, bSize, defaultPageSize);
|
|
107
|
+
expect(Number.isInteger(result)).toBe(true);
|
|
108
|
+
expect(result).toBeGreaterThanOrEqual(0);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('computeLimit', () => {
|
|
115
|
+
describe('with a limit set', () => {
|
|
116
|
+
it('adds the offset to the limit', () => {
|
|
117
|
+
expect(computeLimit(3, 10)).toBe(13);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('returns the limit unchanged when nothing is skipped', () => {
|
|
121
|
+
expect(computeLimit(0, 10)).toBe(10);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('accepts values stored as strings by the widget', () => {
|
|
125
|
+
expect(computeLimit('3', '10')).toBe(13);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('truncates floats towards zero', () => {
|
|
129
|
+
expect(computeLimit(3.9, 10.9)).toBe(13);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('ignores a negative offset', () => {
|
|
133
|
+
expect(computeLimit(-3, 10)).toBe(10);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('without a usable limit', () => {
|
|
138
|
+
it('returns null when the limit is unset', () => {
|
|
139
|
+
expect(computeLimit(3, undefined)).toBeNull();
|
|
140
|
+
expect(computeLimit(3, null)).toBeNull();
|
|
141
|
+
expect(computeLimit(3, '')).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('returns null when the limit is not a number', () => {
|
|
145
|
+
expect(computeLimit(3, 'abc')).toBeNull();
|
|
146
|
+
expect(computeLimit(3, NaN)).toBeNull();
|
|
147
|
+
expect(computeLimit(3, Infinity)).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// plone.app.querystring's querybuilder defaults limit to 0 and only
|
|
151
|
+
// applies it when truthy, so 0 means "no limit". Summing it with the
|
|
152
|
+
// offset would cap the batch at the items the listing then skips.
|
|
153
|
+
it('treats a zero limit as no limit', () => {
|
|
154
|
+
expect(computeLimit(3, 0)).toBeNull();
|
|
155
|
+
expect(computeLimit(3, '0')).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('treats a negative limit as no limit', () => {
|
|
159
|
+
expect(computeLimit(3, -10)).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('returns an integer or null', () => {
|
|
164
|
+
const cases: Array<[IntegerLike, IntegerLike]> = [
|
|
165
|
+
[3, 10],
|
|
166
|
+
[undefined, undefined],
|
|
167
|
+
['3.9', '10.1'],
|
|
168
|
+
[-1, -1],
|
|
169
|
+
[0, 0],
|
|
170
|
+
];
|
|
171
|
+
|
|
172
|
+
for (const [offset, limit] of cases) {
|
|
173
|
+
const result = computeLimit(offset, limit);
|
|
174
|
+
expect(result === null || Number.isInteger(result)).toBe(true);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe('computeVisibleTotal', () => {
|
|
180
|
+
it('subtracts the skipped items from the backend total', () => {
|
|
181
|
+
expect(computeVisibleTotal(23, 3)).toBe(20);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('returns the total untouched when nothing is skipped', () => {
|
|
185
|
+
expect(computeVisibleTotal(23, 0)).toBe(23);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('never goes negative when the offset exceeds the total', () => {
|
|
189
|
+
expect(computeVisibleTotal(2, 5)).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('accepts values stored as strings by the widget', () => {
|
|
193
|
+
expect(computeVisibleTotal('23', '3')).toBe(20);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('falls back to zero when the total is not yet loaded', () => {
|
|
197
|
+
expect(computeVisibleTotal(undefined, 3)).toBe(0);
|
|
198
|
+
expect(computeVisibleTotal(null, 3)).toBe(0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('ignores a negative offset', () => {
|
|
202
|
+
expect(computeVisibleTotal(23, -3)).toBe(23);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('computeTotalPages', () => {
|
|
207
|
+
it('splits the total across whole pages', () => {
|
|
208
|
+
expect(computeTotalPages(20, 10)).toBe(2);
|
|
209
|
+
expect(computeTotalPages(23, 10)).toBe(3);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('counts a partial page', () => {
|
|
213
|
+
expect(computeTotalPages(21, 10)).toBe(3);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('returns zero when there is nothing to show', () => {
|
|
217
|
+
expect(computeTotalPages(0, 10)).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('returns zero when no batch size can be resolved', () => {
|
|
221
|
+
expect(computeTotalPages(20, 0)).toBe(0);
|
|
222
|
+
expect(computeTotalPages(20, undefined)).toBe(0);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('accepts values stored as strings by the widget', () => {
|
|
226
|
+
expect(computeTotalPages('20', '10')).toBe(2);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// The regression this pair exists for: 23 results, skip 3, 10 per page.
|
|
230
|
+
// Dividing the raw total gives 3 pages, but page 3 asks for b_start 23 and
|
|
231
|
+
// comes back empty. Only 20 items are reachable, so there are 2 pages.
|
|
232
|
+
it('does not advertise a page the offset makes unreachable', () => {
|
|
233
|
+
const total = 23;
|
|
234
|
+
const offset = 3;
|
|
235
|
+
const bSize = 10;
|
|
236
|
+
|
|
237
|
+
expect(computeTotalPages(total, bSize)).toBe(3);
|
|
238
|
+
expect(computeTotalPages(computeVisibleTotal(total, offset), bSize)).toBe(
|
|
239
|
+
2,
|
|
240
|
+
);
|
|
241
|
+
expect(computeBStart(3, offset, bSize, 25)).toBe(23);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('keeps the last page reachable across the offset range', () => {
|
|
245
|
+
const total = 25;
|
|
246
|
+
const bSize = 10;
|
|
247
|
+
|
|
248
|
+
for (const offset of [0, 1, 5, 10, 15, 24, 25, 30]) {
|
|
249
|
+
const pages = computeTotalPages(
|
|
250
|
+
computeVisibleTotal(total, offset),
|
|
251
|
+
bSize,
|
|
252
|
+
);
|
|
253
|
+
if (pages > 0) {
|
|
254
|
+
const lastPageStart = computeBStart(pages, offset, bSize, 25);
|
|
255
|
+
expect(lastPageStart).toBeLessThan(total);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination helpers.
|
|
3
|
+
* @module helpers/Pagination/Pagination
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A value that is meant to represent an integer, but which reaches us from
|
|
8
|
+
* user-editable block settings. The querystring widget stores `offset`,
|
|
9
|
+
* `limit` and `b_size` as JSON schema `number` fields, so a value may arrive
|
|
10
|
+
* as a string (`'3'`), as a float (`3.7`), or not at all.
|
|
11
|
+
*/
|
|
12
|
+
export type IntegerLike = number | string | null | undefined;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Coerce a value to an integer, or to null when it holds no usable number.
|
|
16
|
+
* Floats are truncated towards zero, since these values index into a batch.
|
|
17
|
+
* @function toIntegerOrNull
|
|
18
|
+
* @param {IntegerLike} value Value to coerce.
|
|
19
|
+
* @returns {number | null} An integer, or null when value is unset or not a
|
|
20
|
+
* finite number.
|
|
21
|
+
*/
|
|
22
|
+
function toIntegerOrNull(value: IntegerLike): number | null {
|
|
23
|
+
if (value === null || value === undefined) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'string' && value.trim() === '') {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const parsed = Number(value);
|
|
30
|
+
return Number.isFinite(parsed) ? Math.trunc(parsed) : null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Coerce a value to an integer, falling back when it cannot be represented.
|
|
35
|
+
* @function toInteger
|
|
36
|
+
* @param {IntegerLike} value Value to coerce.
|
|
37
|
+
* @param {number} fallback Integer returned when value is not a finite number.
|
|
38
|
+
* @returns {number} An integer.
|
|
39
|
+
*/
|
|
40
|
+
function toInteger(value: IntegerLike, fallback: number): number {
|
|
41
|
+
return toIntegerOrNull(value) ?? fallback;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Compute the `b_start` index for a `@querystring-search` request: the index
|
|
46
|
+
* of the first item the backend should return for the requested page.
|
|
47
|
+
*
|
|
48
|
+
* `offset` shifts the whole result set, letting a listing skip the first N
|
|
49
|
+
* items of its query while paginating over the remainder.
|
|
50
|
+
*
|
|
51
|
+
* Every parameter is coerced to an integer and the result is always a
|
|
52
|
+
* non-negative integer, so a malformed block setting degrades to a sane batch
|
|
53
|
+
* rather than sending `NaN` to the backend. When neither `bSize` nor
|
|
54
|
+
* `defaultPageSize` yields a positive batch size, paging cannot be applied and
|
|
55
|
+
* the result is `offset` alone.
|
|
56
|
+
* @function computeBStart
|
|
57
|
+
* @param {IntegerLike} page 1-based page number. Defaults to the first page.
|
|
58
|
+
* @param {IntegerLike} offset Number of leading items to skip. Defaults to 0.
|
|
59
|
+
* @param {IntegerLike} bSize Batch size configured on the block, if any.
|
|
60
|
+
* @param {IntegerLike} defaultPageSize Batch size to use when bSize is unset.
|
|
61
|
+
* @returns {number} A non-negative integer `b_start` index.
|
|
62
|
+
*/
|
|
63
|
+
export function computeBStart(
|
|
64
|
+
page: IntegerLike,
|
|
65
|
+
offset: IntegerLike,
|
|
66
|
+
bSize: IntegerLike,
|
|
67
|
+
defaultPageSize: IntegerLike,
|
|
68
|
+
): number {
|
|
69
|
+
const pageNumber = Math.max(1, toInteger(page, 1));
|
|
70
|
+
const offsetValue = Math.max(0, toInteger(offset, 0));
|
|
71
|
+
const batchSize =
|
|
72
|
+
Math.max(0, toInteger(bSize, 0)) ||
|
|
73
|
+
Math.max(0, toInteger(defaultPageSize, 0));
|
|
74
|
+
|
|
75
|
+
return Math.max(0, (pageNumber - 1) * batchSize + offsetValue);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Compute the effective `limit` for a `@querystring-search` request.
|
|
80
|
+
*
|
|
81
|
+
* A listing that skips the first `offset` items still wants `limit` items
|
|
82
|
+
* afterwards, so the backend has to return the skipped items on top of the
|
|
83
|
+
* wanted ones: `offset + limit`.
|
|
84
|
+
*
|
|
85
|
+
* A limit of zero, or one that resolves to no usable number, means "no limit"
|
|
86
|
+
* — matching `plone.app.querystring`'s querybuilder, whose `limit` defaults to
|
|
87
|
+
* 0 and is only applied when truthy. Returning `offset` in that case would cap
|
|
88
|
+
* the batch at the very items the listing is about to skip, leaving it empty.
|
|
89
|
+
* @function computeLimit
|
|
90
|
+
* @param {IntegerLike} offset Number of leading items to skip. Defaults to 0.
|
|
91
|
+
* @param {IntegerLike} limit Number of items the listing wants, if capped.
|
|
92
|
+
* @returns {number | null} A positive integer limit, or null for no limit.
|
|
93
|
+
*/
|
|
94
|
+
export function computeLimit(
|
|
95
|
+
offset: IntegerLike,
|
|
96
|
+
limit: IntegerLike,
|
|
97
|
+
): number | null {
|
|
98
|
+
const limitValue = toIntegerOrNull(limit);
|
|
99
|
+
if (limitValue === null || limitValue <= 0) {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return Math.max(0, toInteger(offset, 0)) + limitValue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Compute how many items a listing can actually show.
|
|
108
|
+
*
|
|
109
|
+
* The `items_total` reported by `@querystring-search` is the length of the
|
|
110
|
+
* whole result set: `plone.restapi`'s HypermediaBatch derives it from the
|
|
111
|
+
* sequence length, so `b_start` never reduces it. The items an offset skips
|
|
112
|
+
* are counted in there even though the listing never renders them.
|
|
113
|
+
* @function computeVisibleTotal
|
|
114
|
+
* @param {IntegerLike} total The `items_total` reported by the backend.
|
|
115
|
+
* @param {IntegerLike} offset Number of leading items skipped. Defaults to 0.
|
|
116
|
+
* @returns {number} A non-negative integer count of reachable items.
|
|
117
|
+
*/
|
|
118
|
+
export function computeVisibleTotal(
|
|
119
|
+
total: IntegerLike,
|
|
120
|
+
offset: IntegerLike,
|
|
121
|
+
): number {
|
|
122
|
+
const totalValue = Math.max(0, toInteger(total, 0));
|
|
123
|
+
const offsetValue = Math.max(0, toInteger(offset, 0));
|
|
124
|
+
|
|
125
|
+
return Math.max(0, totalValue - offsetValue);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Compute how many pages a listing spans.
|
|
130
|
+
*
|
|
131
|
+
* `total` is expected to be the reachable item count, so pass the result of
|
|
132
|
+
* `computeVisibleTotal` for an offset listing rather than the raw backend
|
|
133
|
+
* total. Otherwise the listing advertises pages that resolve to an empty batch.
|
|
134
|
+
* @function computeTotalPages
|
|
135
|
+
* @param {IntegerLike} total Number of items the listing can show.
|
|
136
|
+
* @param {IntegerLike} bSize Batch size, i.e. items per page.
|
|
137
|
+
* @returns {number} A non-negative integer page count, 0 when there is no
|
|
138
|
+
* usable batch size.
|
|
139
|
+
*/
|
|
140
|
+
export function computeTotalPages(
|
|
141
|
+
total: IntegerLike,
|
|
142
|
+
bSize: IntegerLike,
|
|
143
|
+
): number {
|
|
144
|
+
const totalValue = Math.max(0, toInteger(total, 0));
|
|
145
|
+
const batchSize = Math.max(0, toInteger(bSize, 0));
|
|
146
|
+
if (batchSize <= 0) {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return Math.ceil(totalValue / batchSize);
|
|
151
|
+
}
|