@plone/volto 19.0.0-alpha.1 → 19.0.0-alpha.10

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 (238) hide show
  1. package/.eslintignore +1 -0
  2. package/.eslintrc +37 -2
  3. package/CHANGELOG.md +228 -0
  4. package/README.md +3 -6
  5. package/cypress/docker/prefixed-rules.yml +26 -0
  6. package/cypress/docker/prefixed.yml +24 -0
  7. package/cypress/support/commands.js +12 -6
  8. package/cypress/support/guillotina.js +1 -0
  9. package/cypress.config.js +1 -0
  10. package/locales/af.json +1 -0
  11. package/locales/ar.json +1 -0
  12. package/locales/bg.json +1 -0
  13. package/locales/bn.json +1 -0
  14. package/locales/ca/LC_MESSAGES/volto.po +96 -17
  15. package/locales/ca.json +1 -1
  16. package/locales/cs.json +1 -0
  17. package/locales/cy.json +1 -0
  18. package/locales/da.json +1 -0
  19. package/locales/de/LC_MESSAGES/volto.po +104 -25
  20. package/locales/de.json +1 -1
  21. package/locales/el.json +1 -0
  22. package/locales/en/LC_MESSAGES/volto.po +97 -18
  23. package/locales/en.json +1 -1
  24. package/locales/en_AU.json +1 -0
  25. package/locales/en_GB.json +1 -0
  26. package/locales/eo.json +1 -0
  27. package/locales/es/LC_MESSAGES/volto.po +97 -18
  28. package/locales/es.json +1 -1
  29. package/locales/et.json +1 -0
  30. package/locales/eu/LC_MESSAGES/volto.po +96 -17
  31. package/locales/eu.json +1 -1
  32. package/locales/fa.json +1 -0
  33. package/locales/fi/LC_MESSAGES/volto.po +96 -17
  34. package/locales/fi.json +1 -1
  35. package/locales/fr/LC_MESSAGES/volto.po +97 -18
  36. package/locales/fr.json +1 -1
  37. package/locales/fu.json +1 -0
  38. package/locales/gl.json +1 -0
  39. package/locales/he.json +1 -0
  40. package/locales/hi/LC_MESSAGES/volto.po +100 -21
  41. package/locales/hi.json +1 -1
  42. package/locales/hr.json +1 -0
  43. package/locales/hu.json +1 -0
  44. package/locales/hy.json +1 -0
  45. package/locales/id.json +1 -0
  46. package/locales/it/LC_MESSAGES/volto.po +101 -22
  47. package/locales/it.json +1 -1
  48. package/locales/ja/LC_MESSAGES/volto.po +96 -17
  49. package/locales/ja.json +1 -1
  50. package/locales/ka.json +1 -0
  51. package/locales/kn.json +1 -0
  52. package/locales/ko.json +1 -0
  53. package/locales/lt.json +1 -0
  54. package/locales/lv.json +1 -0
  55. package/locales/mi.json +1 -0
  56. package/locales/mk.json +1 -0
  57. package/locales/my.json +1 -0
  58. package/locales/nb_NO.json +1 -0
  59. package/locales/nl/LC_MESSAGES/volto.po +100 -21
  60. package/locales/nl.json +1 -1
  61. package/locales/nn.json +1 -0
  62. package/locales/pl.json +1 -0
  63. package/locales/pt/LC_MESSAGES/volto.po +96 -17
  64. package/locales/pt.json +1 -1
  65. package/locales/pt_BR/LC_MESSAGES/volto.po +116 -37
  66. package/locales/pt_BR.json +1 -1
  67. package/locales/rm.json +1 -0
  68. package/locales/ro/LC_MESSAGES/volto.po +100 -21
  69. package/locales/ro.json +1 -1
  70. package/locales/ru/LC_MESSAGES/volto.po +100 -21
  71. package/locales/ru.json +1 -1
  72. package/locales/sk.json +1 -0
  73. package/locales/sl.json +1 -0
  74. package/locales/sm.json +1 -0
  75. package/locales/sq.json +1 -0
  76. package/locales/sr.json +1 -0
  77. package/locales/sr@cyrl.json +1 -0
  78. package/locales/sr@latn.json +1 -0
  79. package/locales/sv.json +1 -1
  80. package/locales/ta.json +1 -0
  81. package/locales/te.json +1 -0
  82. package/locales/th.json +1 -0
  83. package/locales/to.json +1 -0
  84. package/locales/tr.json +1 -0
  85. package/locales/uk.json +1 -0
  86. package/locales/vi.json +1 -0
  87. package/locales/volto.pot +97 -18
  88. package/locales/zh_CN/LC_MESSAGES/volto.po +96 -17
  89. package/locales/zh_CN.json +1 -1
  90. package/locales/zh_Hant.json +1 -0
  91. package/locales/zh_Hant_HK.json +1 -0
  92. package/package.json +28 -26
  93. package/razzle.config.js +16 -0
  94. package/src/actions/content/content.js +0 -1
  95. package/src/actions/controlpanels/controlpanels.js +13 -7
  96. package/src/actions/controlpanels/controlpanels.test.js +11 -5
  97. package/src/actions/users/users.js +2 -2
  98. package/src/components/manage/Add/Add.jsx +5 -6
  99. package/src/components/manage/Blocks/Block/Edit.jsx +1 -0
  100. package/src/components/manage/Blocks/Block/EditBlockWrapper.jsx +9 -4
  101. package/src/components/manage/Blocks/Image/Edit.jsx +5 -1
  102. package/src/components/manage/Blocks/LeadImage/Edit.jsx +2 -2
  103. package/src/components/manage/Blocks/LeadImage/LeadImageSidebar.jsx +1 -1
  104. package/src/components/manage/Blocks/Listing/ImageGallery.jsx +6 -4
  105. package/src/components/manage/Blocks/Maps/Edit.jsx +2 -1
  106. package/src/components/manage/Blocks/Search/SearchBlockView.jsx +21 -4
  107. package/src/components/manage/Blocks/Teaser/Data.jsx +21 -7
  108. package/src/components/manage/Blocks/Teaser/DefaultBody.jsx +1 -1
  109. package/src/components/manage/Blocks/Teaser/schema.js +8 -3
  110. package/src/components/manage/Blocks/Title/Edit.jsx +8 -2
  111. package/src/components/manage/Blocks/Video/Edit.jsx +2 -1
  112. package/src/components/manage/Contents/Contents.jsx +23 -2
  113. package/src/components/manage/Contents/Contents.test.jsx +7 -0
  114. package/src/components/manage/Contents/ContentsBreadcrumbs.Multilingual.test.jsx +18 -5
  115. package/src/components/manage/Contents/ContentsBreadcrumbs.jsx +20 -26
  116. package/src/components/manage/Contents/ContentsBreadcrumbs.test.jsx +14 -0
  117. package/src/components/manage/Contents/ContentsDeleteModal.jsx +258 -206
  118. package/src/components/manage/Contents/ContentsDeleteModal.stories.jsx +26 -8
  119. package/src/components/manage/Contents/ContentsItem.jsx +10 -2
  120. package/src/components/manage/Contents/ContentsUploadModal.test.jsx +13 -22
  121. package/src/components/manage/Controlpanels/ContentType.jsx +1 -1
  122. package/src/components/manage/Controlpanels/Groups/GroupsControlpanel.jsx +3 -2
  123. package/src/components/manage/Controlpanels/Users/RenderUsers.jsx +156 -175
  124. package/src/components/manage/Controlpanels/Users/UsersControlpanel.jsx +575 -630
  125. package/src/components/manage/Controlpanels/Users/UsersControlpanel.test.jsx +4 -3
  126. package/src/components/manage/Edit/Edit.jsx +2 -3
  127. package/src/components/manage/Form/Form.jsx +32 -0
  128. package/src/components/manage/Form/Form.test.jsx +22 -18
  129. package/src/components/manage/Form/InlineForm.jsx +2 -2
  130. package/src/components/manage/Multilingual/CompareLanguages.jsx +2 -5
  131. package/src/components/manage/Multilingual/CreateTranslation.jsx +8 -8
  132. package/src/components/manage/Multilingual/ManageTranslations.jsx +4 -2
  133. package/src/components/manage/Multilingual/ManageTranslations.test.jsx +5 -1
  134. package/src/components/manage/Multilingual/TranslationObject.jsx +1 -1
  135. package/src/components/manage/Sidebar/ObjectBrowserNav.jsx +2 -1
  136. package/src/components/manage/Sidebar/SidebarPortal.test.tsx +42 -0
  137. package/src/components/manage/Sidebar/SidebarPortal.tsx +48 -0
  138. package/src/components/manage/TemplateChooser/TemplateChooser.jsx +2 -1
  139. package/src/components/manage/Toolbar/More.jsx +4 -1
  140. package/src/components/manage/Toolbar/More.test.jsx +3 -0
  141. package/src/components/manage/Toolbar/PersonalTools.jsx +2 -1
  142. package/src/components/manage/Toolbar/Toolbar.jsx +3 -4
  143. package/src/components/manage/Toolbar/Types.jsx +7 -7
  144. package/src/components/manage/UniversalLink/UniversalLink.tsx +1 -0
  145. package/src/components/manage/Widgets/DatetimeWidget.jsx +5 -0
  146. package/src/components/manage/Widgets/FileWidget.jsx +14 -8
  147. package/src/components/manage/Widgets/ImageWidget.jsx +171 -38
  148. package/src/components/manage/Widgets/InternalUrlWidget.jsx +2 -0
  149. package/src/components/manage/Widgets/ObjectBrowserWidget.jsx +3 -0
  150. package/src/components/manage/Widgets/RegistryImageWidget.test.jsx +3 -2
  151. package/src/components/manage/Widgets/SelectAutoComplete.jsx +29 -12
  152. package/src/components/manage/Widgets/SelectWidget.jsx +3 -1
  153. package/src/components/manage/Widgets/UrlWidget.jsx +2 -0
  154. package/src/components/theme/AlternateHrefLangs/AlternateHrefLangs.jsx +1 -3
  155. package/src/components/theme/AlternateHrefLangs/AlternateHrefLangs.test.jsx +0 -4
  156. package/src/components/theme/App/App.jsx +3 -1
  157. package/src/components/theme/App/App.test.jsx +1 -0
  158. package/src/components/theme/Avatar/Avatar.jsx +2 -1
  159. package/src/components/theme/FormattedDate/FormattedDate.stories.jsx +20 -2
  160. package/src/components/theme/Image/Image.jsx +11 -8
  161. package/src/components/theme/LanguageSelector/{LanguageSelector.test.jsx → LanguageSelector.test.tsx} +6 -6
  162. package/src/components/theme/LanguageSelector/LanguageSelector.tsx +89 -0
  163. package/src/components/theme/Logo/Logo.Multilingual.test.jsx +0 -5
  164. package/src/components/theme/MultilingualRedirector/MultilingualRedirector.jsx +8 -12
  165. package/src/components/theme/MultilingualRedirector/MultilingualRedirector.test.jsx +3 -5
  166. package/src/components/theme/Navigation/NavItem.jsx +1 -5
  167. package/src/components/theme/Navigation/Navigation.Multilingual.test.jsx +0 -5
  168. package/src/components/theme/NotFound/NotFound.jsx +5 -2
  169. package/src/components/theme/NotFound/NotFound.test.jsx +3 -0
  170. package/src/components/theme/PreviewImage/PreviewImage.jsx +1 -1
  171. package/src/components/theme/RequestTimeout/RequestTimeout.jsx +1 -1
  172. package/src/components/theme/Sitemap/Sitemap.jsx +6 -5
  173. package/src/components/theme/Sitemap/Sitemap.test.jsx +0 -1
  174. package/src/components/theme/View/FileView.jsx +9 -1
  175. package/src/components/theme/View/View.jsx +1 -1
  176. package/src/components/theme/Widgets/ImageWidget.jsx +2 -1
  177. package/src/config/ControlPanels.js +1 -0
  178. package/src/config/index.js +18 -25
  179. package/src/config/server.js +0 -2
  180. package/src/express-middleware/devproxy.js +20 -5
  181. package/src/helpers/Api/APIResourceWithAuth.js +8 -3
  182. package/src/helpers/Api/Api.js +7 -4
  183. package/src/helpers/AsyncConnect/ssr.js +4 -1
  184. package/src/helpers/Content/Content.js +23 -0
  185. package/src/helpers/Content/Content.test.js +39 -0
  186. package/src/helpers/Content/withClientSideContent.jsx +35 -0
  187. package/src/helpers/Extensions/withBlockSchemaEnhancer.jsx +4 -1
  188. package/src/helpers/Html/Html.jsx +13 -7
  189. package/src/helpers/LanguageMap/LanguageMap.js +115 -8
  190. package/src/helpers/MessageLabels/MessageLabels.js +5 -0
  191. package/src/helpers/Sitemap/Sitemap.js +4 -4
  192. package/src/helpers/Url/Url.js +32 -2
  193. package/src/helpers/Url/Url.test.js +62 -0
  194. package/src/helpers/Utils/withSaveAsDraft.jsx +241 -0
  195. package/src/hooks/user/useUser.js +1 -1
  196. package/src/middleware/Api.test.js +4 -0
  197. package/src/middleware/api.js +77 -28
  198. package/src/middleware/storeProtectLoadUtils.test.js +3 -3
  199. package/src/reducers/content/content.js +3 -18
  200. package/src/reducers/diff/diff.js +5 -1
  201. package/src/reducers/diff/diff.test.js +60 -4
  202. package/src/routes.js +4 -2
  203. package/src/server.jsx +45 -14
  204. package/src/start-client.jsx +9 -6
  205. package/src/start-server.js +9 -3
  206. package/test-setup-config.jsx +0 -2
  207. package/theme/themes/pastanaga/collections/table.overrides +9 -0
  208. package/theme/themes/pastanaga/extras/blocks.less +26 -0
  209. package/theme/themes/pastanaga/extras/contents.less +17 -5
  210. package/theme/themes/pastanaga/extras/main.less +32 -2
  211. package/tsconfig.json +3 -4
  212. package/types/components/manage/Blocks/Teaser/schema.d.ts +1 -0
  213. package/types/components/manage/Controlpanels/Users/RenderUsers.d.ts +18 -2
  214. package/types/components/manage/Controlpanels/Users/UsersControlpanel.d.ts +6 -2
  215. package/types/components/manage/Controlpanels/index.d.ts +2 -2
  216. package/types/components/manage/Sidebar/SidebarPortal.d.ts +7 -15
  217. package/types/components/manage/Widgets/ImageWidget.d.ts +41 -1
  218. package/types/components/manage/Widgets/RecurrenceWidget/Utils.d.ts +12 -18
  219. package/types/components/theme/FormattedDate/FormattedDate.stories.d.ts +1 -1
  220. package/types/components/theme/LanguageSelector/LanguageSelector.d.ts +3 -10
  221. package/types/helpers/Content/Content.d.ts +7 -0
  222. package/types/helpers/Content/withClientSideContent.d.ts +1 -0
  223. package/types/helpers/Extensions/withBlockSchemaEnhancer.d.ts +4 -5
  224. package/types/helpers/Helmet/Helmet.d.ts +1 -1
  225. package/types/helpers/LanguageMap/LanguageMap.d.ts +428 -4
  226. package/types/helpers/MessageLabels/MessageLabels.d.ts +68 -62
  227. package/types/helpers/Url/Url.d.ts +14 -0
  228. package/types/helpers/Url/bulkFlattenToAppURL.d.ts +5 -0
  229. package/types/helpers/Utils/withSaveAsDraft.d.ts +1 -0
  230. package/types/middleware/api.d.ts +6 -9
  231. package/types/reducers/index.d.ts +1 -0
  232. package/types/start-client.d.ts +0 -1
  233. package/package-why.json +0 -34
  234. package/src/actions/content/content.multilingual.test.js +0 -17
  235. package/src/components/manage/Sidebar/SidebarPortal.jsx +0 -47
  236. package/src/components/manage/Sidebar/SidebarPortal.test.jsx +0 -26
  237. package/src/components/theme/LanguageSelector/LanguageSelector.jsx +0 -77
  238. package/theme/themes/pastanaga/extras/utils.less +0 -63
