@plone/volto 18.0.0-alpha.5 → 18.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,40 @@ myst:
17
17
 
18
18
  <!-- towncrier release notes start -->
19
19
 
20
+ ## 18.0.0-alpha.6 (2024-01-02)
21
+
22
+ ### Feature
23
+
24
+ - Added support for custom CSS properties in the `StyleWrapper`. @sneridagh [#5581](https://github.com/plone/volto/issues/5581)
25
+
26
+ ### Bugfix
27
+
28
+ - Use a textarea in the form for editing an existing comment. @Ravi-kumar9347 [#5265](https://github.com/plone/volto/issues/5265)
29
+ - The hamburger icon to open the mobile/tablet navigation is now hidden if there are no navigation items. @Aarav238 [#5353](https://github.com/plone/volto/issues/5353)
30
+ - Fix preview image component @steffenri [#5379](https://github.com/plone/volto/issues/5379)
31
+ - Fix autopopulated value of facet when settings the value for another one. @iFlameing [#5432](https://github.com/plone/volto/issues/5432)
32
+ - Fix sitemap for multilingual sites
33
+ [erral] [#5501](https://github.com/plone/volto/issues/5501)
34
+ - Replace createRef with useRef in SidebarPopup
35
+ [razvanMiu] [#5519](https://github.com/plone/volto/issues/5519)
36
+ - Fixed the project generator's ESLint configuration, added code quality checks to the CI to ensure a generated project can run these checks, and added documentation for how to reconfigure ESLint in projects. @sneridagh [#5530](https://github.com/plone/volto/issues/5530)
37
+ - Fixed edge case error in Unauthorised page and Login route behavior @sneridagh [#5536](https://github.com/plone/volto/issues/5536)
38
+ - changed typo of pnp to pnpm. @ujjwaleee26 [#5537](https://github.com/plone/volto/issues/5537)
39
+
40
+ ### Internal
41
+
42
+ - Added Cypress tests to verify that the modal dialog for deleting linked items pops up correctly. @Molochem [#5529](https://github.com/plone/volto/issues/5529)
43
+ - Fix stylelint violations @sneridagh [#5544](https://github.com/plone/volto/issues/5544)
44
+ - Update Plone to 6.0.9 @sneridagh [#5562](https://github.com/plone/volto/issues/5562)
45
+
46
+ ### Documentation
47
+
48
+ - Removed outdated acceptance tests docs @sneridagh [#5533](https://github.com/plone/volto/issues/5533)
49
+ - Add `how-to-restrict-blocks` to `recipes/index.md`, avoiding Sphinx warning. @stevepiercy [#5546](https://github.com/plone/volto/issues/5546)
50
+ - Point developers to correct installation documentation, replaced yarn commands with pnpm, and correct some misspellings. We also now use includes to keep instructions consistent within Volto and in the main Plone 6 documentation. Finally the `NVM_VERSION` substitution now works. @ujjwaleee26 @stevepiercy [#5556](https://github.com/plone/volto/issues/5556)
51
+ - Fix redirects. @stevepiercy [#5563](https://github.com/plone/volto/issues/5563)
52
+ - Fix redirect for `nvm.fish`. @stevepiercy [#5569](https://github.com/plone/volto/issues/5569)
53
+
20
54
  ## 18.0.0-alpha.5 (2023-12-13)
21
55
 
22
56
  ### Feature
package/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  }
10
10
  ],
11
11
  "license": "MIT",
12
- "version": "18.0.0-alpha.5",
12
+ "version": "18.0.0-alpha.6",
13
13
  "repository": {
14
14
  "type": "git",
15
15
  "url": "git@github.com:plone/volto.git"
@@ -189,7 +189,7 @@
189
189
  "eslint-import-resolver-alias": "1.1.2",
190
190
  "eslint-import-resolver-babel-plugin-root-import": "1.1.1",
191
191
  "eslint-plugin-import": "2.28.1",
192
- "eslint-plugin-jsx-a11y": "6.7.1",
192
+ "eslint-plugin-jsx-a11y": "^6.7.1",
193
193
  "eslint-plugin-prettier": "5.0.0",
194
194
  "eslint-plugin-react": "7.33.2",
195
195
  "eslint-plugin-react-hooks": "4.6.0",
@@ -312,9 +312,9 @@
312
312
  "webpack-node-externals": "3.0.0",
313
313
  "xmlrpc": "1.3.2",
314
314
  "yarnhook": "0.5.1",
315
- "@plone/registry": "1.1.0",
316
- "@plone/scripts": "3.1.0",
317
- "@plone/volto-slate": "18.0.0-alpha.5"
315
+ "@plone/registry": "1.2.0",
316
+ "@plone/volto-slate": "18.0.0-alpha.5",
317
+ "@plone/scripts": "3.1.0"
318
318
  },
319
319
  "devDependencies": {
320
320
  "@jest/globals": "^29.7.0",
@@ -3,6 +3,7 @@ import { Icon } from '@plone/volto/components';
3
3
  import {
4
4
  blockHasValue,
5
5
  buildStyleClassNamesFromData,
6
+ buildStyleObjectFromData,
6
7
  } from '@plone/volto/helpers';
7
8
  import dragSVG from '@plone/volto/icons/drag.svg';
8
9
  import { Button } from 'semantic-ui-react';
@@ -57,7 +58,8 @@ const EditBlockWrapper = (props) => {
57
58
  ? data.required
58
59
  : includes(config.blocks.requiredBlocks, type);
59
60
 
60
- const styles = buildStyleClassNamesFromData(data.styles);
61
+ const classNames = buildStyleClassNamesFromData(data.styles);
62
+ const style = buildStyleObjectFromData(data.styles);
61
63
 
62
64
  return (
63
65
  <div
@@ -66,9 +68,10 @@ const EditBlockWrapper = (props) => {
66
68
  // Right now, we can have the alignment information in the styles property or in the
67
69
  // block data root, we inject the classname here for having control over the whole
68
70
  // Block Edit wrapper
69
- className={cx(`block-editor-${data['@type']}`, styles, {
71
+ className={cx(`block-editor-${data['@type']}`, classNames, {
70
72
  [data.align]: data.align,
71
73
  })}
74
+ style={style}
72
75
  >
73
76
  <div style={{ position: 'relative' }}>
74
77
  <div
@@ -3,10 +3,12 @@ import cx from 'classnames';
3
3
  import {
4
4
  buildStyleClassNamesFromData,
5
5
  buildStyleClassNamesExtenders,
6
+ buildStyleObjectFromData,
6
7
  } from '@plone/volto/helpers';
7
8
 
8
9
  const StyleWrapper = (props) => {
9
- let classNames = [];
10
+ let classNames,
11
+ style = [];
10
12
  const { children, content, data = {}, block } = props;
11
13
  classNames = buildStyleClassNamesFromData(data.styles);
12
14
 
@@ -16,11 +18,15 @@ const StyleWrapper = (props) => {
16
18
  data,
17
19
  classNames,
18
20
  });
21
+
22
+ style = buildStyleObjectFromData(data.styles);
23
+
19
24
  const rewrittenChildren = React.Children.map(children, (child) => {
20
25
  if (React.isValidElement(child)) {
21
26
  const childProps = {
22
27
  ...props,
23
28
  className: cx([child.props.className, ...classNames]),
29
+ style: { ...child.props.style, ...style },
24
30
  };
25
31
  return React.cloneElement(child, childProps);
26
32
  }
@@ -5,7 +5,7 @@ import { withBlockExtensions } from '@plone/volto/helpers';
5
5
  import config from '@plone/volto/registry';
6
6
 
7
7
  const GridBlockView = (props) => {
8
- const { data, path, className } = props;
8
+ const { data, path, className, style } = props;
9
9
  const metadata = props.metadata || props.properties;
10
10
  const columns = data.blocks_layout.items;
11
11
  const blocksConfig =
@@ -22,6 +22,7 @@ const GridBlockView = (props) => {
22
22
  three: columns?.length === 3,
23
23
  four: columns?.length === 4,
24
24
  })}
25
+ style={style}
25
26
  >
26
27
  {data.headline && <h2 className="headline">{data.headline}</h2>}
27
28
 
@@ -9,7 +9,7 @@ import {
9
9
  } from '@plone/volto/helpers';
10
10
  import config from '@plone/volto/registry';
11
11
 
12
- export const View = ({ className, data, detached, properties }) => {
12
+ export const View = ({ className, data, detached, properties, style }) => {
13
13
  const href = data?.href?.[0]?.['@id'] || '';
14
14
 
15
15
  const Image = config.getComponent({ name: 'Image' }).component;
@@ -25,6 +25,7 @@ export const View = ({ className, data, detached, properties }) => {
25
25
  data.align,
26
26
  className,
27
27
  )}
28
+ style={style}
28
29
  >
29
30
  {data.url && (
30
31
  <>
@@ -6,11 +6,12 @@ import { withBlockExtensions } from '@plone/volto/helpers';
6
6
  import { ListingBlockBody as ListingBody } from '@plone/volto/components';
7
7
 
8
8
  const View = (props) => {
9
- const { data, path, pathname, className } = props;
9
+ const { data, path, pathname, className, style } = props;
10
10
 
11
11
  return (
12
12
  <div
13
13
  className={cx('block listing', data.variation || 'default', className)}
14
+ style={style}
14
15
  >
15
16
  <ListingBody {...props} path={path ?? pathname} />
16
17
  </div>
@@ -96,9 +96,21 @@ function normalizeState({
96
96
  const { types: facetWidgetTypes } =
97
97
  config.blocks.blocksConfig.search.extensions.facetWidgets;
98
98
 
99
+ // Here, we are removing the QueryString of the Listing ones, which is present in the Facet
100
+ // because we already initialize the facet with those values.
101
+ const configuredFacets = facetSettings
102
+ ? facetSettings.map((facet) => facet?.field?.value)
103
+ : [];
104
+
105
+ let copyOfQuery = query.query ? [...query.query] : [];
106
+
107
+ const queryWithoutFacet = copyOfQuery.filter((query) => {
108
+ return !configuredFacets.includes(query.i);
109
+ });
110
+
99
111
  const params = {
100
112
  query: [
101
- ...(query.query || []),
113
+ ...(queryWithoutFacet || []),
102
114
  ...(facetSettings || []).map((facet) => {
103
115
  if (!facet?.field) return null;
104
116
 
@@ -261,14 +273,34 @@ const withSearch = (options) => (WrappedComponent) => {
261
273
  // eslint-disable-next-line react-hooks/exhaustive-deps
262
274
  const configuredFacets =
263
275
  data.facets?.map((facet) => facet?.field?.value) || [];
276
+
277
+ // Here we are getting the initial value of the facet if Listing Query contains the same criteria as
278
+ // facet.
279
+ const queryData = data?.query?.query
280
+ ? deserializeQuery(JSON.stringify(data?.query?.query))
281
+ : [];
282
+
283
+ let intializeFacetWithQueryValue = [];
284
+
285
+ for (let value of configuredFacets) {
286
+ const queryString = queryData.find((item) => item.i === value);
287
+ if (queryString) {
288
+ intializeFacetWithQueryValue = [
289
+ ...intializeFacetWithQueryValue,
290
+ { [queryString.i]: queryString.v },
291
+ ];
292
+ }
293
+ }
294
+
264
295
  const multiFacets = data.facets
265
296
  ?.filter((facet) => facet?.multiple)
266
297
  .map((facet) => facet?.field?.value);
267
298
  const [facets, setFacets] = React.useState(
268
299
  Object.assign(
269
300
  {},
270
- ...urlQuery.map(({ i, v }) => ({ [i]: v })), // TODO: the 'o' should be kept. This would be a major refactoring of the facets
271
-
301
+ ...urlQuery.map(({ i, v }) => ({ [i]: v })),
302
+ // TODO: the 'o' should be kept. This would be a major refactoring of the facets
303
+ ...intializeFacetWithQueryValue,
272
304
  // support for simple filters like ?Subject=something
273
305
  // TODO: since the move to hash params this is no longer working.
274
306
  // We'd have to treat the location.search and manage it just like the
@@ -288,8 +320,17 @@ const withSearch = (options) => (WrappedComponent) => {
288
320
  );
289
321
  const previousUrlQuery = usePrevious(urlQuery);
290
322
 
323
+ // During first render the previousUrlQuery is undefined and urlQuery
324
+ // is empty so it ressetting the facet when you are navigating but during reload we have urlQuery and we need
325
+ // to set the facet at first render.
326
+ const preventOverrideOfFacetState =
327
+ previousUrlQuery === undefined && urlQuery.length === 0;
328
+
291
329
  React.useEffect(() => {
292
- if (!isEqual(urlQuery, previousUrlQuery)) {
330
+ if (
331
+ !isEqual(urlQuery, previousUrlQuery) &&
332
+ !preventOverrideOfFacetState
333
+ ) {
293
334
  setFacets(
294
335
  Object.assign(
295
336
  {},
@@ -319,6 +360,7 @@ const withSearch = (options) => (WrappedComponent) => {
319
360
  locationSearchData,
320
361
  multiFacets,
321
362
  previousUrlQuery,
363
+ preventOverrideOfFacetState,
322
364
  ]);
323
365
 
324
366
  const [sortOn, setSortOn] = React.useState(data?.query?.sort_on);
@@ -18,7 +18,7 @@ const messages = defineMessages({
18
18
  });
19
19
 
20
20
  const TeaserDefaultTemplate = (props) => {
21
- const { className, data, isEditMode } = props;
21
+ const { className, data, isEditMode, style } = props;
22
22
  const intl = useIntl();
23
23
  const href = data.href?.[0];
24
24
  const image = data.preview_image?.[0];
@@ -27,7 +27,7 @@ const TeaserDefaultTemplate = (props) => {
27
27
  const { openExternalLinkInNewTab } = config.settings;
28
28
 
29
29
  return (
30
- <div className={cx('block teaser', className)}>
30
+ <div className={cx('block teaser', className)} style={style}>
31
31
  <>
32
32
  {!href && isEditMode && (
33
33
  <Message>
@@ -9,7 +9,7 @@ const DEFAULT_TIMEOUT = 500;
9
9
  const SidebarPopup = (props) => {
10
10
  const { children, open, onClose, overlay } = props;
11
11
 
12
- const asideElement = React.createRef();
12
+ const asideElement = React.useRef();
13
13
 
14
14
  const handleClickOutside = (e) => {
15
15
  if (asideElement && doesNodeContainClick(asideElement.current, e)) return;
@@ -60,6 +60,7 @@ const CommentEditModal = (props) => {
60
60
  text: {
61
61
  title: intl.formatMessage(messages.comment),
62
62
  type: 'string',
63
+ widget: 'textarea',
63
64
  description: '',
64
65
  },
65
66
  },
@@ -77,8 +77,9 @@ const Login = (props) => {
77
77
  qs.parse(props.location?.search ?? location.search).return_url ||
78
78
  location.pathname.replace(/\/login\/?$/, '').replace(/\/logout\/?$/, '') ||
79
79
  '/';
80
+
80
81
  useEffect(() => {
81
- if (token && !props.isLogout) {
82
+ if (token && !(props.isLogout || location?.state?.isLogout)) {
82
83
  history.push(returnUrl || '/');
83
84
  if (toast.isActive('loggedOut')) {
84
85
  toast.dismiss('loggedOut');
@@ -108,7 +109,16 @@ const Login = (props) => {
108
109
  dispatch(resetLoginRequest());
109
110
  }
110
111
  };
111
- }, [dispatch, token, error, intl, history, returnUrl, props.isLogout]);
112
+ }, [
113
+ dispatch,
114
+ token,
115
+ error,
116
+ intl,
117
+ history,
118
+ returnUrl,
119
+ props.isLogout,
120
+ location?.state?.isLogout,
121
+ ]);
112
122
 
113
123
  const onLogin = (event) => {
114
124
  dispatch(
@@ -48,40 +48,42 @@ const Navigation = (props) => {
48
48
  }
49
49
  setisMobileMenuOpen(false);
50
50
  };
51
-
52
51
  return (
53
52
  <nav className="navigation" id="navigation" aria-label="Site">
54
- <div className="hamburger-wrapper mobile tablet only">
55
- <button
56
- className={cx('hamburger hamburger--spin', {
57
- 'is-active': isMobileMenuOpen,
58
- })}
59
- aria-label={
60
- isMobileMenuOpen
61
- ? intl.formatMessage(messages.closeMobileMenu, {
62
- type: type,
63
- })
64
- : intl.formatMessage(messages.openMobileMenu, {
65
- type: type,
66
- })
67
- }
68
- title={
69
- isMobileMenuOpen
70
- ? intl.formatMessage(messages.closeMobileMenu, {
71
- type: type,
72
- })
73
- : intl.formatMessage(messages.openMobileMenu, {
74
- type: type,
75
- })
76
- }
77
- type="button"
78
- onClick={toggleMobileMenu}
79
- >
80
- <span className="hamburger-box">
81
- <span className="hamburger-inner" />
82
- </span>
83
- </button>
84
- </div>
53
+ {items?.length ? (
54
+ <div className="hamburger-wrapper mobile tablet only">
55
+ <button
56
+ className={cx('hamburger hamburger--spin', {
57
+ 'is-active': isMobileMenuOpen,
58
+ })}
59
+ aria-label={
60
+ isMobileMenuOpen
61
+ ? intl.formatMessage(messages.closeMobileMenu, {
62
+ type: type,
63
+ })
64
+ : intl.formatMessage(messages.openMobileMenu, {
65
+ type: type,
66
+ })
67
+ }
68
+ title={
69
+ isMobileMenuOpen
70
+ ? intl.formatMessage(messages.closeMobileMenu, {
71
+ type: type,
72
+ })
73
+ : intl.formatMessage(messages.openMobileMenu, {
74
+ type: type,
75
+ })
76
+ }
77
+ type="button"
78
+ onClick={toggleMobileMenu}
79
+ >
80
+ <span className="hamburger-box">
81
+ <span className="hamburger-inner" />
82
+ </span>
83
+ </button>
84
+ </div>
85
+ ) : null}
86
+
85
87
  <Menu
86
88
  stackable
87
89
  pointing
@@ -11,12 +11,17 @@ function PreviewImage({ item, alt, image_field, showDefault = true, ...rest }) {
11
11
  const Image = config.getComponent({ name: 'Image' }).component;
12
12
 
13
13
  const image = (
14
- <Image item={item} image_field={image_field} alt={alt} {...rest} />
14
+ <Image
15
+ item={item}
16
+ image_field={image_field || item.image_field}
17
+ alt={alt}
18
+ {...rest}
19
+ />
15
20
  );
16
21
 
17
22
  if (!image && !showDefault) return null;
18
23
 
19
- if (image) {
24
+ if (image_field || item?.image_field) {
20
25
  return image;
21
26
  } else {
22
27
  return (
@@ -33,16 +33,16 @@ export function getSitemapPath(pathname = '', lang) {
33
33
  function Sitemap(props) {
34
34
  const {
35
35
  location: { pathname },
36
- language,
36
+ lang,
37
37
  getNavigation,
38
38
  } = props;
39
39
 
40
40
  useEffect(() => {
41
41
  const { settings } = config;
42
- const lang = settings.isMultilingual ? `${toBackendLang(language)}` : null;
43
- const path = getSitemapPath(pathname, lang);
42
+ const language = settings.isMultilingual ? `${toBackendLang(lang)}` : null;
43
+ const path = getSitemapPath(pathname, language);
44
44
  getNavigation(path, 4);
45
- }, [pathname, language, getNavigation]);
45
+ }, [pathname, lang, getNavigation]);
46
46
 
47
47
  const renderItems = (items) => {
48
48
  return (
@@ -3,6 +3,7 @@ import renderer from 'react-test-renderer';
3
3
  import configureStore from 'redux-mock-store';
4
4
  import { Provider } from 'react-intl-redux';
5
5
  import { MemoryRouter } from 'react-router-dom';
6
+ import config from '@plone/volto/registry';
6
7
 
7
8
  import { __test__ as Sitemap, getSitemapPath } from './Sitemap';
8
9
 
@@ -55,6 +56,57 @@ describe('Sitemap', () => {
55
56
  });
56
57
  });
57
58
 
59
+ describe('Sitemap in a multilingual site', () => {
60
+ beforeEach(() => {
61
+ config.settings.isMultilingual = true;
62
+ config.settings.supportedLanguages = ['en', 'es'];
63
+ });
64
+ it('renders a sitemap component', () => {
65
+ const store = mockStore({
66
+ navigation: {
67
+ url: 'http://localhost:8080/Plone/en/@navigation',
68
+ items: [
69
+ {
70
+ url: 'http://localhost:8080/Plone/en/page-1',
71
+ description: '',
72
+ items: [
73
+ {
74
+ url: 'http://localhost:8080/Plone/en/page-1/page-1-2',
75
+ description: '',
76
+ title: 'Page 1-2',
77
+ },
78
+ {
79
+ url: 'http://localhost:8080/Plone/en/page-1/page-1-3',
80
+ description: '',
81
+ title: 'Page 1-3',
82
+ },
83
+ ],
84
+ title: 'Page 1-3',
85
+ },
86
+ {
87
+ url: 'http://localhost:8080/Plone/en/page-2',
88
+ description: '',
89
+ title: 'Page 2',
90
+ },
91
+ ],
92
+ },
93
+ intl: {
94
+ locale: 'en',
95
+ messages: {},
96
+ },
97
+ });
98
+ const component = renderer.create(
99
+ <Provider store={store}>
100
+ <MemoryRouter>
101
+ <Sitemap location={{ pathname: '/en/' }} />
102
+ </MemoryRouter>
103
+ </Provider>,
104
+ );
105
+ const json = component.toJSON();
106
+ expect(json).toMatchSnapshot();
107
+ });
108
+ });
109
+
58
110
  describe('getSitemapPath', () => {
59
111
  it('accepts empty path', () => {
60
112
  expect(getSitemapPath('', null)).toBe('');
@@ -1,8 +1,3 @@
1
- /**
2
- * @module components/theme/Unauthorized/Unauthorized
3
- */
4
-
5
- import React from 'react';
6
1
  import { FormattedMessage } from 'react-intl';
7
2
  import { Link } from 'react-router-dom';
8
3
  import { Container } from 'semantic-ui-react';
@@ -11,11 +6,6 @@ import { useLocation } from 'react-router-dom';
11
6
  import { withServerErrorCode } from '@plone/volto/helpers/Utils/Utils';
12
7
  import { getBaseUrl } from '@plone/volto/helpers';
13
8
 
14
- /**
15
- * unauthorized function.
16
- * @function Unauthorized
17
- * @returns {string} Markup of the unauthorized page.
18
- */
19
9
  const Unauthorized = () => {
20
10
  const error_message = useSelector((state) => state.apierror?.message);
21
11
  let location = useLocation();
@@ -32,7 +22,18 @@ const Unauthorized = () => {
32
22
  defaultMessage="You are trying to access a protected resource, please {login} first."
33
23
  values={{
34
24
  login: (
35
- <Link to={`${getBaseUrl(location.pathname)}/login`}>
25
+ <Link
26
+ to={{
27
+ pathname: `${getBaseUrl(location.pathname)}/login`,
28
+ state: {
29
+ // This is needed to cover the use case of being logged in in
30
+ // another backend (eg. in development), having a token for
31
+ // localhost and try to use it, the login route has to know that
32
+ // it's the same as it comes from a logout
33
+ isLogout: true,
34
+ },
35
+ }}
36
+ >
36
37
  <FormattedMessage id="log in" defaultMessage="log in" />
37
38
  </Link>
38
39
  ),
@@ -563,7 +563,7 @@ export const styleToClassName = (key, value, prefix = '') => {
563
563
  };
564
564
 
565
565
  export const buildStyleClassNamesFromData = (obj = {}, prefix = '') => {
566
- // styles has the form:
566
+ // style wrapper object has the form:
567
567
  // const styles = {
568
568
  // color: 'red',
569
569
  // backgroundColor: '#AABBCC',
@@ -571,6 +571,7 @@ export const buildStyleClassNamesFromData = (obj = {}, prefix = '') => {
571
571
  // Returns: ['has--color--red', 'has--backgroundColor--AABBCC']
572
572
 
573
573
  return Object.entries(obj)
574
+ .filter(([k, v]) => !k.startsWith('--'))
574
575
  .reduce(
575
576
  (acc, [k, v]) => [
576
577
  ...acc,
@@ -602,6 +603,60 @@ export const buildStyleClassNamesExtenders = ({
602
603
  );
603
604
  };
604
605
 
606
+ /**
607
+ * Converts a name+value style pair (ex: color/red) to a pair of [k, v],
608
+ * such as ["color", "red"] so it can be converted back to an object.
609
+ * For now, only covering the 'CSSProperty' use case.
610
+ */
611
+ export const styleDataToStyleObject = (key, value, prefix = '') => {
612
+ if (prefix) {
613
+ return [`--${prefix}${key.replace('--', '')}`, value];
614
+ } else {
615
+ return [key, value];
616
+ }
617
+ };
618
+
619
+ /**
620
+ * Generate styles object from data
621
+ *
622
+ * @function buildStyleObjectFromData
623
+ * @param {Object} obj A style wrapper object data
624
+ * @param {string} prefix The prefix (could be dragged from a recursive call, initially empty)
625
+ * @return {Object} The style object ready to be passed as prop
626
+ */
627
+ export const buildStyleObjectFromData = (obj = {}, prefix = '') => {
628
+ // style wrapper object has the form:
629
+ // const styles = {
630
+ // color: 'red',
631
+ // '--background-color': '#AABBCC',
632
+ // }
633
+ // Returns: {'--background-color: '#AABBCC'}
634
+
635
+ return Object.fromEntries(
636
+ Object.entries(obj)
637
+ .filter(([k, v]) => k.startsWith('--') || isObject(v))
638
+ .reduce(
639
+ (acc, [k, v]) => [
640
+ ...acc,
641
+ // Kept for easy debugging
642
+ // ...(() => {
643
+ // if (isObject(v)) {
644
+ // return Object.entries(
645
+ // buildStyleObjectFromData(v, `${prefix}${k}--`),
646
+ // );
647
+ // }
648
+ // return [styleDataToStyleObject(k, v, prefix)];
649
+ // })(),
650
+ ...(isObject(v)
651
+ ? Object.entries(buildStyleObjectFromData(v, `${prefix}${k}--`))
652
+ : [styleDataToStyleObject(k, v, prefix)]),
653
+ ],
654
+ [],
655
+ )
656
+ .filter((v) => !!v),
657
+ );
658
+ };
659
+
605
660
  /**
606
661
  * Return previous/next blocks given the content object and the current block id
607
662
  *
@@ -18,6 +18,7 @@ import {
18
18
  applySchemaDefaults,
19
19
  buildStyleClassNamesFromData,
20
20
  buildStyleClassNamesExtenders,
21
+ buildStyleObjectFromData,
21
22
  getPreviousNextBlock,
22
23
  blocksFormGenerator,
23
24
  findBlocks,
@@ -1066,6 +1067,62 @@ describe('Blocks', () => {
1066
1067
  };
1067
1068
  expect(buildStyleClassNamesFromData(styles)).toEqual([]);
1068
1069
  });
1070
+
1071
+ it('It does not output any className for style converter values', () => {
1072
+ const styles = {
1073
+ color: 'red',
1074
+ '--background-color': '#FFF',
1075
+ };
1076
+ expect(buildStyleClassNamesFromData(styles)).toEqual(['has--color--red']);
1077
+ });
1078
+
1079
+ it.skip('It does not output any className for unknown converter values', () => {
1080
+ const styles = {
1081
+ color: 'red',
1082
+ 'backgroundColor:style': '#FFF',
1083
+ };
1084
+ expect(buildStyleClassNamesFromData(styles)).toEqual(['has--color--red']);
1085
+ });
1086
+ });
1087
+
1088
+ describe('buildStyleObjectFromData', () => {
1089
+ it('Understands style converter for style values, no styles found', () => {
1090
+ const styles = {
1091
+ color: 'red',
1092
+ backgroundColor: '#FFF',
1093
+ };
1094
+ expect(buildStyleObjectFromData(styles)).toEqual({});
1095
+ });
1096
+
1097
+ it('Understands style converter for style values', () => {
1098
+ const styles = {
1099
+ color: 'red',
1100
+ '--background-color': '#FFF',
1101
+ };
1102
+ expect(buildStyleObjectFromData(styles)).toEqual({
1103
+ '--background-color': '#FFF',
1104
+ });
1105
+ });
1106
+
1107
+ it('Supports multiple nested levels', () => {
1108
+ const styles = {
1109
+ '--color': 'red',
1110
+ backgroundColor: '#AABBCC',
1111
+ nested: {
1112
+ l1: 'white',
1113
+ '--foo': 'white',
1114
+ level2: {
1115
+ '--foo': '#fff',
1116
+ bar: '#000',
1117
+ },
1118
+ },
1119
+ };
1120
+ expect(buildStyleObjectFromData(styles)).toEqual({
1121
+ '--color': 'red',
1122
+ '--nested--foo': 'white',
1123
+ '--nested--level2--foo': '#fff',
1124
+ });
1125
+ });
1069
1126
  });
1070
1127
 
1071
1128
  describe('getPreviousNextBlock', () => {
@@ -58,6 +58,7 @@ export {
58
58
  blocksFormGenerator,
59
59
  buildStyleClassNamesFromData,
60
60
  buildStyleClassNamesExtenders,
61
+ buildStyleObjectFromData,
61
62
  getPreviousNextBlock,
62
63
  findBlocks,
63
64
  } from '@plone/volto/helpers/Blocks/Blocks';
@@ -784,10 +784,12 @@ body.has-toolbar.has-sidebar-collapsed .ui.wrapper > .ui.inner.block.full {
784
784
  // reseting the default Accordion behavior
785
785
  display: initial;
786
786
  }
787
+
787
788
  .ui.form .ui.input input {
788
789
  //reset for semantic-ui outline:none
789
790
  border-width: @1px;
790
791
  border-color: transparent;
792
+
791
793
  &:focus {
792
794
  border-color: @focusedFormBorderColor;
793
795
  }
@@ -131,9 +131,9 @@
131
131
  padding: 0;
132
132
  border: 0;
133
133
  background: transparent;
134
- text-align: initial;
135
134
  // we want buttons to have same font as the page font stack
136
135
  font-family: @pageFont;
136
+ text-align: initial;
137
137
 
138
138
  &:focus {
139
139
  outline: none;
@@ -201,6 +201,7 @@
201
201
  .item:focus-visible {
202
202
  outline: auto;
203
203
  }
204
+
204
205
  .item:first-child {
205
206
  margin-left: 45px;
206
207
  }