@eeacms/volto-eea-website-theme 3.5.5 → 3.6.1
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 +26 -1
- package/package.json +1 -1
- package/src/components/manage/Blocks/Title/variations/WebReport.test.jsx +134 -0
- package/src/components/theme/Widgets/ContributorsViewWidget.jsx +23 -0
- package/src/components/theme/Widgets/ContributorsViewWidget.test.jsx +60 -0
- package/src/components/theme/Widgets/CreatorsViewWidget.jsx +23 -0
- package/src/components/theme/Widgets/CreatorsViewWidget.test.jsx +60 -0
- package/src/components/theme/Widgets/ImageViewWidget.jsx +1 -1
- package/src/components/theme/Widgets/UserSelectWidget.jsx +331 -0
- package/src/components/theme/Widgets/UserSelectWidget.test.jsx +255 -0
- package/src/customizations/volto/actions/vocabularies/vocabularies.js +89 -0
- package/src/customizations/volto/actions/vocabularies/vocabularies.js.diff +32 -0
- package/src/customizations/volto/actions/vocabularies/vocabularies.js.md +4 -0
- package/src/customizations/volto/actions/vocabularies/vocabularies.test.js +57 -0
- package/src/customizations/volto/actions/vocabularies/vocabularies.test.js.diff +45 -0
- package/src/customizations/volto/components/manage/Blocks/LeadImage/View.jsx +1 -0
- package/src/hocs/withRootNavigation.test.jsx +70 -0
- package/src/index.js +9 -3
- package/src/index.test.js +6 -0
package/CHANGELOG.md
CHANGED
@@ -4,7 +4,32 @@ All notable changes to this project will be documented in this file. Dates are d
|
|
4
4
|
|
5
5
|
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
6
6
|
|
7
|
-
### [3.
|
7
|
+
### [3.6.1](https://github.com/eea/volto-eea-website-theme/compare/3.6.0...3.6.1) - 7 May 2025
|
8
|
+
|
9
|
+
#### :nail_care: Enhancements
|
10
|
+
|
11
|
+
- change(item): added one column for flex-group refs #279420 [David Ichim - [`139f614`](https://github.com/eea/volto-eea-website-theme/commit/139f614658ef431f9b347a851d2ad6c015d5031e)]
|
12
|
+
- change(lead-image): add lazy loading for better performance when using LeadImage [David Ichim - [`a388f7f`](https://github.com/eea/volto-eea-website-theme/commit/a388f7fa3d33de5e14f4258e76fcab9cbce57af5)]
|
13
|
+
|
14
|
+
#### :house: Internal changes
|
15
|
+
|
16
|
+
- chore(tests): fix unittests [David Ichim - [`db15cde`](https://github.com/eea/volto-eea-website-theme/commit/db15cde41e2528d7afcc7ef2556499ea5d74a3e3)]
|
17
|
+
|
18
|
+
#### :hammer_and_wrench: Others
|
19
|
+
|
20
|
+
- fix uuid mock from previous commit [David Ichim - [`30fac90`](https://github.com/eea/volto-eea-website-theme/commit/30fac9067b19ed260649c13ade283132b2e660dd)]
|
21
|
+
### [3.6.0](https://github.com/eea/volto-eea-website-theme/compare/3.5.5...3.6.0) - 30 April 2025
|
22
|
+
|
23
|
+
#### :bug: Bug Fixes
|
24
|
+
|
25
|
+
- fix: Create widgets for creators and contributors full name - refs #284007 [Teodor Voicu - [`a3f73ae`](https://github.com/eea/volto-eea-website-theme/commit/a3f73ae02a6a8f92793d7e747e7ba9bbe1f51e05)]
|
26
|
+
- fix: Add email tooltip for users in creators and contributors metadata - refs #274362 [Teodor Voicu - [`f00d2bc`](https://github.com/eea/volto-eea-website-theme/commit/f00d2bcb5c0115db40d95f7e4d9618ce7164b71a)]
|
27
|
+
|
28
|
+
#### :hammer_and_wrench: Others
|
29
|
+
|
30
|
+
- Release 3.6.0 [Alin Voinea - [`645d434`](https://github.com/eea/volto-eea-website-theme/commit/645d434318c3cf5a0f1765fa0aa539cb2ecedd74)]
|
31
|
+
- Update ImageViewWidget [Miu Razvan - [`7b574be`](https://github.com/eea/volto-eea-website-theme/commit/7b574beac1ef64f1ec54c8f42b4c907291d182ea)]
|
32
|
+
### [3.5.5](https://github.com/eea/volto-eea-website-theme/compare/3.5.4...3.5.5) - 25 April 2025
|
8
33
|
|
9
34
|
#### :bug: Bug Fixes
|
10
35
|
|
package/package.json
CHANGED
@@ -0,0 +1,134 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import '@testing-library/jest-dom';
|
3
|
+
import { render, screen } from '@testing-library/react';
|
4
|
+
import WebReport from './WebReport';
|
5
|
+
|
6
|
+
// Mock Portal since we are not in real DOM
|
7
|
+
jest.mock('react-portal', () => ({
|
8
|
+
Portal: ({ children }) => <div data-testid="portal">{children}</div>,
|
9
|
+
}));
|
10
|
+
|
11
|
+
jest.mock('@plone/volto/helpers', () => ({
|
12
|
+
BodyClass: ({ className }) => (
|
13
|
+
<div data-testid="body-class" className={className} />
|
14
|
+
),
|
15
|
+
}));
|
16
|
+
|
17
|
+
jest.mock('@plone/volto/components', () => ({
|
18
|
+
MaybeWrap: ({ condition, as: As, children }) =>
|
19
|
+
condition ? <As>{children}</As> : children,
|
20
|
+
}));
|
21
|
+
|
22
|
+
jest.mock(
|
23
|
+
'@eeacms/volto-eea-website-theme/components/theme/Banner/View',
|
24
|
+
() => (props) => (
|
25
|
+
<div data-testid="banner-view">
|
26
|
+
{props.data.aboveTitle}
|
27
|
+
{props.data.belowTitle}
|
28
|
+
</div>
|
29
|
+
),
|
30
|
+
);
|
31
|
+
|
32
|
+
jest.mock('@eeacms/volto-eea-design-system/ui/Banner/Banner', () => {
|
33
|
+
const Subtitle = ({ children }) => (
|
34
|
+
<div data-testid="banner-subtitle">{children}</div>
|
35
|
+
);
|
36
|
+
return {
|
37
|
+
Subtitle,
|
38
|
+
};
|
39
|
+
});
|
40
|
+
|
41
|
+
describe('WebReport', () => {
|
42
|
+
it('renders with content type and subtitle', () => {
|
43
|
+
const props = {
|
44
|
+
isEditMode: false,
|
45
|
+
data: {
|
46
|
+
hero_header: true,
|
47
|
+
content_type: 'Report',
|
48
|
+
subtitle: 'This is a subtitle',
|
49
|
+
},
|
50
|
+
properties: {
|
51
|
+
type_title: 'Fallback Title',
|
52
|
+
},
|
53
|
+
};
|
54
|
+
|
55
|
+
render(<WebReport {...props} />);
|
56
|
+
|
57
|
+
// Check portal wrapping
|
58
|
+
expect(screen.getByTestId('portal')).toBeInTheDocument();
|
59
|
+
|
60
|
+
// Check BodyClass applied
|
61
|
+
expect(screen.getByTestId('body-class')).toHaveClass(
|
62
|
+
'homepage-inverse',
|
63
|
+
'homepage-header',
|
64
|
+
'light-header',
|
65
|
+
'hero-header',
|
66
|
+
);
|
67
|
+
|
68
|
+
// Check BannerView rendered
|
69
|
+
expect(screen.getByTestId('banner-view')).toBeInTheDocument();
|
70
|
+
|
71
|
+
// Content Type shown
|
72
|
+
expect(screen.getByText('Report')).toBeInTheDocument();
|
73
|
+
|
74
|
+
// Subtitle shown
|
75
|
+
expect(screen.getByText('This is a subtitle')).toBeInTheDocument();
|
76
|
+
});
|
77
|
+
|
78
|
+
it('renders fallback type_title when content_type missing', () => {
|
79
|
+
const props = {
|
80
|
+
isEditMode: false,
|
81
|
+
data: {
|
82
|
+
hero_header: true,
|
83
|
+
subtitle: 'Another subtitle',
|
84
|
+
},
|
85
|
+
properties: {
|
86
|
+
type_title: 'Fallback Title',
|
87
|
+
},
|
88
|
+
};
|
89
|
+
|
90
|
+
render(<WebReport {...props} />);
|
91
|
+
|
92
|
+
// Fallback title used
|
93
|
+
expect(screen.getByText('Fallback Title')).toBeInTheDocument();
|
94
|
+
});
|
95
|
+
|
96
|
+
it('hides content type if hideContentType is true', () => {
|
97
|
+
const props = {
|
98
|
+
isEditMode: false,
|
99
|
+
data: {
|
100
|
+
hero_header: false,
|
101
|
+
hideContentType: true,
|
102
|
+
subtitle: 'Hidden subtitle',
|
103
|
+
},
|
104
|
+
properties: {
|
105
|
+
type_title: 'Hidden Title',
|
106
|
+
},
|
107
|
+
};
|
108
|
+
|
109
|
+
render(<WebReport {...props} />);
|
110
|
+
|
111
|
+
// Should NOT find content type div
|
112
|
+
expect(screen.queryByText('Hidden Title')).not.toBeInTheDocument();
|
113
|
+
});
|
114
|
+
|
115
|
+
it('renders directly without portal in edit mode', () => {
|
116
|
+
const props = {
|
117
|
+
isEditMode: true,
|
118
|
+
data: {
|
119
|
+
subtitle: 'Edit mode subtitle',
|
120
|
+
},
|
121
|
+
properties: {
|
122
|
+
type_title: 'Edit Title',
|
123
|
+
},
|
124
|
+
};
|
125
|
+
|
126
|
+
render(<WebReport {...props} />);
|
127
|
+
|
128
|
+
// Should not wrap with Portal when in edit mode
|
129
|
+
expect(screen.queryByTestId('portal')).not.toBeInTheDocument();
|
130
|
+
|
131
|
+
// Banner still rendered
|
132
|
+
expect(screen.getByTestId('banner-view')).toBeInTheDocument();
|
133
|
+
});
|
134
|
+
});
|
@@ -0,0 +1,23 @@
|
|
1
|
+
import cx from 'classnames';
|
2
|
+
|
3
|
+
const ContributorsViewWidget = ({ value, content, children, className }) => {
|
4
|
+
const resolvedValue = content?.contributors_fullname || value || [];
|
5
|
+
return resolvedValue ? (
|
6
|
+
<span className={cx(className, 'array', 'widget')}>
|
7
|
+
{resolvedValue.map((item, key) => (
|
8
|
+
<>
|
9
|
+
{key ? ', ' : ''}
|
10
|
+
<span key={item.token || item.title || item}>
|
11
|
+
{children
|
12
|
+
? children(item.title || item.token || item)
|
13
|
+
: item.title || item.token || item}
|
14
|
+
</span>
|
15
|
+
</>
|
16
|
+
))}
|
17
|
+
</span>
|
18
|
+
) : (
|
19
|
+
''
|
20
|
+
);
|
21
|
+
};
|
22
|
+
|
23
|
+
export default ContributorsViewWidget;
|
@@ -0,0 +1,60 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import renderer from 'react-test-renderer';
|
3
|
+
import ContributorsViewWidget from './ContributorsViewWidget';
|
4
|
+
|
5
|
+
describe('ContributorsViewWidget', () => {
|
6
|
+
it('renders an empty array view widget component', () => {
|
7
|
+
const component = renderer.create(<ContributorsViewWidget />);
|
8
|
+
const json = component.toJSON();
|
9
|
+
expect(json).toMatchSnapshot();
|
10
|
+
});
|
11
|
+
|
12
|
+
it('renders a simple array view widget component', () => {
|
13
|
+
const component = renderer.create(
|
14
|
+
<ContributorsViewWidget className="metadata" value={['foo', 'bar']} />,
|
15
|
+
);
|
16
|
+
const json = component.toJSON();
|
17
|
+
expect(json).toMatchSnapshot();
|
18
|
+
});
|
19
|
+
|
20
|
+
it('renders a vocabulary array view widget component', () => {
|
21
|
+
const component = renderer.create(
|
22
|
+
<ContributorsViewWidget
|
23
|
+
className="metadata"
|
24
|
+
value={[{ title: 'Foo' }, { title: 'Bar' }]}
|
25
|
+
/>,
|
26
|
+
);
|
27
|
+
const json = component.toJSON();
|
28
|
+
expect(json).toMatchSnapshot();
|
29
|
+
});
|
30
|
+
|
31
|
+
it('renders a full vocabulary array view widget component', () => {
|
32
|
+
const component = renderer.create(
|
33
|
+
<ContributorsViewWidget
|
34
|
+
className="metadata"
|
35
|
+
value={[
|
36
|
+
{ title: 'Foo', token: 'foo' },
|
37
|
+
{ title: 'Bar', token: 'bar' },
|
38
|
+
]}
|
39
|
+
/>,
|
40
|
+
);
|
41
|
+
const json = component.toJSON();
|
42
|
+
expect(json).toMatchSnapshot();
|
43
|
+
});
|
44
|
+
|
45
|
+
it('renders a full vocabulary array view widget component with children', () => {
|
46
|
+
const component = renderer.create(
|
47
|
+
<ContributorsViewWidget
|
48
|
+
className="metadata"
|
49
|
+
value={[
|
50
|
+
{ title: 'Foo', token: 'foo' },
|
51
|
+
{ title: 'Bar', token: 'bar' },
|
52
|
+
]}
|
53
|
+
>
|
54
|
+
{(child) => <strong>{child}</strong>}
|
55
|
+
</ContributorsViewWidget>,
|
56
|
+
);
|
57
|
+
const json = component.toJSON();
|
58
|
+
expect(json).toMatchSnapshot();
|
59
|
+
});
|
60
|
+
});
|
@@ -0,0 +1,23 @@
|
|
1
|
+
import cx from 'classnames';
|
2
|
+
|
3
|
+
const CreatorsViewWidget = ({ value, content, children, className }) => {
|
4
|
+
const resolvedValue = content?.creators_fullname || value || [];
|
5
|
+
return resolvedValue ? (
|
6
|
+
<span className={cx(className, 'array', 'widget')}>
|
7
|
+
{resolvedValue.map((item, key) => (
|
8
|
+
<>
|
9
|
+
{key ? ', ' : ''}
|
10
|
+
<span key={item.token || item.title || item}>
|
11
|
+
{children
|
12
|
+
? children(item.title || item.token || item)
|
13
|
+
: item.title || item.token || item}
|
14
|
+
</span>
|
15
|
+
</>
|
16
|
+
))}
|
17
|
+
</span>
|
18
|
+
) : (
|
19
|
+
''
|
20
|
+
);
|
21
|
+
};
|
22
|
+
|
23
|
+
export default CreatorsViewWidget;
|
@@ -0,0 +1,60 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import renderer from 'react-test-renderer';
|
3
|
+
import CreatorsViewWidget from './CreatorsViewWidget';
|
4
|
+
|
5
|
+
describe('CreatorsViewWidget', () => {
|
6
|
+
it('renders an empty array view widget component', () => {
|
7
|
+
const component = renderer.create(<CreatorsViewWidget />);
|
8
|
+
const json = component.toJSON();
|
9
|
+
expect(json).toMatchSnapshot();
|
10
|
+
});
|
11
|
+
|
12
|
+
it('renders a simple array view widget component', () => {
|
13
|
+
const component = renderer.create(
|
14
|
+
<CreatorsViewWidget className="metadata" value={['foo', 'bar']} />,
|
15
|
+
);
|
16
|
+
const json = component.toJSON();
|
17
|
+
expect(json).toMatchSnapshot();
|
18
|
+
});
|
19
|
+
|
20
|
+
it('renders a vocabulary array view widget component', () => {
|
21
|
+
const component = renderer.create(
|
22
|
+
<CreatorsViewWidget
|
23
|
+
className="metadata"
|
24
|
+
value={[{ title: 'Foo' }, { title: 'Bar' }]}
|
25
|
+
/>,
|
26
|
+
);
|
27
|
+
const json = component.toJSON();
|
28
|
+
expect(json).toMatchSnapshot();
|
29
|
+
});
|
30
|
+
|
31
|
+
it('renders a full vocabulary array view widget component', () => {
|
32
|
+
const component = renderer.create(
|
33
|
+
<CreatorsViewWidget
|
34
|
+
className="metadata"
|
35
|
+
value={[
|
36
|
+
{ title: 'Foo', token: 'foo' },
|
37
|
+
{ title: 'Bar', token: 'bar' },
|
38
|
+
]}
|
39
|
+
/>,
|
40
|
+
);
|
41
|
+
const json = component.toJSON();
|
42
|
+
expect(json).toMatchSnapshot();
|
43
|
+
});
|
44
|
+
|
45
|
+
it('renders a full vocabulary array view widget component with children', () => {
|
46
|
+
const component = renderer.create(
|
47
|
+
<CreatorsViewWidget
|
48
|
+
className="metadata"
|
49
|
+
value={[
|
50
|
+
{ title: 'Foo', token: 'foo' },
|
51
|
+
{ title: 'Bar', token: 'bar' },
|
52
|
+
]}
|
53
|
+
>
|
54
|
+
{(child) => <strong>{child}</strong>}
|
55
|
+
</CreatorsViewWidget>,
|
56
|
+
);
|
57
|
+
const json = component.toJSON();
|
58
|
+
expect(json).toMatchSnapshot();
|
59
|
+
});
|
60
|
+
});
|
@@ -0,0 +1,331 @@
|
|
1
|
+
/**
|
2
|
+
* UserSelectWidget component.
|
3
|
+
* @module components/manage/Widgets/UserSelectWidget
|
4
|
+
*/
|
5
|
+
|
6
|
+
import React, { Component } from 'react';
|
7
|
+
import { defineMessages, injectIntl } from 'react-intl';
|
8
|
+
import PropTypes from 'prop-types';
|
9
|
+
import { compose } from 'redux';
|
10
|
+
import { connect } from 'react-redux';
|
11
|
+
import { injectLazyLibs } from '@plone/volto/helpers/Loadable/Loadable';
|
12
|
+
import { Popup } from 'semantic-ui-react';
|
13
|
+
import {
|
14
|
+
normalizeValue,
|
15
|
+
convertValueToVocabQuery,
|
16
|
+
} from '@plone/volto/components/manage/Widgets/SelectUtils';
|
17
|
+
import checkSVG from '@plone/volto/icons/check.svg';
|
18
|
+
import checkBlankSVG from '@plone/volto/icons/check-blank.svg';
|
19
|
+
import { Icon } from '@plone/volto/components';
|
20
|
+
|
21
|
+
import {
|
22
|
+
getVocabFromHint,
|
23
|
+
getVocabFromField,
|
24
|
+
getVocabFromItems,
|
25
|
+
} from '@plone/volto/helpers';
|
26
|
+
import { getVocabulary, getVocabularyTokenTitle } from '@plone/volto/actions';
|
27
|
+
|
28
|
+
import {
|
29
|
+
ClearIndicator,
|
30
|
+
DropdownIndicator,
|
31
|
+
MultiValueContainer,
|
32
|
+
selectTheme,
|
33
|
+
customSelectStyles,
|
34
|
+
MenuList,
|
35
|
+
} from '@plone/volto/components/manage/Widgets/SelectStyling';
|
36
|
+
|
37
|
+
import { FormFieldWrapper } from '@plone/volto/components';
|
38
|
+
|
39
|
+
const messages = defineMessages({
|
40
|
+
select: {
|
41
|
+
id: 'Select…',
|
42
|
+
defaultMessage: 'Select…',
|
43
|
+
},
|
44
|
+
no_options: {
|
45
|
+
id: 'No options',
|
46
|
+
defaultMessage: 'No options',
|
47
|
+
},
|
48
|
+
type_text: {
|
49
|
+
id: 'Type text...',
|
50
|
+
defaultMessage: 'Type text...',
|
51
|
+
},
|
52
|
+
});
|
53
|
+
|
54
|
+
export const normalizeSingleSelectOption = (value, intl) => {
|
55
|
+
if (!value) return value;
|
56
|
+
|
57
|
+
if (Array.isArray(value)) {
|
58
|
+
// Assuming [token, title] pair.
|
59
|
+
if (value.length === 2)
|
60
|
+
return { value: value[0], label: value[1] || value[0], email: '' };
|
61
|
+
|
62
|
+
throw new Error(`Unknown value type of select widget: ${value}`);
|
63
|
+
}
|
64
|
+
|
65
|
+
const token = value.token ?? value.value ?? value.UID ?? 'no-value';
|
66
|
+
const label =
|
67
|
+
(value.title && value.title !== 'None' ? value.title : undefined) ??
|
68
|
+
value.label ??
|
69
|
+
value.token ??
|
70
|
+
intl.formatMessage(messages.no_value);
|
71
|
+
return {
|
72
|
+
value: token,
|
73
|
+
label,
|
74
|
+
email: value.email ? value.email : label || token,
|
75
|
+
};
|
76
|
+
};
|
77
|
+
|
78
|
+
export const normalizeChoices = (items, intl) =>
|
79
|
+
items.map((item) => normalizeSingleSelectOption(item, intl));
|
80
|
+
|
81
|
+
/**
|
82
|
+
* Custom Option component with a tooltip
|
83
|
+
*/
|
84
|
+
const CustomOption = injectLazyLibs('reactSelect')((props) => {
|
85
|
+
const { Option } = props.reactSelect.components;
|
86
|
+
const color = props.isFocused && !props.isSelected ? '#b8c6c8' : '#007bc1';
|
87
|
+
const svgIcon =
|
88
|
+
props.isFocused || props.isSelected ? checkSVG : checkBlankSVG;
|
89
|
+
|
90
|
+
const { data, innerRef, innerProps } = props;
|
91
|
+
const { label, email } = data;
|
92
|
+
|
93
|
+
return (
|
94
|
+
<Option {...props}>
|
95
|
+
<div ref={innerRef} {...innerProps}>
|
96
|
+
<Popup
|
97
|
+
content={email}
|
98
|
+
position="top center"
|
99
|
+
trigger={
|
100
|
+
<div
|
101
|
+
style={{
|
102
|
+
display: 'flex',
|
103
|
+
alignItems: 'center',
|
104
|
+
justifyContent: 'space-between',
|
105
|
+
gap: '0.5rem',
|
106
|
+
}}
|
107
|
+
>
|
108
|
+
<span>{label}</span>
|
109
|
+
<Icon name={svgIcon} size="20px" color={color} />
|
110
|
+
</div>
|
111
|
+
}
|
112
|
+
/>
|
113
|
+
</div>
|
114
|
+
</Option>
|
115
|
+
);
|
116
|
+
});
|
117
|
+
|
118
|
+
/**
|
119
|
+
* UserSelectWidget component class.
|
120
|
+
* @class UserSelectWidget
|
121
|
+
* @extends Component
|
122
|
+
*/
|
123
|
+
class UserSelectWidget extends Component {
|
124
|
+
static propTypes = {
|
125
|
+
id: PropTypes.string.isRequired,
|
126
|
+
title: PropTypes.string.isRequired,
|
127
|
+
description: PropTypes.string,
|
128
|
+
required: PropTypes.bool,
|
129
|
+
error: PropTypes.arrayOf(PropTypes.string),
|
130
|
+
getVocabulary: PropTypes.func.isRequired,
|
131
|
+
choices: PropTypes.arrayOf(
|
132
|
+
PropTypes.oneOfType([PropTypes.object, PropTypes.array]),
|
133
|
+
),
|
134
|
+
items: PropTypes.shape({
|
135
|
+
vocabulary: PropTypes.object,
|
136
|
+
}),
|
137
|
+
widgetOptions: PropTypes.shape({
|
138
|
+
vocabulary: PropTypes.object,
|
139
|
+
}),
|
140
|
+
value: PropTypes.arrayOf(
|
141
|
+
PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
|
142
|
+
),
|
143
|
+
onChange: PropTypes.func.isRequired,
|
144
|
+
wrapped: PropTypes.bool,
|
145
|
+
isDisabled: PropTypes.bool,
|
146
|
+
placeholder: PropTypes.string,
|
147
|
+
};
|
148
|
+
|
149
|
+
static defaultProps = {
|
150
|
+
description: null,
|
151
|
+
required: false,
|
152
|
+
items: {
|
153
|
+
vocabulary: null,
|
154
|
+
},
|
155
|
+
widgetOptions: {
|
156
|
+
vocabulary: null,
|
157
|
+
},
|
158
|
+
error: [],
|
159
|
+
choices: [],
|
160
|
+
value: null,
|
161
|
+
};
|
162
|
+
|
163
|
+
constructor(props) {
|
164
|
+
super(props);
|
165
|
+
this.handleChange = this.handleChange.bind(this);
|
166
|
+
this.state = {
|
167
|
+
searchLength: 0,
|
168
|
+
termsPairsCache: [],
|
169
|
+
};
|
170
|
+
}
|
171
|
+
|
172
|
+
componentDidMount() {
|
173
|
+
const { id, lang, value, choices } = this.props;
|
174
|
+
if (value && value?.length > 0) {
|
175
|
+
const tokensQuery = convertValueToVocabQuery(
|
176
|
+
normalizeValue(choices, value, this.props.intl),
|
177
|
+
);
|
178
|
+
|
179
|
+
this.props.getVocabularyTokenTitle({
|
180
|
+
vocabNameOrURL: this.props.vocabBaseUrl,
|
181
|
+
subrequest: `widget-${id}-${lang}`,
|
182
|
+
...tokensQuery,
|
183
|
+
});
|
184
|
+
}
|
185
|
+
}
|
186
|
+
|
187
|
+
componentDidUpdate(prevProps, prevState) {
|
188
|
+
const { value, choices } = this.props;
|
189
|
+
if (
|
190
|
+
this.state.termsPairsCache.length === 0 &&
|
191
|
+
value?.length > 0 &&
|
192
|
+
choices?.length > 0 &&
|
193
|
+
(value !== prevProps.value || choices !== prevProps.choices)
|
194
|
+
) {
|
195
|
+
this.setState((state) => ({
|
196
|
+
termsPairsCache: [...state.termsPairsCache, ...choices],
|
197
|
+
}));
|
198
|
+
}
|
199
|
+
}
|
200
|
+
|
201
|
+
componentWillUnmount() {
|
202
|
+
if (this.timeoutRef.current) {
|
203
|
+
clearTimeout(this.timeoutRef.current);
|
204
|
+
}
|
205
|
+
}
|
206
|
+
|
207
|
+
/**
|
208
|
+
* Handle the field change, store it in the local state and back to simple
|
209
|
+
* array of tokens for correct serialization
|
210
|
+
* @method handleChange
|
211
|
+
* @param {array} selectedOption The selected options (already aggregated).
|
212
|
+
* @returns {undefined}
|
213
|
+
*/
|
214
|
+
handleChange(selectedOption) {
|
215
|
+
this.props.onChange(
|
216
|
+
this.props.id,
|
217
|
+
selectedOption ? selectedOption.map((item) => item.value) : null,
|
218
|
+
);
|
219
|
+
this.setState((state) => ({
|
220
|
+
termsPairsCache: [...state.termsPairsCache, ...selectedOption],
|
221
|
+
}));
|
222
|
+
}
|
223
|
+
|
224
|
+
timeoutRef = React.createRef();
|
225
|
+
// How many characters to hold off searching from. Search tarts at this plus one.
|
226
|
+
SEARCH_HOLDOFF = 2;
|
227
|
+
|
228
|
+
loadOptions = (query) => {
|
229
|
+
// Implement a debounce of 400ms and a min search of 3 chars
|
230
|
+
if (query.length > this.SEARCH_HOLDOFF) {
|
231
|
+
if (this.timeoutRef.current) clearTimeout(this.timeoutRef.current);
|
232
|
+
return new Promise((resolve) => {
|
233
|
+
this.timeoutRef.current = setTimeout(async () => {
|
234
|
+
const res = await this.fetchAvailableChoices(query);
|
235
|
+
resolve(res);
|
236
|
+
}, 400);
|
237
|
+
});
|
238
|
+
} else {
|
239
|
+
return Promise.resolve([]);
|
240
|
+
}
|
241
|
+
};
|
242
|
+
|
243
|
+
fetchAvailableChoices = async (query) => {
|
244
|
+
const resp = await this.props.getVocabulary({
|
245
|
+
vocabNameOrURL: this.props.vocabBaseUrl,
|
246
|
+
query,
|
247
|
+
size: -1,
|
248
|
+
subrequest: this.props.lang,
|
249
|
+
});
|
250
|
+
return normalizeChoices(resp.items || [], this.props.intl);
|
251
|
+
};
|
252
|
+
|
253
|
+
render() {
|
254
|
+
const selectedOption = normalizeValue(
|
255
|
+
this.state.termsPairsCache,
|
256
|
+
this.props.value,
|
257
|
+
this.props.intl,
|
258
|
+
);
|
259
|
+
const SelectAsync = this.props.reactSelectAsync.default;
|
260
|
+
|
261
|
+
return (
|
262
|
+
<FormFieldWrapper {...this.props}>
|
263
|
+
<SelectAsync
|
264
|
+
id={`field-${this.props.id}`}
|
265
|
+
key={this.props.id}
|
266
|
+
isDisabled={this.props.disabled || this.props.isDisabled}
|
267
|
+
className="react-select-container"
|
268
|
+
classNamePrefix="react-select"
|
269
|
+
cacheOptions
|
270
|
+
defaultOptions={[]}
|
271
|
+
loadOptions={this.loadOptions}
|
272
|
+
onInputChange={(search) =>
|
273
|
+
this.setState({ searchLength: search.length })
|
274
|
+
}
|
275
|
+
noOptionsMessage={() =>
|
276
|
+
this.props.intl.formatMessage(
|
277
|
+
this.state.searchLength > this.SEARCH_HOLDOFF
|
278
|
+
? messages.no_options
|
279
|
+
: messages.type_text,
|
280
|
+
)
|
281
|
+
}
|
282
|
+
styles={customSelectStyles}
|
283
|
+
theme={selectTheme}
|
284
|
+
components={{
|
285
|
+
...(this.props.choices?.length > 25 && { MenuList }),
|
286
|
+
MultiValueContainer,
|
287
|
+
ClearIndicator,
|
288
|
+
DropdownIndicator,
|
289
|
+
Option: CustomOption,
|
290
|
+
}}
|
291
|
+
value={selectedOption || []}
|
292
|
+
placeholder={
|
293
|
+
this.props.placeholder ??
|
294
|
+
this.props.intl.formatMessage(messages.select)
|
295
|
+
}
|
296
|
+
onChange={this.handleChange}
|
297
|
+
isMulti
|
298
|
+
/>
|
299
|
+
</FormFieldWrapper>
|
300
|
+
);
|
301
|
+
}
|
302
|
+
}
|
303
|
+
|
304
|
+
export default compose(
|
305
|
+
injectIntl,
|
306
|
+
injectLazyLibs(['reactSelectAsync']),
|
307
|
+
connect(
|
308
|
+
(state, props) => {
|
309
|
+
const vocabBaseUrl =
|
310
|
+
getVocabFromHint(props) ||
|
311
|
+
getVocabFromField(props) ||
|
312
|
+
getVocabFromItems(props);
|
313
|
+
|
314
|
+
const vocabState =
|
315
|
+
state.vocabularies?.[vocabBaseUrl]?.subrequests?.[
|
316
|
+
`widget-${props.id}-${state.intl.locale}`
|
317
|
+
]?.items;
|
318
|
+
|
319
|
+
return props.items?.choices
|
320
|
+
? { choices: props.items.choices, lang: state.intl.locale }
|
321
|
+
: vocabState
|
322
|
+
? {
|
323
|
+
choices: vocabState,
|
324
|
+
vocabBaseUrl,
|
325
|
+
lang: state.intl.locale,
|
326
|
+
}
|
327
|
+
: { vocabBaseUrl, lang: state.intl.locale };
|
328
|
+
},
|
329
|
+
{ getVocabulary, getVocabularyTokenTitle },
|
330
|
+
),
|
331
|
+
)(UserSelectWidget);
|
@@ -0,0 +1,255 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import configureStore from 'redux-mock-store';
|
3
|
+
import { Provider } from 'react-intl-redux';
|
4
|
+
import { waitFor, render, screen } from '@testing-library/react';
|
5
|
+
|
6
|
+
import UserSelectWidget, {
|
7
|
+
normalizeChoices,
|
8
|
+
normalizeSingleSelectOption,
|
9
|
+
} from './UserSelectWidget';
|
10
|
+
|
11
|
+
const mockStore = configureStore();
|
12
|
+
|
13
|
+
jest.mock('@plone/volto/helpers/Loadable/Loadable');
|
14
|
+
beforeAll(
|
15
|
+
async () =>
|
16
|
+
await require('@plone/volto/helpers/Loadable/Loadable').__setLoadables(),
|
17
|
+
);
|
18
|
+
|
19
|
+
test('renders a select widget component', async () => {
|
20
|
+
const store = mockStore({
|
21
|
+
intl: {
|
22
|
+
locale: 'en',
|
23
|
+
messages: {},
|
24
|
+
},
|
25
|
+
vocabularies: {
|
26
|
+
'plone.app.vocabularies.Keywords': {
|
27
|
+
items: [
|
28
|
+
{ email: 'myemail@provider.com', title: 'My item', value: 'myitem' },
|
29
|
+
],
|
30
|
+
itemsTotal: 1,
|
31
|
+
},
|
32
|
+
},
|
33
|
+
});
|
34
|
+
|
35
|
+
const props = {
|
36
|
+
getVocabulary: () => {
|
37
|
+
return Promise.resolve({
|
38
|
+
items: [
|
39
|
+
{ email: 'foo@provider.com', token: 'foo', title: 'Foo' },
|
40
|
+
{ email: 'bar@provider.com', token: 'bar', title: 'Bar' },
|
41
|
+
{ email: 'foobar@provider.com', token: 'fooBar', title: 'FooBar' },
|
42
|
+
],
|
43
|
+
});
|
44
|
+
},
|
45
|
+
widgetOptions: {
|
46
|
+
vocabulary: { '@id': 'plone.app.vocabularies.Keywords' },
|
47
|
+
},
|
48
|
+
};
|
49
|
+
|
50
|
+
const { container } = render(
|
51
|
+
<Provider store={store}>
|
52
|
+
<UserSelectWidget
|
53
|
+
{...props}
|
54
|
+
id="my-field"
|
55
|
+
title="My field"
|
56
|
+
fieldSet="default"
|
57
|
+
onChange={() => {}}
|
58
|
+
onBlur={() => {}}
|
59
|
+
onClick={() => {}}
|
60
|
+
/>
|
61
|
+
</Provider>,
|
62
|
+
);
|
63
|
+
|
64
|
+
await waitFor(() => screen.getByText('My field'));
|
65
|
+
expect(container).toMatchSnapshot();
|
66
|
+
});
|
67
|
+
|
68
|
+
// Test normalization of choices
|
69
|
+
test('normalizes vocabulary API response correctly', () => {
|
70
|
+
const mockData = [
|
71
|
+
{ email: 'charlie@example.com', token: 'charlie', title: 'Charlie' },
|
72
|
+
{ email: 'dana@example.com', token: 'dana', title: 'Dana' },
|
73
|
+
];
|
74
|
+
|
75
|
+
const result = normalizeChoices(mockData, {
|
76
|
+
formatMessage: (msg) => msg.defaultMessage,
|
77
|
+
});
|
78
|
+
|
79
|
+
expect(result).toEqual([
|
80
|
+
{ value: 'charlie', label: 'Charlie', email: 'charlie@example.com' },
|
81
|
+
{ value: 'dana', label: 'Dana', email: 'dana@example.com' },
|
82
|
+
]);
|
83
|
+
});
|
84
|
+
|
85
|
+
// Test missing email default handling
|
86
|
+
test('defaults missing email to label or token', () => {
|
87
|
+
const mockData = [{ token: 'no-email', title: 'No Email User' }];
|
88
|
+
|
89
|
+
const result = normalizeChoices(mockData, {
|
90
|
+
formatMessage: (msg) => msg.defaultMessage,
|
91
|
+
});
|
92
|
+
|
93
|
+
expect(result).toEqual([
|
94
|
+
{ value: 'no-email', label: 'No Email User', email: 'No Email User' },
|
95
|
+
]);
|
96
|
+
});
|
97
|
+
|
98
|
+
// Test search logic (filters results based on query)
|
99
|
+
test('filters choices based on search query', () => {
|
100
|
+
const mockData = [
|
101
|
+
{ email: 'george@example.com', token: 'george', title: 'George' },
|
102
|
+
{ email: 'hannah@example.com', token: 'hannah', title: 'Hannah' },
|
103
|
+
];
|
104
|
+
|
105
|
+
const result = normalizeChoices(mockData, {
|
106
|
+
formatMessage: (msg) => msg.defaultMessage,
|
107
|
+
});
|
108
|
+
|
109
|
+
const filteredResults = result.filter((item) =>
|
110
|
+
item.label.toLowerCase().includes('geo'),
|
111
|
+
);
|
112
|
+
|
113
|
+
expect(filteredResults).toEqual([
|
114
|
+
{ value: 'george', label: 'George', email: 'george@example.com' },
|
115
|
+
]);
|
116
|
+
});
|
117
|
+
|
118
|
+
test('normalizes a valid object with email', () => {
|
119
|
+
const result = normalizeSingleSelectOption(
|
120
|
+
{ token: 'user1', title: 'User One', email: 'user1@example.com' },
|
121
|
+
{ formatMessage: (msg) => msg.defaultMessage },
|
122
|
+
);
|
123
|
+
|
124
|
+
expect(result).toEqual({
|
125
|
+
value: 'user1',
|
126
|
+
label: 'User One',
|
127
|
+
email: 'user1@example.com',
|
128
|
+
});
|
129
|
+
});
|
130
|
+
|
131
|
+
test('normalizes an object with missing email using label fallback', () => {
|
132
|
+
const result = normalizeSingleSelectOption(
|
133
|
+
{ token: 'user2', title: 'User Two' },
|
134
|
+
{ formatMessage: (msg) => msg.defaultMessage },
|
135
|
+
);
|
136
|
+
|
137
|
+
expect(result).toEqual({
|
138
|
+
value: 'user2',
|
139
|
+
label: 'User Two',
|
140
|
+
email: 'User Two',
|
141
|
+
});
|
142
|
+
});
|
143
|
+
|
144
|
+
test('normalizes an array [token, title]', () => {
|
145
|
+
const result = normalizeSingleSelectOption(['user3', 'User Three'], {
|
146
|
+
formatMessage: (msg) => msg.defaultMessage,
|
147
|
+
});
|
148
|
+
|
149
|
+
expect(result).toEqual({
|
150
|
+
value: 'user3',
|
151
|
+
label: 'User Three',
|
152
|
+
email: '',
|
153
|
+
});
|
154
|
+
});
|
155
|
+
|
156
|
+
test('throws an error for unexpected array format', () => {
|
157
|
+
expect(() => {
|
158
|
+
normalizeSingleSelectOption(['wrongFormat'], {
|
159
|
+
formatMessage: (msg) => msg.defaultMessage,
|
160
|
+
});
|
161
|
+
}).toThrow('Unknown value type of select widget: wrongFormat');
|
162
|
+
});
|
163
|
+
|
164
|
+
test('normalizes an object with only token and falls back correctly', () => {
|
165
|
+
const result = normalizeSingleSelectOption(
|
166
|
+
{ token: 'user4' },
|
167
|
+
{ formatMessage: (msg) => msg.defaultMessage },
|
168
|
+
);
|
169
|
+
|
170
|
+
expect(result).toEqual({
|
171
|
+
value: 'user4',
|
172
|
+
label: 'user4',
|
173
|
+
email: 'user4',
|
174
|
+
});
|
175
|
+
});
|
176
|
+
|
177
|
+
test('returns input when value is null or undefined', () => {
|
178
|
+
expect(normalizeSingleSelectOption(null, {})).toBe(null);
|
179
|
+
expect(normalizeSingleSelectOption(undefined, {})).toBe(undefined);
|
180
|
+
});
|
181
|
+
|
182
|
+
// Test normalizeSingleSelectOption with an object missing title
|
183
|
+
test('normalizes an object with missing title and fallback to token', () => {
|
184
|
+
const result = normalizeSingleSelectOption(
|
185
|
+
{ token: 'user5', email: 'user5@example.com' },
|
186
|
+
{ formatMessage: (msg) => msg.defaultMessage },
|
187
|
+
);
|
188
|
+
|
189
|
+
expect(result).toEqual({
|
190
|
+
value: 'user5',
|
191
|
+
label: 'user5',
|
192
|
+
email: 'user5@example.com',
|
193
|
+
});
|
194
|
+
});
|
195
|
+
|
196
|
+
// Test normalizeChoices with an empty array
|
197
|
+
test('handles empty array correctly in normalizeChoices', () => {
|
198
|
+
const result = normalizeChoices([], {
|
199
|
+
formatMessage: (msg) => msg.defaultMessage,
|
200
|
+
});
|
201
|
+
|
202
|
+
expect(result).toEqual([]);
|
203
|
+
});
|
204
|
+
|
205
|
+
// Test normalizeSingleSelectOption when title is "None"
|
206
|
+
test('defaults label to token when title is "None"', () => {
|
207
|
+
const result = normalizeSingleSelectOption(
|
208
|
+
{ token: 'user6', title: 'None', email: 'user6@example.com' },
|
209
|
+
{ formatMessage: (msg) => msg.defaultMessage },
|
210
|
+
);
|
211
|
+
|
212
|
+
expect(result).toEqual({
|
213
|
+
value: 'user6',
|
214
|
+
label: 'user6',
|
215
|
+
email: 'user6@example.com',
|
216
|
+
});
|
217
|
+
});
|
218
|
+
|
219
|
+
// Test normalizeSingleSelectOption with both token and value fields
|
220
|
+
test('handles object with both token and value fields', () => {
|
221
|
+
const result = normalizeSingleSelectOption(
|
222
|
+
{ token: 'user7', value: 'actualValue', title: 'User Seven' },
|
223
|
+
{ formatMessage: (msg) => msg.defaultMessage },
|
224
|
+
);
|
225
|
+
|
226
|
+
expect(result).toEqual({
|
227
|
+
value: 'user7',
|
228
|
+
label: 'User Seven',
|
229
|
+
email: 'User Seven',
|
230
|
+
});
|
231
|
+
});
|
232
|
+
|
233
|
+
// Test normalizeChoices with nested objects
|
234
|
+
test('ignores extra nested data in normalizeChoices', () => {
|
235
|
+
const mockData = [
|
236
|
+
{
|
237
|
+
email: 'nested@example.com',
|
238
|
+
token: 'nestedUser',
|
239
|
+
title: 'Nested User',
|
240
|
+
extraField: { something: 'should be ignored' },
|
241
|
+
},
|
242
|
+
];
|
243
|
+
|
244
|
+
const result = normalizeChoices(mockData, {
|
245
|
+
formatMessage: (msg) => msg.defaultMessage,
|
246
|
+
});
|
247
|
+
|
248
|
+
expect(result).toEqual([
|
249
|
+
{
|
250
|
+
value: 'nestedUser',
|
251
|
+
label: 'Nested User',
|
252
|
+
email: 'nested@example.com',
|
253
|
+
},
|
254
|
+
]);
|
255
|
+
});
|
@@ -0,0 +1,89 @@
|
|
1
|
+
/**
|
2
|
+
* Vocabularies actions.
|
3
|
+
* @module actions/vocabularies/vocabularies
|
4
|
+
*/
|
5
|
+
|
6
|
+
import {
|
7
|
+
GET_VOCABULARY,
|
8
|
+
GET_VOCABULARY_TOKEN_TITLE,
|
9
|
+
} from '@plone/volto/constants/ActionTypes';
|
10
|
+
import { getVocabName } from '@plone/volto/helpers/Vocabularies/Vocabularies';
|
11
|
+
import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
|
12
|
+
import qs from 'query-string';
|
13
|
+
|
14
|
+
/**
|
15
|
+
* Get vocabulary given a URL (coming from a Schema) or from a vocabulary name.
|
16
|
+
* @function getVocabulary
|
17
|
+
* @param {string} vocabNameOrURL Full API URL of vocabulary or vocabulary name
|
18
|
+
* @param {string} query Only include results containing this string.
|
19
|
+
* @param {number} start Start of result batch.
|
20
|
+
* @param {number} b_size The size of the batch.
|
21
|
+
* @param {string} subrequest Name of the subrequest.
|
22
|
+
* @returns {Object} Get vocabulary action.
|
23
|
+
*/
|
24
|
+
export function getVocabulary({
|
25
|
+
vocabNameOrURL,
|
26
|
+
query = null,
|
27
|
+
start = 0,
|
28
|
+
size,
|
29
|
+
subrequest,
|
30
|
+
}) {
|
31
|
+
const vocabPath = vocabNameOrURL.includes('/')
|
32
|
+
? flattenToAppURL(vocabNameOrURL)
|
33
|
+
: `/@vocabularies/${vocabNameOrURL}`;
|
34
|
+
|
35
|
+
let queryString = `b_start=${start}${size ? '&b_size=' + size : ''}`;
|
36
|
+
|
37
|
+
if (query) {
|
38
|
+
queryString = `${queryString}&title=${query}`;
|
39
|
+
}
|
40
|
+
return {
|
41
|
+
type: GET_VOCABULARY,
|
42
|
+
vocabulary: vocabNameOrURL,
|
43
|
+
start,
|
44
|
+
request: {
|
45
|
+
op: 'get',
|
46
|
+
path: `${vocabPath}?${queryString}`,
|
47
|
+
},
|
48
|
+
subrequest,
|
49
|
+
};
|
50
|
+
}
|
51
|
+
|
52
|
+
/**
|
53
|
+
* Get the title value given a token from vocabulary given a vocabulary URL
|
54
|
+
* (coming from a Schema) or from a vocabulary name.
|
55
|
+
* @function getVocabularyTokenTitle
|
56
|
+
* @param {string} vocabNameOrURL Full API URL of vocabulary or vocabulary name
|
57
|
+
* @param {string} token Only include results containing this string.
|
58
|
+
* @returns {Object} Get vocabulary action.
|
59
|
+
*/
|
60
|
+
export function getVocabularyTokenTitle({
|
61
|
+
vocabNameOrURL,
|
62
|
+
token = null,
|
63
|
+
tokens = null,
|
64
|
+
subrequest,
|
65
|
+
}) {
|
66
|
+
// In case we have a URL, we have to get the vocabulary name
|
67
|
+
const vocabulary = getVocabName(vocabNameOrURL);
|
68
|
+
const queryString = {
|
69
|
+
...(token && { token }),
|
70
|
+
...(tokens && { tokens }),
|
71
|
+
};
|
72
|
+
|
73
|
+
return {
|
74
|
+
type: GET_VOCABULARY_TOKEN_TITLE,
|
75
|
+
vocabulary: vocabNameOrURL,
|
76
|
+
token,
|
77
|
+
tokens,
|
78
|
+
subrequest,
|
79
|
+
request: {
|
80
|
+
op: 'get',
|
81
|
+
path: `/@vocabularies/${vocabulary}?b_size=-1&${qs.stringify(
|
82
|
+
queryString,
|
83
|
+
{
|
84
|
+
encode: false,
|
85
|
+
},
|
86
|
+
)}`,
|
87
|
+
},
|
88
|
+
};
|
89
|
+
}
|
@@ -0,0 +1,32 @@
|
|
1
|
+
diff --git a/src/customizations/volto/actions/vocabularies/vocabularies.js b/src/customizations/volto/actions/vocabularies/vocabularies.js
|
2
|
+
index 811aee2..ec60589 100644
|
3
|
+
--- a/src/customizations/volto/actions/vocabularies/vocabularies.js
|
4
|
+
+++ b/src/customizations/volto/actions/vocabularies/vocabularies.js
|
5
|
+
@@ -8,6 +8,7 @@ import {
|
6
|
+
GET_VOCABULARY_TOKEN_TITLE,
|
7
|
+
} from '@plone/volto/constants/ActionTypes';
|
8
|
+
import { getVocabName } from '@plone/volto/helpers/Vocabularies/Vocabularies';
|
9
|
+
+import { flattenToAppURL } from '@plone/volto/helpers/Url/Url';
|
10
|
+
import qs from 'query-string';
|
11
|
+
|
12
|
+
/**
|
13
|
+
@@ -27,7 +28,9 @@ export function getVocabulary({
|
14
|
+
size,
|
15
|
+
subrequest,
|
16
|
+
}) {
|
17
|
+
- const vocabulary = getVocabName(vocabNameOrURL);
|
18
|
+
+ const vocabPath = vocabNameOrURL.includes('/')
|
19
|
+
+ ? flattenToAppURL(vocabNameOrURL)
|
20
|
+
+ : `/@vocabularies/${vocabNameOrURL}`;
|
21
|
+
|
22
|
+
let queryString = `b_start=${start}${size ? '&b_size=' + size : ''}`;
|
23
|
+
|
24
|
+
@@ -40,7 +43,7 @@ export function getVocabulary({
|
25
|
+
start,
|
26
|
+
request: {
|
27
|
+
op: 'get',
|
28
|
+
- path: `/@vocabularies/${vocabulary}?${queryString}`,
|
29
|
+
+ path: `${vocabPath}?${queryString}`,
|
30
|
+
},
|
31
|
+
subrequest,
|
32
|
+
};
|
@@ -0,0 +1,57 @@
|
|
1
|
+
import { getVocabulary } from './vocabularies';
|
2
|
+
import { GET_VOCABULARY } from '@plone/volto/constants/ActionTypes';
|
3
|
+
|
4
|
+
describe('Vocabularies actions', () => {
|
5
|
+
describe('getVocabulary', () => {
|
6
|
+
it('should create an action to get a vocabulary', () => {
|
7
|
+
const vocabNameOrURL = 'plone.app.vocabularies.Keywords';
|
8
|
+
const query = 'john';
|
9
|
+
const action = getVocabulary({ vocabNameOrURL, query });
|
10
|
+
|
11
|
+
expect(action.type).toEqual(GET_VOCABULARY);
|
12
|
+
expect(action.vocabulary).toEqual(vocabNameOrURL);
|
13
|
+
expect(action.request.op).toEqual('get');
|
14
|
+
expect(action.request.path).toEqual(
|
15
|
+
`/@vocabularies/${vocabNameOrURL}?b_start=0&title=${query}`,
|
16
|
+
);
|
17
|
+
});
|
18
|
+
it('should create an action to get a vocabulary if a URL is passed', () => {
|
19
|
+
const vocabNameOrURL =
|
20
|
+
'http://localhost:3000/@vocabularies/plone.app.vocabularies.Keywords';
|
21
|
+
const query = 'john';
|
22
|
+
const action = getVocabulary({ vocabNameOrURL, query });
|
23
|
+
|
24
|
+
expect(action.type).toEqual(GET_VOCABULARY);
|
25
|
+
expect(action.vocabulary).toEqual(vocabNameOrURL);
|
26
|
+
expect(action.request.op).toEqual('get');
|
27
|
+
expect(action.request.path).toEqual(
|
28
|
+
`/@vocabularies/plone.app.vocabularies.Keywords?b_start=0&title=${query}`,
|
29
|
+
);
|
30
|
+
});
|
31
|
+
it('should create an action to get a vocabulary if a URL with path is passed', () => {
|
32
|
+
const vocabNameOrURL =
|
33
|
+
'http://localhost:3000/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords';
|
34
|
+
const query = 'john';
|
35
|
+
const action = getVocabulary({ vocabNameOrURL, query });
|
36
|
+
|
37
|
+
expect(action.type).toEqual(GET_VOCABULARY);
|
38
|
+
expect(action.vocabulary).toEqual(vocabNameOrURL);
|
39
|
+
expect(action.request.op).toEqual('get');
|
40
|
+
expect(action.request.path).toEqual(
|
41
|
+
`/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords?b_start=0&title=${query}`,
|
42
|
+
);
|
43
|
+
});
|
44
|
+
it('should create an action to get a vocabulary if an b_size=-1 is passed', () => {
|
45
|
+
const vocabNameOrURL =
|
46
|
+
'http://localhost:3000/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords';
|
47
|
+
const action = getVocabulary({ vocabNameOrURL, size: -1 });
|
48
|
+
|
49
|
+
expect(action.type).toEqual(GET_VOCABULARY);
|
50
|
+
expect(action.vocabulary).toEqual(vocabNameOrURL);
|
51
|
+
expect(action.request.op).toEqual('get');
|
52
|
+
expect(action.request.path).toEqual(
|
53
|
+
`/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords?b_start=0&b_size=-1`,
|
54
|
+
);
|
55
|
+
});
|
56
|
+
});
|
57
|
+
});
|
@@ -0,0 +1,45 @@
|
|
1
|
+
diff --git a/src/customizations/volto/actions/vocabularies/vocabularies.test.js b/src/customizations/volto/actions/vocabularies/vocabularies.test.js
|
2
|
+
index b7deafc..ca4fc8a 100644
|
3
|
+
--- a/src/customizations/volto/actions/vocabularies/vocabularies.test.js
|
4
|
+
+++ b/src/customizations/volto/actions/vocabularies/vocabularies.test.js
|
5
|
+
@@ -17,7 +17,7 @@ describe('Vocabularies actions', () => {
|
6
|
+
});
|
7
|
+
it('should create an action to get a vocabulary if a URL is passed', () => {
|
8
|
+
const vocabNameOrURL =
|
9
|
+
- 'http://localhost:8080/@vocabularies/plone.app.vocabularies.Keywords';
|
10
|
+
+ 'http://localhost:3000/@vocabularies/plone.app.vocabularies.Keywords';
|
11
|
+
const query = 'john';
|
12
|
+
const action = getVocabulary({ vocabNameOrURL, query });
|
13
|
+
|
14
|
+
@@ -30,7 +30,7 @@ describe('Vocabularies actions', () => {
|
15
|
+
});
|
16
|
+
it('should create an action to get a vocabulary if a URL with path is passed', () => {
|
17
|
+
const vocabNameOrURL =
|
18
|
+
- 'http://localhost:8080/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords';
|
19
|
+
+ 'http://localhost:3000/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords';
|
20
|
+
const query = 'john';
|
21
|
+
const action = getVocabulary({ vocabNameOrURL, query });
|
22
|
+
|
23
|
+
@@ -38,19 +38,19 @@ describe('Vocabularies actions', () => {
|
24
|
+
expect(action.vocabulary).toEqual(vocabNameOrURL);
|
25
|
+
expect(action.request.op).toEqual('get');
|
26
|
+
expect(action.request.path).toEqual(
|
27
|
+
- `/@vocabularies/plone.app.vocabularies.Keywords?b_start=0&title=${query}`,
|
28
|
+
+ `/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords?b_start=0&title=${query}`,
|
29
|
+
);
|
30
|
+
});
|
31
|
+
it('should create an action to get a vocabulary if an b_size=-1 is passed', () => {
|
32
|
+
const vocabNameOrURL =
|
33
|
+
- 'http://localhost:8080/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords';
|
34
|
+
+ 'http://localhost:3000/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords';
|
35
|
+
const action = getVocabulary({ vocabNameOrURL, size: -1 });
|
36
|
+
|
37
|
+
expect(action.type).toEqual(GET_VOCABULARY);
|
38
|
+
expect(action.vocabulary).toEqual(vocabNameOrURL);
|
39
|
+
expect(action.request.op).toEqual('get');
|
40
|
+
expect(action.request.path).toEqual(
|
41
|
+
- `/@vocabularies/plone.app.vocabularies.Keywords?b_start=0&b_size=-1`,
|
42
|
+
+ `/de/foo/bar/@vocabularies/plone.app.vocabularies.Keywords?b_start=0&b_size=-1`,
|
43
|
+
);
|
44
|
+
});
|
45
|
+
});
|
@@ -0,0 +1,70 @@
|
|
1
|
+
import React from 'react';
|
2
|
+
import { Provider } from 'react-redux';
|
3
|
+
import configureStore from 'redux-mock-store';
|
4
|
+
import thunk from 'redux-thunk'; // ✅ Add redux-thunk middleware
|
5
|
+
import { render } from '@testing-library/react';
|
6
|
+
import withRootNavigation from './withRootNavigation';
|
7
|
+
import { getNavigation } from '@plone/volto/actions';
|
8
|
+
import { getBaseUrl, hasApiExpander } from '@plone/volto/helpers';
|
9
|
+
import config from '@plone/volto/registry';
|
10
|
+
|
11
|
+
// Mock dependencies
|
12
|
+
jest.mock('@plone/volto/actions', () => ({
|
13
|
+
getNavigation: jest.fn(() => ({ type: 'GET_NAVIGATION' })), // ✅ Ensure it returns a plain object
|
14
|
+
}));
|
15
|
+
jest.mock('@plone/volto/helpers', () => ({
|
16
|
+
getBaseUrl: jest.fn(() => '/en'),
|
17
|
+
hasApiExpander: jest.fn(() => false),
|
18
|
+
}));
|
19
|
+
jest.mock('@plone/volto/registry', () => ({
|
20
|
+
settings: { navDepth: 2 },
|
21
|
+
}));
|
22
|
+
|
23
|
+
// ✅ Use redux-thunk middleware
|
24
|
+
const mockStore = configureStore([thunk]); // Add thunk to support async actions
|
25
|
+
|
26
|
+
const initialState = {
|
27
|
+
navigation: { items: [{ title: 'Home', url: '/' }] },
|
28
|
+
intl: { locale: 'en' },
|
29
|
+
};
|
30
|
+
|
31
|
+
const store = mockStore(initialState);
|
32
|
+
|
33
|
+
// Mock Wrapped Component
|
34
|
+
const MockComponent = (props) => {
|
35
|
+
return (
|
36
|
+
<div data-testid="wrapped-component">{JSON.stringify(props.items)}</div>
|
37
|
+
);
|
38
|
+
};
|
39
|
+
|
40
|
+
const WrappedComponent = withRootNavigation(MockComponent);
|
41
|
+
|
42
|
+
describe('withRootNavigation HOC', () => {
|
43
|
+
beforeEach(() => {
|
44
|
+
jest.clearAllMocks();
|
45
|
+
});
|
46
|
+
|
47
|
+
test('calls getNavigation when API expander is not set', () => {
|
48
|
+
render(
|
49
|
+
<Provider store={store}>
|
50
|
+
<WrappedComponent />
|
51
|
+
</Provider>,
|
52
|
+
);
|
53
|
+
|
54
|
+
expect(getBaseUrl).toHaveBeenCalledWith('/en'); // Check base URL calculation
|
55
|
+
expect(hasApiExpander).toHaveBeenCalledWith('navigation', '/en'); // Ensure API expander is checked
|
56
|
+
expect(getNavigation).toHaveBeenCalledWith('/en', config.settings.navDepth); // Ensure getNavigation is dispatched
|
57
|
+
});
|
58
|
+
|
59
|
+
test('does not call getNavigation if API expander is already set', () => {
|
60
|
+
hasApiExpander.mockReturnValue(true); // Simulate that API expander is already set
|
61
|
+
|
62
|
+
render(
|
63
|
+
<Provider store={store}>
|
64
|
+
<WrappedComponent />
|
65
|
+
</Provider>,
|
66
|
+
);
|
67
|
+
|
68
|
+
expect(getNavigation).not.toHaveBeenCalled(); // Ensure getNavigation is NOT called
|
69
|
+
});
|
70
|
+
});
|
package/src/index.js
CHANGED
@@ -2,7 +2,6 @@ import React from 'react';
|
|
2
2
|
import { v4 as uuid } from 'uuid';
|
3
3
|
import { Icon } from '@plone/volto/components';
|
4
4
|
import { default as TokenWidgetEdit } from '@plone/volto/components/manage/Widgets/TokenWidget';
|
5
|
-
import SelectAutoCompleteWidget from '@plone/volto/components/manage/Widgets/SelectAutoComplete';
|
6
5
|
import { serializeNodesToText } from '@plone/volto-slate/editor/render';
|
7
6
|
import TableBlockEdit from '@plone/volto-slate/blocks/Table/TableBlockEdit';
|
8
7
|
import TableBlockView from '@plone/volto-slate/blocks/Table/TableBlockView';
|
@@ -20,7 +19,10 @@ import { TopicsWidget } from '@eeacms/volto-eea-website-theme/components/theme/W
|
|
20
19
|
import { DateWidget } from '@eeacms/volto-eea-website-theme/components/theme/Widgets/DateWidget';
|
21
20
|
import { DatetimeWidget } from '@eeacms/volto-eea-website-theme/components/theme/Widgets/DatetimeWidget';
|
22
21
|
import CreatableSelectWidget from '@eeacms/volto-eea-website-theme/components/theme/Widgets/CreatableSelectWidget';
|
22
|
+
import UserSelectWidget from '@eeacms/volto-eea-website-theme/components/theme/Widgets/UserSelectWidget';
|
23
23
|
import ImageViewWidget from '@eeacms/volto-eea-website-theme/components/theme/Widgets/ImageViewWidget';
|
24
|
+
import CreatorsViewWidget from '@eeacms/volto-eea-website-theme/components/theme/Widgets/CreatorsViewWidget';
|
25
|
+
import ContributorsViewWidget from '@eeacms/volto-eea-website-theme/components/theme/Widgets/ContributorsViewWidget';
|
24
26
|
|
25
27
|
import Tag from '@eeacms/volto-eea-design-system/ui/Tag/Tag';
|
26
28
|
|
@@ -336,6 +338,7 @@ const applyConfig = (config) => {
|
|
336
338
|
title: 'No. of columns',
|
337
339
|
description: 'Choose the number of flex columns',
|
338
340
|
choices: [
|
341
|
+
[1, 1],
|
339
342
|
[2, 2],
|
340
343
|
[3, 3],
|
341
344
|
[4, 4],
|
@@ -371,9 +374,12 @@ const applyConfig = (config) => {
|
|
371
374
|
config.widgets.views.id.topics = TopicsWidget;
|
372
375
|
config.widgets.views.id.subjects = TokenWidget;
|
373
376
|
config.widgets.views.widget.tags = TokenWidget;
|
377
|
+
config.widgets.views.id.creators = CreatorsViewWidget;
|
378
|
+
config.widgets.views.id.contributors = ContributorsViewWidget;
|
379
|
+
config.widgets.views.widget.contributors = ContributorsViewWidget;
|
380
|
+
config.widgets.views.widget.creators = CreatorsViewWidget;
|
374
381
|
config.widgets.widget.creatable_select = CreatableSelectWidget;
|
375
|
-
config.widgets.vocabulary['plone.app.vocabularies.Users'] =
|
376
|
-
SelectAutoCompleteWidget;
|
382
|
+
config.widgets.vocabulary['plone.app.vocabularies.Users'] = UserSelectWidget;
|
377
383
|
|
378
384
|
config.widgets.views.factory = {
|
379
385
|
...(config.widgets.views.factory || {}),
|
package/src/index.test.js
CHANGED
@@ -46,6 +46,12 @@ jest.mock('@plone/volto/components', () => ({
|
|
46
46
|
Icon: 'MockedIcon',
|
47
47
|
}));
|
48
48
|
|
49
|
+
jest.mock('uuid', () => {
|
50
|
+
return {
|
51
|
+
v4: () => 'mock-uuid-' + Math.random().toString(36).substr(2, 9),
|
52
|
+
};
|
53
|
+
});
|
54
|
+
|
49
55
|
global.__SERVER__ = true;
|
50
56
|
|
51
57
|
describe('applyConfig', () => {
|