@@ -22,10 +22,10 @@ export const SITEMAP_BATCH_SIZE = 5000;
22
22
  export const generateSitemap = (_req, start = 0, size = undefined) =>
23
23
  new Promise((resolve) => {
24
24
  const { settings } = config;
25
- const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
25
+ const apiSuffix = settings.legacyTraverse ? '' : '/++api++';
26
26
  const apiPath = settings.internalApiPath ?? settings.apiPath;
27
27
  const request = superagent.get(
28
- `${apiPath}${APISUFIX}/@search?metadata_fields=modified&b_start=${start}&b_size=${
28
+ `${apiPath}${apiSuffix}/@search?metadata_fields=modified&b_start=${start}&b_size=${
29
29
  size !== undefined ? size : 100000000
30
30
  }&use_site_search_settings=1`,
31
31
  );
@@ -64,10 +64,10 @@ export const generateSitemap = (_req, start = 0, size = undefined) =>
64
64
  export const generateSitemapIndex = (_req, gzip = false) =>
65
65
  new Promise((resolve) => {
66
66
  const { settings } = config;
67
- const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
67
+ const apiSuffix = settings.legacyTraverse ? '' : '/++api++';
68
68
  const apiPath = settings.internalApiPath ?? settings.apiPath;
69
69
  const request = superagent.get(
70
- `${apiPath}${APISUFIX}/@search?metadata_fields=modified&b_size=0&use_site_search_settings=1`,
70
+ `${apiPath}${apiSuffix}/@search?metadata_fields=modified&b_size=0&use_site_search_settings=1`,
71
71
  );
72
72
  request.set('Accept', 'application/json');
73
73
  const authToken = _req.universalCookies.get('auth_token');
@@ -193,7 +193,7 @@ export function addAppURL(url) {
193
193
  */
194
194
  export function expandToBackendURL(path) {
195
195
  const { settings } = config;
196
- const APISUFIX = settings.legacyTraverse ? '' : '/++api++';
196
+ const apiSuffix = settings.legacyTraverse ? '' : '/++api++';
197
197
  let adjustedPath;
198
198
  if (path.startsWith('http://') || path.startsWith('https://')) {
199
199
  // flattenToAppURL first if we get a full URL
@@ -210,7 +210,7 @@ export function expandToBackendURL(path) {
210
210
  apiPath = settings.apiPath;
211
211
  }
212
212
 
213
- return `${apiPath}${APISUFIX}${adjustedPath}`;
213
+ return `${apiPath}${apiSuffix}${adjustedPath}`;
214
214
  }
215
215
 
216
216
  /**
@@ -258,6 +258,36 @@ export function isUrl(url) {
258
258
  return urlRegex().test(url);
259
259
  }
260
260
 
261
+ /**
262
+ * Add subpath path if set in settings
263
+ * @method addSubpathPrefix
264
+ * @param {string} src pathname
265
+ * @returns {string} prefixed subpath pathname
266
+ */
267
+ export function addSubpathPrefix(src) {
268
+ let url = src;
269
+ const { subpathPrefix } = config.settings;
270
+ if (isInternalURL(src) && subpathPrefix && !src.startsWith(subpathPrefix)) {
271
+ url = subpathPrefix + src; //add subpathPrefix to src if it's an internal url and not a static resource.
272
+ }
273
+ return url;
274
+ }
275
+
276
+ /**
277
+ * strip subpath path particulary from api calls
278
+ * @method stripSubpathPrefix
279
+ * @param {string} src pathname
280
+ * @returns {string} pathname
281
+ */
282
+ export function stripSubpathPrefix(src) {
283
+ let url = src;
284
+ const { subpathPrefix } = config.settings;
285
+ if (subpathPrefix && src.match(new RegExp(`^${subpathPrefix}(/|$)`))) {
286
+ url = src.slice(subpathPrefix.length);
287
+ }
288
+ return url;
289
+ }
290
+
261
291
  /**
262
292
  * Get field url
263
293
  * @method getFieldURL
@@ -19,6 +19,8 @@ import {
19
19
  normaliseMail,
20
20
  normalizeTelephone,
21
21
  flattenScales,
22
+ addSubpathPrefix,
23
+ stripSubpathPrefix,
22
24
  } from './Url';
23
25
 
24
26
  beforeEach(() => {
@@ -533,4 +535,64 @@ describe('Url', () => {
533
535
  });
534
536
  });
535
537
  });
538
+
539
+ describe('Subpath tests', () => {
540
+ beforeEach(() => {
541
+ settings.subpathPrefix = '/site';
542
+ });
543
+ describe('addSubpathPrefix', () => {
544
+ it('adds subpath prefix to internal URLs', () => {
545
+ expect(addSubpathPrefix('/some-page')).toBe('/site/some-page');
546
+ expect(addSubpathPrefix('/news/article')).toBe('/site/news/article');
547
+ });
548
+
549
+ it('does not add subpath prefix to URLs that already have it', () => {
550
+ expect(addSubpathPrefix('/site/some-page')).toBe('/site/some-page');
551
+ });
552
+
553
+ it('does not add subpath prefix to external URLs', () => {
554
+ expect(addSubpathPrefix('https://example.com/page')).toBe(
555
+ 'https://example.com/page',
556
+ );
557
+ });
558
+
559
+ it('handles empty subpath prefix', () => {
560
+ settings.subpathPrefix = '';
561
+ expect(addSubpathPrefix('/some-page')).toBe('/some-page');
562
+ });
563
+
564
+ it('handles undefined subpath prefix', () => {
565
+ settings.subpathPrefix = undefined;
566
+ expect(addSubpathPrefix('/some-page')).toBe('/some-page');
567
+ });
568
+ });
569
+
570
+ describe('stripSubpathPrefix', () => {
571
+ beforeEach(() => {
572
+ settings.subpathPrefix = '/site';
573
+ });
574
+ it('removes subpath prefix from URLs that have it', () => {
575
+ expect(stripSubpathPrefix('/site/some-page')).toBe('/some-page');
576
+ expect(stripSubpathPrefix('/site/news/article')).toBe('/news/article');
577
+ });
578
+
579
+ it('leaves URLs unchanged if they do not have the prefix', () => {
580
+ expect(stripSubpathPrefix('/other/some-page')).toBe('/other/some-page');
581
+ });
582
+
583
+ it('handles the case where URL is exactly the subpath prefix', () => {
584
+ expect(stripSubpathPrefix('/site')).toBe('');
585
+ });
586
+
587
+ it('handles empty subpath prefix', () => {
588
+ settings.subpathPrefix = '';
589
+ expect(stripSubpathPrefix('/some-page')).toBe('/some-page');
590
+ });
591
+
592
+ it('handles undefined subpath prefix', () => {
593
+ settings.subpathPrefix = undefined;
594
+ expect(stripSubpathPrefix('/some-page')).toBe('/some-page');
595
+ });
596
+ });
597
+ });
536
598
  });
@@ -0,0 +1,241 @@
1
+ import React from 'react';
2
+ import hoistNonReactStatics from 'hoist-non-react-statics';
3
+ import isEqual from 'react-fast-compare';
4
+ import { toast } from 'react-toastify';
5
+ import Icon from '@plone/volto/components/theme/Icon/Icon';
6
+ import Toast from '@plone/volto/components/manage/Toast/Toast';
7
+ import { Button } from 'semantic-ui-react';
8
+ import checkSVG from '@plone/volto/icons/check.svg';
9
+ import clearSVG from '@plone/volto/icons/clear.svg';
10
+ import { useIntl, defineMessages } from 'react-intl';
11
+ import { useLocation } from 'react-router-dom';
12
+
13
+ const messages = defineMessages({
14
+ autoSaveFound: {
15
+ id: 'Autosaved content found',
16
+ defaultMessage: 'Autosaved content found',
17
+ },
18
+ loadData: {
19
+ id: 'Do you want to restore your autosaved content?',
20
+ defaultMessage: 'Do you want to restore your autosaved content?',
21
+ },
22
+ loadExpiredData: {
23
+ id: "Another person edited this content, and it's currently displayed. Do you want to replace it with your autosaved content?",
24
+ defaultMessage:
25
+ "Another person edited this content, and it's currently displayed. Do you want to replace it with your autosaved content?",
26
+ },
27
+ });
28
+
29
+ function getDisplayName(WrappedComponent) {
30
+ return WrappedComponent.displayName || WrappedComponent.name || 'Component';
31
+ }
32
+
33
+ const mapSchemaToData = (schema, data) => {
34
+ if (!data) return {};
35
+ const dataKeys = Object.keys(data);
36
+ return Object.assign(
37
+ {},
38
+ ...Object.keys(schema.properties)
39
+ .filter((k) => dataKeys.includes(k))
40
+ .map((k) => ({ [k]: data[k] })),
41
+ );
42
+ };
43
+
44
+ // will be used to avoid using the first mount call if there is a second call
45
+ let mountTime;
46
+
47
+ const getFormId = (props, location) => {
48
+ const { type, pathname = location.pathname, isEditForm, schema } = props;
49
+ const id = isEditForm
50
+ ? ['form', type, pathname].join('-')
51
+ : type
52
+ ? ['form', pathname, type].join('-')
53
+ : schema?.properties?.comment
54
+ ? ['form', pathname, 'comment'].join('-')
55
+ : ['form', pathname].join('-');
56
+
57
+ return id;
58
+ };
59
+
60
+ /**
61
+ * Toast content that has OK and Cancel buttons
62
+ * @param {function} onUpdate
63
+ * @param {function} onClose
64
+ * @param {string} userMessage
65
+ * @returns
66
+ */
67
+ const ConfirmAutoSave = ({ onUpdate, onClose, userMessage }) => {
68
+ const handleClickOK = () => onUpdate();
69
+ const handleClickCancel = () => onClose();
70
+
71
+ return (
72
+ <div className="toast-box-center">
73
+ <div>{userMessage}</div>
74
+ <Button
75
+ icon
76
+ aria-label="Unchecked"
77
+ className="save toast-box"
78
+ onClick={handleClickOK}
79
+ >
80
+ <Icon
81
+ name={checkSVG}
82
+ size="24px"
83
+ className="circled toast-box-blue-icon"
84
+ />
85
+ </Button>
86
+ <Button
87
+ icon
88
+ aria-label="Unchecked"
89
+ className="save toast-box"
90
+ onClick={handleClickCancel}
91
+ >
92
+ <Icon
93
+ name={clearSVG}
94
+ size="24px"
95
+ className="circled toast-box-blue-icon"
96
+ />
97
+ </Button>
98
+ </div>
99
+ );
100
+ };
101
+
102
+ /**
103
+ * Will remove localStorage item using debounce
104
+ * @param {string} id
105
+ * @param {number} timerForDeletion
106
+ */
107
+ const clearStorage = (id, timerForDeletion) => {
108
+ timerForDeletion.current && clearTimeout(timerForDeletion.current);
109
+ timerForDeletion.current = setTimeout(() => {
110
+ localStorage.removeItem(id);
111
+ }, 500);
112
+ };
113
+
114
+ /**
115
+ * Stale if server date is more recent
116
+ * @param {string} serverModifiedDate
117
+ * @param {string} autoSaveDate
118
+ * @returns {Boolean}
119
+ */
120
+ const autoSaveFoundIsStale = (serverModifiedDate, autoSaveDate) => {
121
+ const result = !serverModifiedDate
122
+ ? false
123
+ : new Date(serverModifiedDate) > new Date(autoSaveDate);
124
+ return result;
125
+ };
126
+
127
+ const draftApi = (id, schema, timer, timerForDeletion, intl) => ({
128
+ // - since Add Content Type will call componentDidMount twice, we will
129
+ // use the second call (using debounce)- the first will ignore any setState comands;
130
+ // - Delete local data only if user confirms Cancel
131
+ // - Will tell user that it has local stored data, even if its less recent than the server data
132
+ checkSavedDraft(state, updateCallback) {
133
+ if (!schema) return;
134
+ const saved = localStorage.getItem(id);
135
+
136
+ if (saved && Object.keys(JSON.parse(saved)).length > 1) {
137
+ const formData = mapSchemaToData(schema, state);
138
+ // includes autoSaveDate
139
+ const foundSavedData = JSON.parse(saved);
140
+ // includes only form data found in schema (no autoSaveDate)
141
+ const foundSavedSchemaData = mapSchemaToData(schema, foundSavedData);
142
+
143
+ if (!isEqual(formData, foundSavedSchemaData)) {
144
+ // eslint-disable-next-line no-alert
145
+ // cancel existing setTimeout to avoid using first call if
146
+ // successive calls are made
147
+ mountTime && clearTimeout(mountTime);
148
+ mountTime = setTimeout(() => {
149
+ toast.info(
150
+ <Toast
151
+ position="top-right"
152
+ info
153
+ autoClose={false}
154
+ title={intl.formatMessage(messages.autoSaveFound)}
155
+ content={
156
+ <ConfirmAutoSave
157
+ onUpdate={() => updateCallback(foundSavedSchemaData)}
158
+ onClose={() => clearStorage(id, timerForDeletion)}
159
+ userMessage={
160
+ autoSaveFoundIsStale(
161
+ state.modified,
162
+ foundSavedData.autoSaveDate,
163
+ )
164
+ ? intl.formatMessage(messages.loadExpiredData)
165
+ : intl.formatMessage(messages.loadData)
166
+ }
167
+ />
168
+ }
169
+ />,
170
+ );
171
+ }, 300);
172
+ }
173
+ }
174
+ },
175
+ // use debounce mode
176
+ onSaveDraft(state) {
177
+ if (!schema) return;
178
+ timer.current && clearTimeout(timer.current);
179
+ timer.current = setTimeout(() => {
180
+ const formData = mapSchemaToData(schema, state);
181
+ const saved = localStorage.getItem(id);
182
+ const newData = JSON.parse(saved);
183
+
184
+ localStorage.setItem(
185
+ id,
186
+ JSON.stringify({
187
+ ...newData,
188
+ ...formData,
189
+ autoSaveDate: new Date(),
190
+ }),
191
+ );
192
+ }, 300);
193
+ },
194
+
195
+ onCancelDraft() {
196
+ if (!schema) return;
197
+ clearStorage(id, timerForDeletion);
198
+ },
199
+ });
200
+
201
+ export default function withSaveAsDraft(options) {
202
+ const { forwardRef } = options;
203
+
204
+ return (WrappedComponent) => {
205
+ function WithSaveAsDraft(props) {
206
+ const { schema } = props;
207
+ const intl = useIntl();
208
+ const location = useLocation();
209
+ const id = getFormId(props, location);
210
+ const timmeRef = React.useRef();
211
+ const timmerForDeletionRef = React.useRef();
212
+ const api = React.useMemo(
213
+ () => draftApi(id, schema, timmeRef, timmerForDeletionRef, intl),
214
+ [id, schema, timmeRef, timmerForDeletionRef, intl],
215
+ );
216
+
217
+ return (
218
+ <WrappedComponent
219
+ {...props}
220
+ {...api}
221
+ ref={forwardRef ? props.forwardedRef : null}
222
+ />
223
+ );
224
+ }
225
+
226
+ WithSaveAsDraft.displayName = `WithSaveAsDraft(${getDisplayName(
227
+ WrappedComponent,
228
+ )})`;
229
+
230
+ if (forwardRef) {
231
+ return hoistNonReactStatics(
232
+ React.forwardRef((props, ref) => (
233
+ <WithSaveAsDraft {...props} forwardedRef={ref} />
234
+ )),
235
+ WrappedComponent,
236
+ );
237
+ }
238
+
239
+ return hoistNonReactStatics(WithSaveAsDraft, WrappedComponent);
240
+ };
241
+ }
@@ -12,7 +12,7 @@ const useUser = () => {
12
12
  const dispatch = useDispatch();
13
13
 
14
14
  useEffect(() => {
15
- if (!user?.id && users?.get.loading === false) {
15
+ if (userId && !user?.id && users?.get.loading === false) {
16
16
  dispatch(getUser(userId));
17
17
  }
18
18
  }, [dispatch, userId, user, users?.get.loading]);
@@ -25,6 +25,8 @@ describe('api middleware helpers', () => {
25
25
  const result = addExpandersToPath(
26
26
  '/de/mypage?expand=translations',
27
27
  GET_CONTENT,
28
+ false,
29
+ true,
28
30
  );
29
31
  expect(result).toEqual('/de/mypage?expand=translations,mycustomexpander');
30
32
  });
@@ -183,6 +185,8 @@ describe('api middleware helpers', () => {
183
185
  const result = addExpandersToPath(
184
186
  '/de/mypage/@navigation?expand=translations&expand.navigation.depth=3&someotherquery=1&someotherquery=2',
185
187
  GET_CONTENT,
188
+ false,
189
+ true,
186
190
  );
187
191
  // No need to stringify
188
192
  expect(result).toEqual(
@@ -35,7 +35,7 @@ let socket = null;
35
35
  *
36
36
  * - It should add the expanders set in the config settings
37
37
  * - It should preserve any query if present
38
- * - It should preserve (and add) any expand parameter (if present) e.g. translations
38
+ * - It should preserve (and add) any expand parameter (if present)
39
39
  * - It should take use the correct codification for arrays in querystring (repeated parameter for each member of the array)
40
40
  *
41
41
  * @function addExpandersToPath
@@ -43,7 +43,7 @@ let socket = null;
43
43
  * @param {*} type The action type
44
44
  * @returns {string} The url/path with the configured expanders added to the query string
45
45
  */
46
- export function addExpandersToPath(path, type, isAnonymous) {
46
+ export function addExpandersToPath(path, type, isAnonymous, isMultilingual) {
47
47
  const { settings } = config;
48
48
  const { apiExpanders = [] } = settings;
49
49
 
@@ -58,7 +58,14 @@ export function addExpandersToPath(path, type, isAnonymous) {
58
58
 
59
59
  const expandMerge = compact(
60
60
  union([expand, ...flatten(expandersFromConfig)]),
61
- ).filter((item) => !(item === 'types' && isAnonymous)); // Remove types expander if isAnonymous
61
+ ).filter(
62
+ // Remove types for anonymous, translations unless multilingual
63
+ (item) =>
64
+ !(
65
+ (item === 'types' && isAnonymous) ||
66
+ (item === 'translations' && !isMultilingual)
67
+ ),
68
+ );
62
69
 
63
70
  const stringifiedExpand = qs.stringify(
64
71
  { expand: expandMerge },
@@ -128,6 +135,8 @@ function sendOnSocket(request) {
128
135
  * @param {Object} api Api object.
129
136
  * @returns {Promise} Action promise.
130
137
  */
138
+ let isHydrating = __CLIENT__ ? true : false;
139
+
131
140
  const apiMiddlewareFactory =
132
141
  (api) =>
133
142
  ({ dispatch, getState }) =>
@@ -135,14 +144,17 @@ const apiMiddlewareFactory =
135
144
  (action) => {
136
145
  const { settings } = config;
137
146
 
138
- const token = getState().userSession.token;
139
- let uploadedFiles = getState().content.uploadedFiles;
147
+ const state = getState();
148
+ const token = state.userSession.token;
149
+ let uploadedFiles = state.content.uploadedFiles;
150
+ const isMultilingual = state.site.data.features?.multilingual;
140
151
  let isAnonymous = true;
141
152
  if (token) {
142
153
  const tokenExpiration = jwtDecode(token).exp;
143
154
  const currentTime = new Date().getTime() / 1000;
144
155
  isAnonymous = !token || currentTime > tokenExpiration;
145
156
  }
157
+ const hasExistingError = state.content.get?.error;
146
158
 
147
159
  if (typeof action === 'function') {
148
160
  return action(dispatch, getState);
@@ -164,14 +176,24 @@ const apiMiddlewareFactory =
164
176
  request.map((item) =>
165
177
  sendOnSocket({
166
178
  ...item,
167
- path: addExpandersToPath(item.path, type, isAnonymous),
179
+ path: addExpandersToPath(
180
+ item.path,
181
+ type,
182
+ isAnonymous,
183
+ isMultilingual,
184
+ ),
168
185
  id: type,
169
186
  }),
170
187
  ),
171
188
  )
172
189
  : sendOnSocket({
173
190
  ...request,
174
- path: addExpandersToPath(request.path, type, isAnonymous),
191
+ path: addExpandersToPath(
192
+ request.path,
193
+ type,
194
+ isAnonymous,
195
+ isMultilingual,
196
+ ),
175
197
  id: type,
176
198
  });
177
199
  } else {
@@ -180,7 +202,12 @@ const apiMiddlewareFactory =
180
202
  ? request.reduce((prevPromise, item) => {
181
203
  return prevPromise.then((acc) => {
182
204
  return api[item.op](
183
- addExpandersToPath(item.path, type, isAnonymous),
205
+ addExpandersToPath(
206
+ item.path,
207
+ type,
208
+ isAnonymous,
209
+ isMultilingual,
210
+ ),
184
211
  {
185
212
  data: item.data,
186
213
  type: item.type,
@@ -201,28 +228,42 @@ const apiMiddlewareFactory =
201
228
  }, Promise.resolve([]))
202
229
  : Promise.all(
203
230
  request.map((item) =>
204
- api[item.op](addExpandersToPath(item.path, type, isAnonymous), {
205
- data: item.data,
206
- type: item.type,
207
- headers: item.headers,
208
- params: request.params,
209
- checkUrl: settings.actions_raising_api_errors.includes(
210
- action.type,
231
+ api[item.op](
232
+ addExpandersToPath(
233
+ item.path,
234
+ type,
235
+ isAnonymous,
236
+ isMultilingual,
211
237
  ),
212
- attach: item.attach,
213
- }),
238
+ {
239
+ data: item.data,
240
+ type: item.type,
241
+ headers: item.headers,
242
+ params: request.params,
243
+ checkUrl: settings.actions_raising_api_errors.includes(
244
+ action.type,
245
+ ),
246
+ attach: item.attach,
247
+ },
248
+ ),
214
249
  ),
215
250
  )
216
- : api[request.op](addExpandersToPath(request.path, type, isAnonymous), {
217
- data: request.data,
218
- type: request.type,
219
- headers: request.headers,
220
- params: request.params,
221
- checkUrl: settings.actions_raising_api_errors.includes(action.type),
222
- attach: request.attach,
223
- });
251
+ : api[request.op](
252
+ addExpandersToPath(request.path, type, isAnonymous, isMultilingual),
253
+ {
254
+ data: request.data,
255
+ type: request.type,
256
+ headers: request.headers,
257
+ params: request.params,
258
+ checkUrl: settings.actions_raising_api_errors.includes(
259
+ action.type,
260
+ ),
261
+ attach: request.attach,
262
+ },
263
+ );
224
264
  actionPromise.then(
225
265
  (result) => {
266
+ isHydrating = false;
226
267
  if (uploadedFiles !== 0) {
227
268
  dispatch(updateUploadedFiles(0));
228
269
  }
@@ -298,8 +339,16 @@ const apiMiddlewareFactory =
298
339
  }
299
340
  },
300
341
  (error) => {
342
+ // Make sure an error during hydration
343
+ // (for example when serving an archived page)
344
+ // doesn't hide the SSR content.
345
+ if (isHydrating && !hasExistingError) {
346
+ isHydrating = false;
347
+ return;
348
+ }
349
+
301
350
  // Only SSR can set ECONNREFUSED
302
- if (error.code === 'ECONNREFUSED') {
351
+ if (error?.code === 'ECONNREFUSED') {
303
352
  next({
304
353
  ...rest,
305
354
  error,
@@ -310,7 +359,7 @@ const apiMiddlewareFactory =
310
359
  }
311
360
 
312
361
  // Response error is marked crossDomain if CORS error happen
313
- else if (error.crossDomain) {
362
+ else if (error?.crossDomain) {
314
363
  next({
315
364
  ...rest,
316
365
  error,
@@ -361,7 +410,7 @@ const apiMiddlewareFactory =
361
410
  ...rest,
362
411
  error,
363
412
  statusCode: error.response,
364
- message: error.response.body.message,
413
+ message: error.response?.body?.message,
365
414
  connectionRefused: false,
366
415
  type: SET_APIERROR,
367
416
  });
@@ -445,9 +445,9 @@ describe('storeProtectLoadUtils', () => {
445
445
  isCounting: false,
446
446
  });
447
447
  };
448
- test('pending', expectPass({ type: 'ANY_PENDING' }), 2);
449
- test('success', expectPass({ type: 'ANY_SUCCESS' }), 2);
450
- test('failure', expectPass({ type: 'ANY_FAIL' }), 2);
448
+ test('pending', expectPass({ type: 'ANY_PENDING' }, 2));
449
+ test('success', expectPass({ type: 'ANY_SUCCESS' }, 2));
450
+ test('failure', expectPass({ type: 'ANY_FAIL' }, 2));
451
451
  });
452
452
  describe('counting', () => {
453
453
  const expectCount = (action, from, to) => () => {
@@ -3,11 +3,10 @@
3
3
  * @module reducers/content/content
4
4
  */
5
5
 
6
- import map from 'lodash/map';
7
- import mapKeys from 'lodash/mapKeys';
8
6
  import omit from 'lodash/omit';
9
7
 
10
8
  import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
9
+ import { flattenStaticBehaviors } from '@plone/volto/helpers/Content/Content';
11
10
 
12
11
  import {
13
12
  CREATE_CONTENT,
@@ -131,14 +130,7 @@ export default function content(state = initialState, action = {}) {
131
130
  },
132
131
  };
133
132
  case `${CREATE_CONTENT}_SUCCESS`:
134
- if (result['@static_behaviors']) {
135
- map(result['@static_behaviors'], (behavior) => {
136
- result = {
137
- ...omit(result, behavior),
138
- ...mapKeys(result[behavior], (value, key) => `${behavior}.${key}`),
139
- };
140
- });
141
- }
133
+ result = flattenStaticBehaviors(result);
142
134
  const data = action.subrequest
143
135
  ? Array.isArray(result)
144
136
  ? result.map((item) => ({
@@ -188,14 +180,7 @@ export default function content(state = initialState, action = {}) {
188
180
  },
189
181
  };
190
182
  case `${GET_CONTENT}_SUCCESS`:
191
- if (result['@static_behaviors']) {
192
- map(result['@static_behaviors'], (behavior) => {
193
- result = {
194
- ...omit(result, behavior),
195
- ...mapKeys(result[behavior], (value, key) => `${behavior}.${key}`),
196
- };
197
- });
198
- }
183
+ result = flattenStaticBehaviors(result);
199
184
 
200
185
  const transforms = config.getUtilities({
201
186
  type: 'transform',