@plone/volto 19.1.5 → 19.2.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 +30 -0
- package/locales/af/LC_MESSAGES/volto.po +18 -11
- package/locales/af.json +1 -1
- package/locales/ar/LC_MESSAGES/volto.po +18 -11
- package/locales/ar.json +1 -1
- package/locales/bg/LC_MESSAGES/volto.po +18 -11
- package/locales/bg.json +1 -1
- package/locales/bn/LC_MESSAGES/volto.po +18 -11
- package/locales/bn.json +1 -1
- package/locales/ca/LC_MESSAGES/volto.po +18 -11
- package/locales/ca.json +1 -1
- package/locales/cs/LC_MESSAGES/volto.po +18 -11
- package/locales/cs.json +1 -1
- package/locales/cy/LC_MESSAGES/volto.po +18 -11
- package/locales/cy.json +1 -1
- package/locales/da/LC_MESSAGES/volto.po +18 -11
- package/locales/da.json +1 -1
- package/locales/de/LC_MESSAGES/volto.po +18 -11
- package/locales/de.json +1 -1
- package/locales/el/LC_MESSAGES/volto.po +18 -11
- package/locales/el.json +1 -1
- package/locales/en/LC_MESSAGES/volto.po +18 -11
- package/locales/en.json +1 -1
- package/locales/en_AU/LC_MESSAGES/volto.po +18 -11
- package/locales/en_AU.json +1 -1
- package/locales/en_GB/LC_MESSAGES/volto.po +18 -11
- package/locales/en_GB.json +1 -1
- package/locales/eo/LC_MESSAGES/volto.po +18 -11
- package/locales/eo.json +1 -1
- package/locales/es/LC_MESSAGES/volto.po +18 -11
- package/locales/es.json +1 -1
- package/locales/et/LC_MESSAGES/volto.po +18 -11
- package/locales/et.json +1 -1
- package/locales/eu/LC_MESSAGES/volto.po +18 -11
- package/locales/eu.json +1 -1
- package/locales/fa/LC_MESSAGES/volto.po +18 -11
- package/locales/fa.json +1 -1
- package/locales/fi/LC_MESSAGES/volto.po +18 -11
- package/locales/fi.json +1 -1
- package/locales/fr/LC_MESSAGES/volto.po +18 -11
- package/locales/fr.json +1 -1
- package/locales/fu/LC_MESSAGES/volto.po +18 -11
- package/locales/fu.json +1 -1
- package/locales/gl/LC_MESSAGES/volto.po +18 -11
- package/locales/gl.json +1 -1
- package/locales/he/LC_MESSAGES/volto.po +18 -11
- package/locales/he.json +1 -1
- package/locales/hi/LC_MESSAGES/volto.po +18 -11
- package/locales/hi.json +1 -1
- package/locales/hr/LC_MESSAGES/volto.po +18 -11
- package/locales/hr.json +1 -1
- package/locales/hu/LC_MESSAGES/volto.po +18 -11
- package/locales/hu.json +1 -1
- package/locales/hy/LC_MESSAGES/volto.po +18 -11
- package/locales/hy.json +1 -1
- package/locales/id/LC_MESSAGES/volto.po +18 -11
- package/locales/id.json +1 -1
- package/locales/it/LC_MESSAGES/volto.po +18 -11
- package/locales/it.json +1 -1
- package/locales/ja/LC_MESSAGES/volto.po +18 -11
- package/locales/ja.json +1 -1
- package/locales/ka/LC_MESSAGES/volto.po +18 -11
- package/locales/ka.json +1 -1
- package/locales/kn/LC_MESSAGES/volto.po +18 -11
- package/locales/kn.json +1 -1
- package/locales/ko/LC_MESSAGES/volto.po +18 -11
- package/locales/ko.json +1 -1
- package/locales/lt/LC_MESSAGES/volto.po +18 -11
- package/locales/lt.json +1 -1
- package/locales/lv/LC_MESSAGES/volto.po +18 -11
- package/locales/lv.json +1 -1
- package/locales/mi/LC_MESSAGES/volto.po +18 -11
- package/locales/mi.json +1 -1
- package/locales/mk/LC_MESSAGES/volto.po +18 -11
- package/locales/mk.json +1 -1
- package/locales/my/LC_MESSAGES/volto.po +18 -11
- package/locales/my.json +1 -1
- package/locales/nb_NO/LC_MESSAGES/volto.po +18 -11
- package/locales/nb_NO.json +1 -1
- package/locales/nl/LC_MESSAGES/volto.po +18 -11
- package/locales/nl.json +1 -1
- package/locales/nn/LC_MESSAGES/volto.po +18 -11
- package/locales/nn.json +1 -1
- package/locales/pl/LC_MESSAGES/volto.po +18 -11
- package/locales/pl.json +1 -1
- package/locales/pt/LC_MESSAGES/volto.po +18 -11
- package/locales/pt.json +1 -1
- package/locales/pt_BR/LC_MESSAGES/volto.po +18 -11
- package/locales/pt_BR.json +1 -1
- package/locales/rm/LC_MESSAGES/volto.po +18 -11
- package/locales/rm.json +1 -1
- package/locales/ro/LC_MESSAGES/volto.po +18 -11
- package/locales/ro.json +1 -1
- package/locales/ru/LC_MESSAGES/volto.po +18 -11
- package/locales/ru.json +1 -1
- package/locales/sk/LC_MESSAGES/volto.po +18 -11
- package/locales/sk.json +1 -1
- package/locales/sl/LC_MESSAGES/volto.po +18 -11
- package/locales/sl.json +1 -1
- package/locales/sm/LC_MESSAGES/volto.po +18 -11
- package/locales/sm.json +1 -1
- package/locales/sq/LC_MESSAGES/volto.po +18 -11
- package/locales/sq.json +1 -1
- package/locales/sr/LC_MESSAGES/volto.po +18 -11
- package/locales/sr.json +1 -1
- package/locales/sr@cyrl/LC_MESSAGES/volto.po +18 -11
- package/locales/sr@cyrl.json +1 -1
- package/locales/sr@latn/LC_MESSAGES/volto.po +18 -11
- package/locales/sr@latn.json +1 -1
- package/locales/sv/LC_MESSAGES/volto.po +18 -11
- package/locales/sv.json +1 -1
- package/locales/ta/LC_MESSAGES/volto.po +18 -11
- package/locales/ta.json +1 -1
- package/locales/te/LC_MESSAGES/volto.po +18 -11
- package/locales/te.json +1 -1
- package/locales/th/LC_MESSAGES/volto.po +18 -11
- package/locales/th.json +1 -1
- package/locales/to/LC_MESSAGES/volto.po +18 -11
- package/locales/to.json +1 -1
- package/locales/tr/LC_MESSAGES/volto.po +18 -11
- package/locales/tr.json +1 -1
- package/locales/uk/LC_MESSAGES/volto.po +18 -11
- package/locales/uk.json +1 -1
- package/locales/vi/LC_MESSAGES/volto.po +18 -11
- package/locales/vi.json +1 -1
- package/locales/volto.pot +17 -10
- package/locales/zh_CN/LC_MESSAGES/volto.po +18 -11
- package/locales/zh_CN.json +1 -1
- package/locales/zh_Hant/LC_MESSAGES/volto.po +18 -11
- package/locales/zh_Hant.json +1 -1
- package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +18 -11
- 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/Controlpanels/BlockTypes.test.ts +132 -0
- package/src/components/manage/Controlpanels/BlockTypes.tsx +32 -11
- package/src/components/manage/Sidebar/ObjectBrowserBody.jsx +23 -2
- package/src/components/manage/Toolbar/More.jsx +13 -0
- package/src/components/manage/Widgets/FileWidget.jsx +19 -9
- package/src/components/manage/Widgets/QuerystringWidget.jsx +18 -1
- package/src/components/manage/Widgets/QuerystringWidget.test.jsx +11 -0
- package/src/components/manage/Widgets/RegistryImageWidget.jsx +5 -3
- package/src/config/server.js +2 -0
- package/src/express-middleware/images.js +0 -1
- package/src/express-middleware/portraits.js +34 -0
- package/src/helpers/Api/PloneBackendAPIResourceWithAuth.js +39 -0
- package/src/helpers/Api/PloneBackendAPIResourceWithAuth.test.js +60 -0
- package/src/helpers/Pagination/Pagination.test.ts +259 -0
- package/src/helpers/Pagination/Pagination.ts +151 -0
- package/theme/themes/pastanaga/extras/focus.less +33 -0
- package/theme/themes/pastanaga/extras/main.less +1 -0
- package/theme/themes/pastanaga/extras/skiplinks.less +0 -2
- package/theme/themes/pastanaga/globals/site.variables +7 -0
- package/types/actions/querystringsearch/querystringsearch.d.ts +51 -3
- package/types/components/manage/Controlpanels/BlockTypes.d.ts +48 -0
- 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/src/actions/querystringsearch/querystringsearch.js +0 -63
|
@@ -74,6 +74,113 @@ describe('querystringsearch action', () => {
|
|
|
74
74
|
expect(action.request.data.sort_order).toEqual('descending');
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
+
describe('offset', () => {
|
|
78
|
+
const data = {
|
|
79
|
+
query: [
|
|
80
|
+
{
|
|
81
|
+
i: 'portal_type',
|
|
82
|
+
o: 'plone.app.querystring.operation.selection.any',
|
|
83
|
+
v: ['Document'],
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
it('skips the leading items on the first page', () => {
|
|
89
|
+
const action = getQueryStringResults(
|
|
90
|
+
'',
|
|
91
|
+
{ ...data, b_size: 10, offset: 3 },
|
|
92
|
+
'sub',
|
|
93
|
+
1,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
expect(action.request.data).toMatchObject({ b_start: 3 });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('stays applied while paginating', () => {
|
|
100
|
+
const action = getQueryStringResults(
|
|
101
|
+
'',
|
|
102
|
+
{ ...data, b_size: 10, offset: 3 },
|
|
103
|
+
'sub',
|
|
104
|
+
2,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
expect(action.request.data).toMatchObject({ b_start: 13 });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('tolerates an offset stored as a string by the widget', () => {
|
|
111
|
+
const action = getQueryStringResults(
|
|
112
|
+
'',
|
|
113
|
+
{ ...data, b_size: 10, offset: '3' },
|
|
114
|
+
'sub',
|
|
115
|
+
2,
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(action.request.data).toMatchObject({ b_start: 13 });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('applies the offset even when no page is requested', () => {
|
|
122
|
+
const action = getQueryStringResults('', { ...data, offset: 4 }, 'sub');
|
|
123
|
+
|
|
124
|
+
expect(action.request.data.b_start).toEqual(4);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('raises the limit by the offset so the listing still gets its items', () => {
|
|
128
|
+
const action = getQueryStringResults(
|
|
129
|
+
'',
|
|
130
|
+
{ ...data, b_size: 10, offset: 3, limit: 10 },
|
|
131
|
+
'sub',
|
|
132
|
+
1,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(action.request.data.limit).toEqual(13);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// plone.app.querystring's querybuilder only applies limit when truthy, so
|
|
139
|
+
// a zero limit must reach the backend untouched rather than being raised
|
|
140
|
+
// to the offset, which would cap the batch at the skipped items.
|
|
141
|
+
it('keeps a zero limit meaning no limit', () => {
|
|
142
|
+
const action = getQueryStringResults(
|
|
143
|
+
'',
|
|
144
|
+
{ ...data, b_size: 10, offset: 3, limit: 0 },
|
|
145
|
+
'sub',
|
|
146
|
+
1,
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
expect(action.request.data.limit).toEqual(0);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('leaves the request untouched when nothing is skipped', () => {
|
|
153
|
+
const withoutOffset = getQueryStringResults(
|
|
154
|
+
'',
|
|
155
|
+
{ ...data, b_size: 10, limit: 5 },
|
|
156
|
+
'sub',
|
|
157
|
+
2,
|
|
158
|
+
);
|
|
159
|
+
const withZeroOffset = getQueryStringResults(
|
|
160
|
+
'',
|
|
161
|
+
{ ...data, b_size: 10, limit: 5, offset: 0 },
|
|
162
|
+
'sub',
|
|
163
|
+
2,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
expect(withoutOffset.request.data).toMatchObject({
|
|
167
|
+
b_start: 10,
|
|
168
|
+
limit: 5,
|
|
169
|
+
});
|
|
170
|
+
expect(withZeroOffset.request.data).toEqual({
|
|
171
|
+
...withoutOffset.request.data,
|
|
172
|
+
offset: 0,
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('sends no b_start when there is neither a page nor an offset', () => {
|
|
177
|
+
const action = getQueryStringResults('', data);
|
|
178
|
+
|
|
179
|
+
expect(action.request.data.b_start).toBeUndefined();
|
|
180
|
+
expect(JSON.stringify(action.request.data)).not.toContain('b_start');
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
77
184
|
describe('depth handling', () => {
|
|
78
185
|
it('appends depth to path value when depth is set and path has no embedded depth', () => {
|
|
79
186
|
const data = {
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Querystring search actions.
|
|
3
|
+
* @module actions/querystringsearch/querystringsearch
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { GET_QUERYSTRING_RESULTS } from '@plone/volto/constants/ActionTypes';
|
|
7
|
+
import config from '@plone/volto/registry';
|
|
8
|
+
import {
|
|
9
|
+
computeBStart,
|
|
10
|
+
computeLimit,
|
|
11
|
+
type IntegerLike,
|
|
12
|
+
} from '@plone/volto/helpers/Pagination/Pagination';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A single `plone.app.querystring` criterion: index, operation, value.
|
|
16
|
+
*/
|
|
17
|
+
export interface QuerystringCriterion {
|
|
18
|
+
i: string;
|
|
19
|
+
o: string;
|
|
20
|
+
v?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The querystring settings stored on a block. Numeric fields are typed as
|
|
25
|
+
* `IntegerLike` because they are edited through JSON schema `number` widgets
|
|
26
|
+
* and may reach us as strings.
|
|
27
|
+
*/
|
|
28
|
+
export interface QuerystringData {
|
|
29
|
+
query?: QuerystringCriterion[];
|
|
30
|
+
sort_on?: string;
|
|
31
|
+
sort_order?: string | boolean | null;
|
|
32
|
+
b_size?: IntegerLike;
|
|
33
|
+
offset?: IntegerLike;
|
|
34
|
+
limit?: IntegerLike;
|
|
35
|
+
depth?: IntegerLike;
|
|
36
|
+
[key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The payload sent to the `@querystring-search` endpoint. `b_start` is only
|
|
41
|
+
* present once a page or an offset places the batch past the beginning.
|
|
42
|
+
*/
|
|
43
|
+
export interface QuerystringSearchQuery extends QuerystringData {
|
|
44
|
+
b_start?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GetQueryStringResultsAction {
|
|
48
|
+
type: typeof GET_QUERYSTRING_RESULTS;
|
|
49
|
+
subrequest?: string;
|
|
50
|
+
request: {
|
|
51
|
+
op: 'get' | 'post';
|
|
52
|
+
path: string;
|
|
53
|
+
data: QuerystringSearchQuery | null;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get querystring results.
|
|
59
|
+
* @function getQueryStringResults
|
|
60
|
+
* @param {string} path Path to search under.
|
|
61
|
+
* @param {QuerystringData} data Querystring settings from the block.
|
|
62
|
+
* @param {string} subrequest Subrequest id used to store the results.
|
|
63
|
+
* @param {IntegerLike} page 1-based page number. Defaults to the first page.
|
|
64
|
+
* @returns {GetQueryStringResultsAction} Get querystringsearch results action.
|
|
65
|
+
*/
|
|
66
|
+
export function getQueryStringResults(
|
|
67
|
+
path: string,
|
|
68
|
+
data: QuerystringData,
|
|
69
|
+
subrequest?: string,
|
|
70
|
+
page?: IntegerLike,
|
|
71
|
+
): GetQueryStringResultsAction {
|
|
72
|
+
const { settings } = config;
|
|
73
|
+
|
|
74
|
+
// fixes https://github.com/plone/volto/issues/1059
|
|
75
|
+
const requestData: QuerystringData = JSON.parse(JSON.stringify(data));
|
|
76
|
+
|
|
77
|
+
if (data?.depth != null) {
|
|
78
|
+
delete requestData.depth;
|
|
79
|
+
requestData.query?.forEach((q) => {
|
|
80
|
+
if (q.i === 'path') {
|
|
81
|
+
// fixes https://github.com/plone/volto/issues/8349
|
|
82
|
+
// Skip if depth is already embedded in the path value (e.g. set via object browser)
|
|
83
|
+
const value = String(q.v);
|
|
84
|
+
const hasEmbeddedDepth = /::\d+$/.test(value);
|
|
85
|
+
if (!hasEmbeddedDepth) q.v = `${value}::${data.depth}`;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// fixes https://github.com/plone/volto/issues/2397
|
|
91
|
+
if (requestData?.sort_order !== null) {
|
|
92
|
+
if (typeof requestData.sort_order === 'boolean') {
|
|
93
|
+
requestData.sort_order = requestData.sort_order
|
|
94
|
+
? 'descending'
|
|
95
|
+
: 'ascending';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const b_start = computeBStart(
|
|
100
|
+
page,
|
|
101
|
+
requestData.offset,
|
|
102
|
+
requestData.b_size,
|
|
103
|
+
settings.defaultPageSize,
|
|
104
|
+
);
|
|
105
|
+
// A listing that skips the first `offset` items still wants `limit` of them
|
|
106
|
+
// afterwards, so the backend has to return the skipped ones on top.
|
|
107
|
+
const limit = computeLimit(requestData.offset, requestData.limit);
|
|
108
|
+
|
|
109
|
+
const query: QuerystringSearchQuery = {
|
|
110
|
+
...requestData,
|
|
111
|
+
...(!requestData.b_size && {
|
|
112
|
+
b_size: settings.defaultPageSize,
|
|
113
|
+
}),
|
|
114
|
+
// Keep the payload as it was before offsets existed: a caller that passes
|
|
115
|
+
// neither a page nor an offset gets no b_start at all, and a limit that
|
|
116
|
+
// needs no raising is forwarded untouched.
|
|
117
|
+
...((page || b_start > 0) && { b_start }),
|
|
118
|
+
...(limit !== null && { limit }),
|
|
119
|
+
query: requestData?.query,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
type: GET_QUERYSTRING_RESULTS,
|
|
124
|
+
subrequest,
|
|
125
|
+
request: {
|
|
126
|
+
op: settings.querystringSearchGet ? 'get' : 'post',
|
|
127
|
+
path: `${path}/@querystring-search${
|
|
128
|
+
settings.querystringSearchGet
|
|
129
|
+
? `?query=${encodeURIComponent(JSON.stringify(query))}`
|
|
130
|
+
: ''
|
|
131
|
+
}`,
|
|
132
|
+
data: settings.querystringSearchGet ? null : query,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -38,6 +38,11 @@ export default function getListingBlockAsyncData(props) {
|
|
|
38
38
|
path,
|
|
39
39
|
{
|
|
40
40
|
...data.querystring,
|
|
41
|
+
// Shape the request exactly as withQuerystringResults does on the
|
|
42
|
+
// client, so the first paint and the re-render agree. The offset is
|
|
43
|
+
// turned into b_start by the action itself: computing it here would
|
|
44
|
+
// apply it twice.
|
|
45
|
+
offset: data.querystring.offset || 0,
|
|
41
46
|
...(resolvedExtensions?.variation?.fullobjects
|
|
42
47
|
? { fullobjects: 1 }
|
|
43
48
|
: { metadata_fields: '_all' }),
|
|
@@ -7,6 +7,10 @@ import { getContent } from '@plone/volto/actions/content/content';
|
|
|
7
7
|
import { getQueryStringResults } from '@plone/volto/actions/querystringsearch/querystringsearch';
|
|
8
8
|
import { usePagination } from '@plone/volto/helpers/Utils/usePagination';
|
|
9
9
|
import { getBaseUrl } from '@plone/volto/helpers/Url/Url';
|
|
10
|
+
import {
|
|
11
|
+
computeTotalPages,
|
|
12
|
+
computeVisibleTotal,
|
|
13
|
+
} from '@plone/volto/helpers/Pagination/Pagination';
|
|
10
14
|
|
|
11
15
|
import config from '@plone/volto/registry';
|
|
12
16
|
|
|
@@ -37,6 +41,7 @@ export default function withQuerystringResults(WrappedComponent) {
|
|
|
37
41
|
variation?.fullobjects ? { fullobjects: 1 } : { metadata_fields: '_all' },
|
|
38
42
|
{
|
|
39
43
|
b_size: b_size,
|
|
44
|
+
offset: querystring?.offset || 0,
|
|
40
45
|
},
|
|
41
46
|
...copyFields.map((name) =>
|
|
42
47
|
Object.keys(querystring).includes(name)
|
|
@@ -62,14 +67,22 @@ export default function withQuerystringResults(WrappedComponent) {
|
|
|
62
67
|
? querystringResults?.[subrequestID]?.items || []
|
|
63
68
|
: folderItems;
|
|
64
69
|
|
|
70
|
+
// The backend counts the items the offset skips in its total, but the
|
|
71
|
+
// listing never shows them. Everything the user sees — the page count and
|
|
72
|
+
// the result counter — is about the items past the offset.
|
|
73
|
+
const subrequestTotal = querystringResults?.[subrequestID]?.total;
|
|
74
|
+
const visibleTotal = computeVisibleTotal(
|
|
75
|
+
subrequestTotal,
|
|
76
|
+
adaptedQuery.offset,
|
|
77
|
+
);
|
|
78
|
+
|
|
65
79
|
const showAsFolderListing = !hasQuery && content?.items_total > b_size;
|
|
66
|
-
const showAsQueryListing =
|
|
67
|
-
hasQuery && querystringResults?.[subrequestID]?.total > b_size;
|
|
80
|
+
const showAsQueryListing = hasQuery && visibleTotal > b_size;
|
|
68
81
|
|
|
69
82
|
const totalPages = showAsFolderListing
|
|
70
|
-
?
|
|
83
|
+
? computeTotalPages(content.items_total, b_size)
|
|
71
84
|
: showAsQueryListing
|
|
72
|
-
?
|
|
85
|
+
? computeTotalPages(visibleTotal, b_size)
|
|
73
86
|
: 0;
|
|
74
87
|
|
|
75
88
|
const prevBatch = showAsFolderListing
|
|
@@ -135,7 +148,7 @@ export default function withQuerystringResults(WrappedComponent) {
|
|
|
135
148
|
<WrappedComponent
|
|
136
149
|
{...props}
|
|
137
150
|
onPaginationChange={(e, { activePage }) => setCurrentPage(activePage)}
|
|
138
|
-
total={
|
|
151
|
+
total={subrequestTotal === undefined ? undefined : visibleTotal}
|
|
139
152
|
batch_size={b_size}
|
|
140
153
|
currentPage={currentPage}
|
|
141
154
|
totalPages={totalPages}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createIntl, createIntlCache } from 'react-intl';
|
|
2
|
+
import type { BlocksConfigData } from '@plone/types';
|
|
3
|
+
import { sortConfigBlocks } from './BlockTypes';
|
|
4
|
+
|
|
5
|
+
const intl = createIntl(
|
|
6
|
+
{ locale: 'en', messages: { Teaser: 'Destaque' } },
|
|
7
|
+
createIntlCache(),
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
// `BlockConfigBase` types id and title as required, so a malformed entry
|
|
11
|
+
// cannot be expressed through it. That mismatch between the type and the
|
|
12
|
+
// configuration is the bug these tests cover, so the fixtures go around it.
|
|
13
|
+
const asBlocksConfig = (config: Record<string, unknown>) =>
|
|
14
|
+
config as unknown as BlocksConfigData;
|
|
15
|
+
|
|
16
|
+
describe('sortConfigBlocks', () => {
|
|
17
|
+
it('lists a registered block', () => {
|
|
18
|
+
const blocks = sortConfigBlocks(
|
|
19
|
+
asBlocksConfig({ image: { id: 'image', title: 'Image' } }),
|
|
20
|
+
intl,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
expect(blocks).toEqual([{ id: 'image', title: 'Image' }]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('translates the title', () => {
|
|
27
|
+
const blocks = sortConfigBlocks(
|
|
28
|
+
asBlocksConfig({ teaser: { id: 'teaser', title: 'Teaser' } }),
|
|
29
|
+
intl,
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(blocks[0].title).toEqual('Destaque');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('sorts by the translated title, not by the config order', () => {
|
|
36
|
+
const blocks = sortConfigBlocks(
|
|
37
|
+
asBlocksConfig({
|
|
38
|
+
zebra: { id: 'zebra', title: 'Zebra' },
|
|
39
|
+
apple: { id: 'apple', title: 'Apple' },
|
|
40
|
+
// 'Teaser' translates to 'Destaque', so it sorts before 'Zebra'.
|
|
41
|
+
teaser: { id: 'teaser', title: 'Teaser' },
|
|
42
|
+
}),
|
|
43
|
+
intl,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(blocks.map((block) => block.title)).toEqual([
|
|
47
|
+
'Apple',
|
|
48
|
+
'Destaque',
|
|
49
|
+
'Zebra',
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps the rest of the block config', () => {
|
|
54
|
+
const blocks = sortConfigBlocks(
|
|
55
|
+
asBlocksConfig({
|
|
56
|
+
image: {
|
|
57
|
+
id: 'image',
|
|
58
|
+
title: 'Image',
|
|
59
|
+
icon: 'image.svg',
|
|
60
|
+
group: 'media',
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
intl,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
expect(blocks[0]).toMatchObject({ icon: 'image.svg', group: 'media' });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('entries that are not blocks', () => {
|
|
70
|
+
// An add-on that configures a block which was never registered leaves an
|
|
71
|
+
// entry behind carrying only what it set. Listing it would link to a block
|
|
72
|
+
// that does not exist, and a row with no id crashes the table.
|
|
73
|
+
it('drops an entry with neither an id nor a title', () => {
|
|
74
|
+
const blocks = sortConfigBlocks(
|
|
75
|
+
asBlocksConfig({
|
|
76
|
+
image: { id: 'image', title: 'Image' },
|
|
77
|
+
accordion: { addPermission: [] },
|
|
78
|
+
}),
|
|
79
|
+
intl,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(blocks).toEqual([{ id: 'image', title: 'Image' }]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('drops an entry with an id but no title', () => {
|
|
86
|
+
const blocks = sortConfigBlocks(
|
|
87
|
+
asBlocksConfig({ accordion: { id: 'accordion' } }),
|
|
88
|
+
intl,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(blocks).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('drops an entry with a title but no id', () => {
|
|
95
|
+
const blocks = sortConfigBlocks(
|
|
96
|
+
asBlocksConfig({ accordion: { title: 'Accordion' } }),
|
|
97
|
+
intl,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
expect(blocks).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not fall back to the config key for a missing id', () => {
|
|
104
|
+
const blocks = sortConfigBlocks(
|
|
105
|
+
asBlocksConfig({ accordion: { title: 'Accordion' } }),
|
|
106
|
+
intl,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(blocks.map((block) => block.id)).not.toContain('accordion');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('gives every listed block an id, so the table can key its rows', () => {
|
|
113
|
+
const blocks = sortConfigBlocks(
|
|
114
|
+
asBlocksConfig({
|
|
115
|
+
image: { id: 'image', title: 'Image' },
|
|
116
|
+
accordion: { addPermission: [] },
|
|
117
|
+
teaser: { id: 'teaser' },
|
|
118
|
+
}),
|
|
119
|
+
intl,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(blocks.length).toBeGreaterThan(0);
|
|
123
|
+
for (const block of blocks) {
|
|
124
|
+
expect(block.id).toBeTruthy();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('returns nothing when no block is configured', () => {
|
|
130
|
+
expect(sortConfigBlocks(asBlocksConfig({}), intl)).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -5,6 +5,7 @@ import { useClient } from '@plone/volto/hooks';
|
|
|
5
5
|
import config from '@plone/volto/registry';
|
|
6
6
|
import { createPortal } from 'react-dom';
|
|
7
7
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|
8
|
+
import type { IntlShape } from 'react-intl';
|
|
8
9
|
import { Link } from 'react-router-dom';
|
|
9
10
|
import { useEffect } from 'react';
|
|
10
11
|
import { getBlockTypes } from '@plone/volto/actions/blockTypes/blockTypes';
|
|
@@ -15,6 +16,7 @@ import UniversalLink from '@plone/volto/components/manage/UniversalLink/Universa
|
|
|
15
16
|
|
|
16
17
|
import backSVG from '@plone/volto/icons/back.svg';
|
|
17
18
|
import type { Location } from 'history';
|
|
19
|
+
import type { BlocksConfigData } from '@plone/types';
|
|
18
20
|
|
|
19
21
|
const messages = defineMessages({
|
|
20
22
|
back: {
|
|
@@ -53,6 +55,35 @@ type SelectorState = {
|
|
|
53
55
|
blockTypes: BlockTypesState;
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* List the configured blocks, by title.
|
|
60
|
+
*
|
|
61
|
+
* An add-on can configure a block that was never registered — enhancing one
|
|
62
|
+
* that ships with another add-on, say — which leaves an entry behind that is
|
|
63
|
+
* not a block at all, with no id or no title. `BlockConfigBase` says both are
|
|
64
|
+
* always there, but the config disagrees, so drop those entries rather than
|
|
65
|
+
* list a block that does not exist.
|
|
66
|
+
* @function sortConfigBlocks
|
|
67
|
+
* @param {BlocksConfigData} blocksConfig The blocks configuration.
|
|
68
|
+
* @param {IntlShape} intl Used to translate each block title.
|
|
69
|
+
* @returns {Array} The configured blocks, sorted by translated title.
|
|
70
|
+
*/
|
|
71
|
+
export function sortConfigBlocks(
|
|
72
|
+
blocksConfig: BlocksConfigData,
|
|
73
|
+
intl: IntlShape,
|
|
74
|
+
) {
|
|
75
|
+
return Object.entries(blocksConfig)
|
|
76
|
+
.filter(([, blockConfig]) => blockConfig.id && blockConfig.title)
|
|
77
|
+
.map(([, blockConfig]) => ({
|
|
78
|
+
...blockConfig,
|
|
79
|
+
title: intl.formatMessage({
|
|
80
|
+
id: blockConfig.title,
|
|
81
|
+
defaultMessage: blockConfig.title,
|
|
82
|
+
}),
|
|
83
|
+
}))
|
|
84
|
+
.sort((a, b) => (a.title === b.title ? 0 : a.title > b.title ? 1 : -1));
|
|
85
|
+
}
|
|
86
|
+
|
|
56
87
|
const BlockTypesControlpanel = (props: RouteProps) => {
|
|
57
88
|
const { location } = props;
|
|
58
89
|
const intl = useIntl();
|
|
@@ -62,17 +93,7 @@ const BlockTypesControlpanel = (props: RouteProps) => {
|
|
|
62
93
|
const pathname = location.pathname;
|
|
63
94
|
const isClient = useClient();
|
|
64
95
|
|
|
65
|
-
const blocks =
|
|
66
|
-
.map(([key, blockConfig]) => ({
|
|
67
|
-
...blockConfig,
|
|
68
|
-
title: blockConfig.title
|
|
69
|
-
? intl.formatMessage({
|
|
70
|
-
id: blockConfig.title,
|
|
71
|
-
defaultMessage: blockConfig.title,
|
|
72
|
-
})
|
|
73
|
-
: blockConfig.id ?? key,
|
|
74
|
-
}))
|
|
75
|
-
.sort((a, b) => (a.title === b.title ? 0 : a.title > b.title ? 1 : -1));
|
|
96
|
+
const blocks = sortConfigBlocks(blocksConfig, intl);
|
|
76
97
|
|
|
77
98
|
useEffect(() => {
|
|
78
99
|
dispatch(getBlockTypes());
|
|
@@ -5,6 +5,7 @@ import { connect } from 'react-redux';
|
|
|
5
5
|
import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
|
|
6
6
|
import { Input, Segment, Breadcrumb } from 'semantic-ui-react';
|
|
7
7
|
|
|
8
|
+
import debounce from 'lodash/debounce';
|
|
8
9
|
import join from 'lodash/join';
|
|
9
10
|
|
|
10
11
|
// These absolute imports (without using the corresponding centralized index.js) are required
|
|
@@ -159,6 +160,22 @@ class ObjectBrowserBody extends Component {
|
|
|
159
160
|
view: this.props.mode === 'image' ? 'icons' : 'list',
|
|
160
161
|
};
|
|
161
162
|
this.searchInputRef = React.createRef();
|
|
163
|
+
// Debounce the live-search dispatch so rapid keystrokes do not produce a
|
|
164
|
+
// burst of overlapping requests whose responses may resolve out of order
|
|
165
|
+
// (an older, less-filtered response arriving after the latest one would
|
|
166
|
+
// visually "reset" the list to look unfiltered).
|
|
167
|
+
this.debouncedSearch = debounce(this.doSearch, 300);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Component will unmount
|
|
172
|
+
* @method componentWillUnmount
|
|
173
|
+
* @returns {undefined}
|
|
174
|
+
*/
|
|
175
|
+
componentWillUnmount() {
|
|
176
|
+
if (this.debouncedSearch) {
|
|
177
|
+
this.debouncedSearch.cancel();
|
|
178
|
+
}
|
|
162
179
|
}
|
|
163
180
|
|
|
164
181
|
/**
|
|
@@ -248,8 +265,7 @@ class ObjectBrowserBody extends Component {
|
|
|
248
265
|
view: prevState.view === 'icons' ? 'list' : 'icons',
|
|
249
266
|
}));
|
|
250
267
|
|
|
251
|
-
|
|
252
|
-
const text = flattenToAppURL(e.target.value);
|
|
268
|
+
doSearch = (text) => {
|
|
253
269
|
if (text.startsWith('/')) {
|
|
254
270
|
this.setState({ currentFolder: text });
|
|
255
271
|
this.props.searchContent(
|
|
@@ -286,6 +302,11 @@ class ObjectBrowserBody extends Component {
|
|
|
286
302
|
}
|
|
287
303
|
};
|
|
288
304
|
|
|
305
|
+
onSearch = (e) => {
|
|
306
|
+
const text = flattenToAppURL(e.target.value);
|
|
307
|
+
this.debouncedSearch(text);
|
|
308
|
+
};
|
|
309
|
+
|
|
289
310
|
onSelectItem = (item) => {
|
|
290
311
|
const url = item['@id'];
|
|
291
312
|
const { block, data, mode, dataName, onChangeBlock } = this.props;
|
|
@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
|
|
|
4
4
|
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
|
|
5
5
|
import { Link, useHistory } from 'react-router-dom';
|
|
6
6
|
import find from 'lodash/find';
|
|
7
|
+
import { Dimmer, Loader } from 'semantic-ui-react';
|
|
7
8
|
import { toast } from 'react-toastify';
|
|
8
9
|
|
|
9
10
|
import Toast from '@plone/volto/components/manage/Toast/Toast';
|
|
@@ -103,6 +104,10 @@ const messages = defineMessages({
|
|
|
103
104
|
id: 'workingCopyGenericError',
|
|
104
105
|
defaultMessage: 'An error occurred while performing this operation.',
|
|
105
106
|
},
|
|
107
|
+
Loading: {
|
|
108
|
+
id: 'Loading',
|
|
109
|
+
defaultMessage: 'Loading',
|
|
110
|
+
},
|
|
106
111
|
});
|
|
107
112
|
|
|
108
113
|
const More = (props) => {
|
|
@@ -123,6 +128,8 @@ const More = (props) => {
|
|
|
123
128
|
const workingCopyApply = workingCopy?.apply.loading;
|
|
124
129
|
const workingCopyCreate = workingCopy?.create.loading;
|
|
125
130
|
const workingCopyRemove = workingCopy?.remove.loading;
|
|
131
|
+
const isWorkingCopyLoading =
|
|
132
|
+
workingCopyApply || workingCopyCreate || workingCopyRemove;
|
|
126
133
|
|
|
127
134
|
const prevWorkingCopyApplyLoading = usePrevious(workingCopyApply);
|
|
128
135
|
const prevWorkingCopyCreateLoading = usePrevious(workingCopyCreate);
|
|
@@ -216,6 +223,9 @@ const More = (props) => {
|
|
|
216
223
|
: null,
|
|
217
224
|
}}
|
|
218
225
|
>
|
|
226
|
+
<Dimmer active={isWorkingCopyLoading} inverted>
|
|
227
|
+
<Loader>{intl.formatMessage(messages.Loading)}</Loader>
|
|
228
|
+
</Dimmer>
|
|
219
229
|
<header>
|
|
220
230
|
<h2>{content.title}</h2>
|
|
221
231
|
<button
|
|
@@ -335,6 +345,7 @@ const More = (props) => {
|
|
|
335
345
|
<li>
|
|
336
346
|
<button
|
|
337
347
|
aria-label={intl.formatMessage(messages.CreateWorkingCopy)}
|
|
348
|
+
disabled={workingCopyCreate}
|
|
338
349
|
onClick={() => {
|
|
339
350
|
dispatch(createWorkingCopy(path)).then((response) => {
|
|
340
351
|
history.push(flattenToAppURL(response['@id']));
|
|
@@ -354,6 +365,7 @@ const More = (props) => {
|
|
|
354
365
|
<li>
|
|
355
366
|
<button
|
|
356
367
|
aria-label={intl.formatMessage(messages.applyWorkingCopy)}
|
|
368
|
+
disabled={workingCopyApply}
|
|
357
369
|
onClick={() => {
|
|
358
370
|
dispatch(applyWorkingCopy(path)).then((response) => {
|
|
359
371
|
history.push(flattenToAppURL(content.working_copy_of['@id']));
|
|
@@ -395,6 +407,7 @@ const More = (props) => {
|
|
|
395
407
|
<li>
|
|
396
408
|
<button
|
|
397
409
|
aria-label={intl.formatMessage(messages.removeWorkingCopy)}
|
|
410
|
+
disabled={workingCopyRemove}
|
|
398
411
|
onClick={() => {
|
|
399
412
|
dispatch(removeWorkingCopy(path)).then((response) => {
|
|
400
413
|
history.push(flattenToAppURL(content.working_copy_of['@id']));
|
|
@@ -35,12 +35,14 @@ const messages = defineMessages({
|
|
|
35
35
|
defaultMessage: 'Drop files here ...',
|
|
36
36
|
},
|
|
37
37
|
editFile: {
|
|
38
|
-
id: 'Drop
|
|
39
|
-
defaultMessage:
|
|
38
|
+
id: 'Drop file here to replace the existing file',
|
|
39
|
+
defaultMessage:
|
|
40
|
+
'File upload area. Press Enter or click to replace the existing file.',
|
|
40
41
|
},
|
|
41
42
|
fileDrag: {
|
|
42
|
-
id: 'Drop
|
|
43
|
-
defaultMessage:
|
|
43
|
+
id: 'Drop file here to upload a new file',
|
|
44
|
+
defaultMessage:
|
|
45
|
+
'File upload area. Press Enter or click to open the file browser.',
|
|
44
46
|
},
|
|
45
47
|
replaceFile: {
|
|
46
48
|
id: 'Replace existing file',
|
|
@@ -60,8 +62,14 @@ const messages = defineMessages({
|
|
|
60
62
|
defaultMessage: 'File is not of the accepted type {accept}',
|
|
61
63
|
},
|
|
62
64
|
dragAndDropActionA11y: {
|
|
63
|
-
id: '
|
|
64
|
-
defaultMessage:
|
|
65
|
+
id: 'Press Enter to browse files from your computer.',
|
|
66
|
+
defaultMessage:
|
|
67
|
+
'File upload area. Press Enter or click to open the file browser.',
|
|
68
|
+
},
|
|
69
|
+
dragAndDropReplaceA11y: {
|
|
70
|
+
id: 'File upload area. Press Enter or click to replace the existing file',
|
|
71
|
+
defaultMessage:
|
|
72
|
+
'File upload area. Press Enter or click to replace the existing file.',
|
|
65
73
|
},
|
|
66
74
|
requiredField: {
|
|
67
75
|
id: 'This field is required.',
|
|
@@ -180,9 +188,11 @@ const FileWidget = (props) => {
|
|
|
180
188
|
};
|
|
181
189
|
|
|
182
190
|
const statusTextA11y = [
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
value
|
|
192
|
+
? intl.formatMessage(messages.dragAndDropReplaceA11y)
|
|
193
|
+
: intl.formatMessage(messages.dragAndDropActionA11y),
|
|
194
|
+
props.required && intl.formatMessage(messages.requiredField),
|
|
195
|
+
value?.filename,
|
|
186
196
|
]
|
|
187
197
|
.filter(Boolean)
|
|
188
198
|
.join('. ');
|