@eeacms/volto-eea-website-theme 3.5.5 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -24
- 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/hocs/withRootNavigation.test.jsx +70 -0
- package/src/index.js +8 -3
@@ -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
|
+
};
|