@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.
Files changed (163) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/locales/af/LC_MESSAGES/volto.po +18 -11
  3. package/locales/af.json +1 -1
  4. package/locales/ar/LC_MESSAGES/volto.po +18 -11
  5. package/locales/ar.json +1 -1
  6. package/locales/bg/LC_MESSAGES/volto.po +18 -11
  7. package/locales/bg.json +1 -1
  8. package/locales/bn/LC_MESSAGES/volto.po +18 -11
  9. package/locales/bn.json +1 -1
  10. package/locales/ca/LC_MESSAGES/volto.po +18 -11
  11. package/locales/ca.json +1 -1
  12. package/locales/cs/LC_MESSAGES/volto.po +18 -11
  13. package/locales/cs.json +1 -1
  14. package/locales/cy/LC_MESSAGES/volto.po +18 -11
  15. package/locales/cy.json +1 -1
  16. package/locales/da/LC_MESSAGES/volto.po +18 -11
  17. package/locales/da.json +1 -1
  18. package/locales/de/LC_MESSAGES/volto.po +18 -11
  19. package/locales/de.json +1 -1
  20. package/locales/el/LC_MESSAGES/volto.po +18 -11
  21. package/locales/el.json +1 -1
  22. package/locales/en/LC_MESSAGES/volto.po +18 -11
  23. package/locales/en.json +1 -1
  24. package/locales/en_AU/LC_MESSAGES/volto.po +18 -11
  25. package/locales/en_AU.json +1 -1
  26. package/locales/en_GB/LC_MESSAGES/volto.po +18 -11
  27. package/locales/en_GB.json +1 -1
  28. package/locales/eo/LC_MESSAGES/volto.po +18 -11
  29. package/locales/eo.json +1 -1
  30. package/locales/es/LC_MESSAGES/volto.po +18 -11
  31. package/locales/es.json +1 -1
  32. package/locales/et/LC_MESSAGES/volto.po +18 -11
  33. package/locales/et.json +1 -1
  34. package/locales/eu/LC_MESSAGES/volto.po +18 -11
  35. package/locales/eu.json +1 -1
  36. package/locales/fa/LC_MESSAGES/volto.po +18 -11
  37. package/locales/fa.json +1 -1
  38. package/locales/fi/LC_MESSAGES/volto.po +18 -11
  39. package/locales/fi.json +1 -1
  40. package/locales/fr/LC_MESSAGES/volto.po +18 -11
  41. package/locales/fr.json +1 -1
  42. package/locales/fu/LC_MESSAGES/volto.po +18 -11
  43. package/locales/fu.json +1 -1
  44. package/locales/gl/LC_MESSAGES/volto.po +18 -11
  45. package/locales/gl.json +1 -1
  46. package/locales/he/LC_MESSAGES/volto.po +18 -11
  47. package/locales/he.json +1 -1
  48. package/locales/hi/LC_MESSAGES/volto.po +18 -11
  49. package/locales/hi.json +1 -1
  50. package/locales/hr/LC_MESSAGES/volto.po +18 -11
  51. package/locales/hr.json +1 -1
  52. package/locales/hu/LC_MESSAGES/volto.po +18 -11
  53. package/locales/hu.json +1 -1
  54. package/locales/hy/LC_MESSAGES/volto.po +18 -11
  55. package/locales/hy.json +1 -1
  56. package/locales/id/LC_MESSAGES/volto.po +18 -11
  57. package/locales/id.json +1 -1
  58. package/locales/it/LC_MESSAGES/volto.po +18 -11
  59. package/locales/it.json +1 -1
  60. package/locales/ja/LC_MESSAGES/volto.po +18 -11
  61. package/locales/ja.json +1 -1
  62. package/locales/ka/LC_MESSAGES/volto.po +18 -11
  63. package/locales/ka.json +1 -1
  64. package/locales/kn/LC_MESSAGES/volto.po +18 -11
  65. package/locales/kn.json +1 -1
  66. package/locales/ko/LC_MESSAGES/volto.po +18 -11
  67. package/locales/ko.json +1 -1
  68. package/locales/lt/LC_MESSAGES/volto.po +18 -11
  69. package/locales/lt.json +1 -1
  70. package/locales/lv/LC_MESSAGES/volto.po +18 -11
  71. package/locales/lv.json +1 -1
  72. package/locales/mi/LC_MESSAGES/volto.po +18 -11
  73. package/locales/mi.json +1 -1
  74. package/locales/mk/LC_MESSAGES/volto.po +18 -11
  75. package/locales/mk.json +1 -1
  76. package/locales/my/LC_MESSAGES/volto.po +18 -11
  77. package/locales/my.json +1 -1
  78. package/locales/nb_NO/LC_MESSAGES/volto.po +18 -11
  79. package/locales/nb_NO.json +1 -1
  80. package/locales/nl/LC_MESSAGES/volto.po +18 -11
  81. package/locales/nl.json +1 -1
  82. package/locales/nn/LC_MESSAGES/volto.po +18 -11
  83. package/locales/nn.json +1 -1
  84. package/locales/pl/LC_MESSAGES/volto.po +18 -11
  85. package/locales/pl.json +1 -1
  86. package/locales/pt/LC_MESSAGES/volto.po +18 -11
  87. package/locales/pt.json +1 -1
  88. package/locales/pt_BR/LC_MESSAGES/volto.po +18 -11
  89. package/locales/pt_BR.json +1 -1
  90. package/locales/rm/LC_MESSAGES/volto.po +18 -11
  91. package/locales/rm.json +1 -1
  92. package/locales/ro/LC_MESSAGES/volto.po +18 -11
  93. package/locales/ro.json +1 -1
  94. package/locales/ru/LC_MESSAGES/volto.po +18 -11
  95. package/locales/ru.json +1 -1
  96. package/locales/sk/LC_MESSAGES/volto.po +18 -11
  97. package/locales/sk.json +1 -1
  98. package/locales/sl/LC_MESSAGES/volto.po +18 -11
  99. package/locales/sl.json +1 -1
  100. package/locales/sm/LC_MESSAGES/volto.po +18 -11
  101. package/locales/sm.json +1 -1
  102. package/locales/sq/LC_MESSAGES/volto.po +18 -11
  103. package/locales/sq.json +1 -1
  104. package/locales/sr/LC_MESSAGES/volto.po +18 -11
  105. package/locales/sr.json +1 -1
  106. package/locales/sr@cyrl/LC_MESSAGES/volto.po +18 -11
  107. package/locales/sr@cyrl.json +1 -1
  108. package/locales/sr@latn/LC_MESSAGES/volto.po +18 -11
  109. package/locales/sr@latn.json +1 -1
  110. package/locales/sv/LC_MESSAGES/volto.po +18 -11
  111. package/locales/sv.json +1 -1
  112. package/locales/ta/LC_MESSAGES/volto.po +18 -11
  113. package/locales/ta.json +1 -1
  114. package/locales/te/LC_MESSAGES/volto.po +18 -11
  115. package/locales/te.json +1 -1
  116. package/locales/th/LC_MESSAGES/volto.po +18 -11
  117. package/locales/th.json +1 -1
  118. package/locales/to/LC_MESSAGES/volto.po +18 -11
  119. package/locales/to.json +1 -1
  120. package/locales/tr/LC_MESSAGES/volto.po +18 -11
  121. package/locales/tr.json +1 -1
  122. package/locales/uk/LC_MESSAGES/volto.po +18 -11
  123. package/locales/uk.json +1 -1
  124. package/locales/vi/LC_MESSAGES/volto.po +18 -11
  125. package/locales/vi.json +1 -1
  126. package/locales/volto.pot +17 -10
  127. package/locales/zh_CN/LC_MESSAGES/volto.po +18 -11
  128. package/locales/zh_CN.json +1 -1
  129. package/locales/zh_Hant/LC_MESSAGES/volto.po +18 -11
  130. package/locales/zh_Hant.json +1 -1
  131. package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +18 -11
  132. package/locales/zh_Hant_HK.json +1 -1
  133. package/package.json +6 -7
  134. package/src/actions/querystringsearch/querystringsearch.test.js +107 -0
  135. package/src/actions/querystringsearch/querystringsearch.ts +135 -0
  136. package/src/components/manage/Blocks/Listing/getAsyncData.js +5 -0
  137. package/src/components/manage/Blocks/Listing/withQuerystringResults.jsx +18 -5
  138. package/src/components/manage/Controlpanels/BlockTypes.test.ts +132 -0
  139. package/src/components/manage/Controlpanels/BlockTypes.tsx +32 -11
  140. package/src/components/manage/Sidebar/ObjectBrowserBody.jsx +23 -2
  141. package/src/components/manage/Toolbar/More.jsx +13 -0
  142. package/src/components/manage/Widgets/FileWidget.jsx +19 -9
  143. package/src/components/manage/Widgets/QuerystringWidget.jsx +18 -1
  144. package/src/components/manage/Widgets/QuerystringWidget.test.jsx +11 -0
  145. package/src/components/manage/Widgets/RegistryImageWidget.jsx +5 -3
  146. package/src/config/server.js +2 -0
  147. package/src/express-middleware/images.js +0 -1
  148. package/src/express-middleware/portraits.js +34 -0
  149. package/src/helpers/Api/PloneBackendAPIResourceWithAuth.js +39 -0
  150. package/src/helpers/Api/PloneBackendAPIResourceWithAuth.test.js +60 -0
  151. package/src/helpers/Pagination/Pagination.test.ts +259 -0
  152. package/src/helpers/Pagination/Pagination.ts +151 -0
  153. package/theme/themes/pastanaga/extras/focus.less +33 -0
  154. package/theme/themes/pastanaga/extras/main.less +1 -0
  155. package/theme/themes/pastanaga/extras/skiplinks.less +0 -2
  156. package/theme/themes/pastanaga/globals/site.variables +7 -0
  157. package/types/actions/querystringsearch/querystringsearch.d.ts +51 -3
  158. package/types/components/manage/Controlpanels/BlockTypes.d.ts +48 -0
  159. package/types/components/manage/Widgets/QuerystringWidget.d.ts +6 -0
  160. package/types/express-middleware/portraits.d.ts +1 -0
  161. package/types/helpers/Api/PloneBackendAPIResourceWithAuth.d.ts +1 -0
  162. package/types/helpers/Pagination/Pagination.d.ts +74 -0
  163. 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
- ? Math.ceil(content.items_total / b_size)
83
+ ? computeTotalPages(content.items_total, b_size)
71
84
  : showAsQueryListing
72
- ? Math.ceil(querystringResults[subrequestID].total / b_size)
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={querystringResults?.[subrequestID]?.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 = Object.entries(blocksConfig)
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
- onSearch = (e) => {
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 a file here or click to replace the existing file',
39
- defaultMessage: 'Drop a file here or click to replace the existing file',
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 a file here or click to upload',
43
- defaultMessage: 'Drop a file here or click to upload',
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: 'File upload area. Press Enter to open the file browser',
64
- defaultMessage: 'File upload area. Press Enter to open the file browser',
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
- intl.formatMessage(messages.dragAndDropActionA11y), // Interaction instructions
184
- props.required && intl.formatMessage(messages.requiredField), // Required field status
185
- value?.filename, // Current file name if a file is uploaded
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('. ');