@plone/volto 18.33.1 → 18.34.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 +28 -0
- package/README.md +0 -1
- package/locales/af/LC_MESSAGES/volto.po +25 -0
- package/locales/af.json +1 -1
- package/locales/ar/LC_MESSAGES/volto.po +25 -0
- package/locales/ar.json +1 -1
- package/locales/bg/LC_MESSAGES/volto.po +25 -0
- package/locales/bg.json +1 -1
- package/locales/bn/LC_MESSAGES/volto.po +25 -0
- package/locales/bn.json +1 -1
- package/locales/ca/LC_MESSAGES/volto.po +25 -0
- package/locales/ca.json +1 -1
- package/locales/cs/LC_MESSAGES/volto.po +25 -0
- package/locales/cs.json +1 -1
- package/locales/cy/LC_MESSAGES/volto.po +25 -0
- package/locales/cy.json +1 -1
- package/locales/da/LC_MESSAGES/volto.po +25 -0
- package/locales/da.json +1 -1
- package/locales/de/LC_MESSAGES/volto.po +25 -0
- package/locales/de.json +1 -1
- package/locales/el/LC_MESSAGES/volto.po +25 -0
- package/locales/el.json +1 -1
- package/locales/en/LC_MESSAGES/volto.po +25 -0
- package/locales/en.json +1 -1
- package/locales/en_AU/LC_MESSAGES/volto.po +25 -0
- package/locales/en_AU.json +1 -1
- package/locales/en_GB/LC_MESSAGES/volto.po +25 -0
- package/locales/en_GB.json +1 -1
- package/locales/eo/LC_MESSAGES/volto.po +25 -0
- package/locales/eo.json +1 -1
- package/locales/es/LC_MESSAGES/volto.po +42 -17
- package/locales/es.json +1 -1
- package/locales/et/LC_MESSAGES/volto.po +25 -0
- package/locales/et.json +1 -1
- package/locales/eu/LC_MESSAGES/volto.po +25 -0
- package/locales/eu.json +1 -1
- package/locales/fa/LC_MESSAGES/volto.po +25 -0
- package/locales/fa.json +1 -1
- package/locales/fi/LC_MESSAGES/volto.po +25 -0
- package/locales/fi.json +1 -1
- package/locales/fr/LC_MESSAGES/volto.po +25 -0
- package/locales/fr.json +1 -1
- package/locales/fu/LC_MESSAGES/volto.po +25 -0
- package/locales/fu.json +1 -1
- package/locales/gl/LC_MESSAGES/volto.po +30 -5
- package/locales/gl.json +1 -1
- package/locales/he/LC_MESSAGES/volto.po +25 -0
- package/locales/he.json +1 -1
- package/locales/hi/LC_MESSAGES/volto.po +25 -0
- package/locales/hi.json +1 -1
- package/locales/hr/LC_MESSAGES/volto.po +25 -0
- package/locales/hr.json +1 -1
- package/locales/hu/LC_MESSAGES/volto.po +25 -0
- package/locales/hu.json +1 -1
- package/locales/hy/LC_MESSAGES/volto.po +25 -0
- package/locales/hy.json +1 -1
- package/locales/id/LC_MESSAGES/volto.po +25 -0
- package/locales/id.json +1 -1
- package/locales/it/LC_MESSAGES/volto.po +25 -0
- package/locales/it.json +1 -1
- package/locales/ja/LC_MESSAGES/volto.po +25 -0
- package/locales/ja.json +1 -1
- package/locales/ka/LC_MESSAGES/volto.po +25 -0
- package/locales/ka.json +1 -1
- package/locales/kn/LC_MESSAGES/volto.po +25 -0
- package/locales/kn.json +1 -1
- package/locales/ko/LC_MESSAGES/volto.po +25 -0
- package/locales/ko.json +1 -1
- package/locales/lt/LC_MESSAGES/volto.po +25 -0
- package/locales/lt.json +1 -1
- package/locales/lv/LC_MESSAGES/volto.po +25 -0
- package/locales/lv.json +1 -1
- package/locales/mi/LC_MESSAGES/volto.po +25 -0
- package/locales/mi.json +1 -1
- package/locales/mk/LC_MESSAGES/volto.po +25 -0
- package/locales/mk.json +1 -1
- package/locales/my/LC_MESSAGES/volto.po +25 -0
- package/locales/my.json +1 -1
- package/locales/nb_NO/LC_MESSAGES/volto.po +25 -0
- package/locales/nb_NO.json +1 -1
- package/locales/nl/LC_MESSAGES/volto.po +25 -0
- package/locales/nl.json +1 -1
- package/locales/nn/LC_MESSAGES/volto.po +25 -0
- package/locales/nn.json +1 -1
- package/locales/pl/LC_MESSAGES/volto.po +25 -0
- package/locales/pl.json +1 -1
- package/locales/pt/LC_MESSAGES/volto.po +25 -0
- package/locales/pt.json +1 -1
- package/locales/pt_BR/LC_MESSAGES/volto.po +25 -0
- package/locales/pt_BR.json +1 -1
- package/locales/rm/LC_MESSAGES/volto.po +25 -0
- package/locales/rm.json +1 -1
- package/locales/ro/LC_MESSAGES/volto.po +25 -0
- package/locales/ro.json +1 -1
- package/locales/ru/LC_MESSAGES/volto.po +25 -0
- package/locales/ru.json +1 -1
- package/locales/sk/LC_MESSAGES/volto.po +25 -0
- package/locales/sk.json +1 -1
- package/locales/sl/LC_MESSAGES/volto.po +25 -0
- package/locales/sl.json +1 -1
- package/locales/sm/LC_MESSAGES/volto.po +25 -0
- package/locales/sm.json +1 -1
- package/locales/sq/LC_MESSAGES/volto.po +25 -0
- package/locales/sq.json +1 -1
- package/locales/sr/LC_MESSAGES/volto.po +25 -0
- package/locales/sr.json +1 -1
- package/locales/sr@cyrl/LC_MESSAGES/volto.po +25 -0
- package/locales/sr@cyrl.json +1 -1
- package/locales/sr@latn/LC_MESSAGES/volto.po +25 -0
- package/locales/sr@latn.json +1 -1
- package/locales/sv/LC_MESSAGES/volto.po +25 -0
- package/locales/sv.json +1 -1
- package/locales/ta/LC_MESSAGES/volto.po +25 -0
- package/locales/ta.json +1 -1
- package/locales/te/LC_MESSAGES/volto.po +25 -0
- package/locales/te.json +1 -1
- package/locales/th/LC_MESSAGES/volto.po +25 -0
- package/locales/th.json +1 -1
- package/locales/to/LC_MESSAGES/volto.po +25 -0
- package/locales/to.json +1 -1
- package/locales/tr/LC_MESSAGES/volto.po +25 -0
- package/locales/tr.json +1 -1
- package/locales/uk/LC_MESSAGES/volto.po +25 -0
- package/locales/uk.json +1 -1
- package/locales/vi/LC_MESSAGES/volto.po +25 -0
- package/locales/vi.json +1 -1
- package/locales/volto.pot +26 -1
- package/locales/zh_CN/LC_MESSAGES/volto.po +25 -0
- package/locales/zh_CN.json +1 -1
- package/locales/zh_Hant/LC_MESSAGES/volto.po +25 -0
- package/locales/zh_Hant.json +1 -1
- package/locales/zh_Hant_HK/LC_MESSAGES/volto.po +25 -0
- package/locales/zh_Hant_HK.json +1 -1
- package/package.json +7 -7
- package/razzle.config.js +1 -0
- package/src/actions/users/users.js +2 -2
- package/src/components/manage/Blocks/Block/Order/Item.jsx +18 -10
- package/src/components/manage/Blocks/Block/Order/Item.test.jsx +90 -0
- package/src/components/manage/Controlpanels/AddonsControlpanel.jsx +7 -0
- package/src/components/manage/Controlpanels/DatabaseInformation.jsx +9 -0
- package/src/components/manage/Controlpanels/ModerateComments.jsx +8 -0
- package/src/components/manage/Controlpanels/Users/UserGroupMembershipControlPanel.test.jsx +3 -0
- package/src/components/manage/Widgets/DatetimeWidget.jsx +92 -58
- package/src/components/manage/Widgets/DatetimeWidget.test.jsx +55 -0
- package/src/components/manage/Widgets/FormFieldWrapper.jsx +7 -5
- package/src/components/manage/Widgets/TextWidget.jsx +4 -0
- package/src/components/manage/Widgets/UrlWidget.jsx +51 -6
- package/src/components/theme/Unauthorized/Unauthorized.jsx +30 -22
- package/src/components/theme/Unauthorized/Unauthorized.test.jsx +28 -1
- package/theme/themes/default/globals/site.variables +2 -2
- package/theme/themes/pastanaga/extras/main.less +15 -0
- package/theme/themes/pastanaga/globals/site.variables +0 -2
- package/types/components/manage/Blocks/Block/Order/Item.test.d.ts +1 -0
- package/webpack-plugins/webpack-less-plugin.js +1 -1
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* @module actions/users/users
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import
|
|
6
|
+
import qs from 'query-string';
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
9
|
CREATE_USER,
|
|
@@ -92,7 +92,7 @@ export function listUsers(options = {}) {
|
|
|
92
92
|
|
|
93
93
|
let filterarg =
|
|
94
94
|
groups_filter.length > 0
|
|
95
|
-
? stringify(
|
|
95
|
+
? qs.stringify(
|
|
96
96
|
{ 'groups-filter': groups_filter },
|
|
97
97
|
{ arrayFormat: 'colon-list-separator' },
|
|
98
98
|
)
|
|
@@ -44,6 +44,12 @@ export const Item = forwardRef(
|
|
|
44
44
|
config.blocks.blocksConfig[data?.['@type']]?.icon ||
|
|
45
45
|
config.blocks.blocksConfig.title?.icon;
|
|
46
46
|
|
|
47
|
+
const required =
|
|
48
|
+
typeof data?.required === 'boolean'
|
|
49
|
+
? data.required
|
|
50
|
+
: includes(config.blocks.requiredBlocks, data?.['@type']);
|
|
51
|
+
const fixed = !!data?.fixed;
|
|
52
|
+
|
|
47
53
|
return (
|
|
48
54
|
<li
|
|
49
55
|
className={classNames(
|
|
@@ -93,15 +99,17 @@ export const Item = forwardRef(
|
|
|
93
99
|
ref={ref}
|
|
94
100
|
style={style}
|
|
95
101
|
>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
{!fixed && (
|
|
103
|
+
<button
|
|
104
|
+
ref={ref}
|
|
105
|
+
{...handleProps}
|
|
106
|
+
className={classNames('action', 'drag')}
|
|
107
|
+
tabIndex={0}
|
|
108
|
+
data-cypress="draggable-handle"
|
|
109
|
+
>
|
|
110
|
+
<Icon name={dragSVG} size="16px" />
|
|
111
|
+
</button>
|
|
112
|
+
)}
|
|
105
113
|
<span
|
|
106
114
|
className={cx('text', {
|
|
107
115
|
errored: errors && Object.keys(errors).length > 0,
|
|
@@ -118,7 +126,7 @@ export const Item = forwardRef(
|
|
|
118
126
|
config.blocks.blocksConfig[data?.['@type']]?.title ||
|
|
119
127
|
data?.title}
|
|
120
128
|
</span>
|
|
121
|
-
{!clone && onRemove && (
|
|
129
|
+
{!clone && onRemove && !required && (
|
|
122
130
|
<button
|
|
123
131
|
onClick={onRemove}
|
|
124
132
|
className={classNames('action', 'delete')}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import configureStore from 'redux-mock-store';
|
|
3
|
+
import { Provider } from 'react-intl-redux';
|
|
4
|
+
import { render } from '@testing-library/react';
|
|
5
|
+
import config from '@plone/volto/registry';
|
|
6
|
+
|
|
7
|
+
import { Item } from './Item';
|
|
8
|
+
|
|
9
|
+
const mockStore = configureStore();
|
|
10
|
+
|
|
11
|
+
const defaultStoreState = {
|
|
12
|
+
intl: {
|
|
13
|
+
locale: 'en',
|
|
14
|
+
messages: {},
|
|
15
|
+
},
|
|
16
|
+
form: {
|
|
17
|
+
ui: {
|
|
18
|
+
selected: null,
|
|
19
|
+
hovered: null,
|
|
20
|
+
multiSelected: [],
|
|
21
|
+
gridSelected: null,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const renderItem = (data = {}) => {
|
|
27
|
+
const store = mockStore(defaultStoreState);
|
|
28
|
+
|
|
29
|
+
return render(
|
|
30
|
+
<Provider store={store}>
|
|
31
|
+
<Item
|
|
32
|
+
id="title-block-id"
|
|
33
|
+
data={{ '@type': 'title', ...data }}
|
|
34
|
+
depth={0}
|
|
35
|
+
indentationWidth={25}
|
|
36
|
+
onRemove={() => {}}
|
|
37
|
+
onSelectBlock={() => {}}
|
|
38
|
+
handleProps={{}}
|
|
39
|
+
/>
|
|
40
|
+
</Provider>,
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('Order Item', () => {
|
|
45
|
+
let requiredBlocks;
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
requiredBlocks = [...(config.blocks.requiredBlocks || [])];
|
|
49
|
+
config.blocks.requiredBlocks = [];
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
config.blocks.requiredBlocks = requiredBlocks;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('renders drag and delete actions for movable and removable blocks', () => {
|
|
57
|
+
const { container } = renderItem();
|
|
58
|
+
|
|
59
|
+
expect(container.querySelector('.action.drag')).not.toBeNull();
|
|
60
|
+
expect(container.querySelector('.action.delete')).not.toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('hides delete action for required blocks', () => {
|
|
64
|
+
const { container } = renderItem({ required: true });
|
|
65
|
+
|
|
66
|
+
expect(container.querySelector('.action.delete')).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('hides delete action for block types configured as required', () => {
|
|
70
|
+
config.blocks.requiredBlocks = ['title'];
|
|
71
|
+
|
|
72
|
+
const { container } = renderItem();
|
|
73
|
+
|
|
74
|
+
expect(container.querySelector('.action.delete')).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('allows explicit required=false to override required block types', () => {
|
|
78
|
+
config.blocks.requiredBlocks = ['title'];
|
|
79
|
+
|
|
80
|
+
const { container } = renderItem({ required: false });
|
|
81
|
+
|
|
82
|
+
expect(container.querySelector('.action.delete')).not.toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('hides drag action for fixed blocks', () => {
|
|
86
|
+
const { container } = renderItem({ fixed: true });
|
|
87
|
+
|
|
88
|
+
expect(container.querySelector('.action.drag')).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -27,6 +27,7 @@ import Helmet from '@plone/volto/helpers/Helmet/Helmet';
|
|
|
27
27
|
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
28
28
|
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
|
|
29
29
|
import Toast from '@plone/volto/components/manage/Toast/Toast';
|
|
30
|
+
import Error from '@plone/volto/components/theme/Error/Error';
|
|
30
31
|
import circleBottomSVG from '@plone/volto/icons/circle-bottom.svg';
|
|
31
32
|
import circleTopSVG from '@plone/volto/icons/circle-top.svg';
|
|
32
33
|
import backSVG from '@plone/volto/icons/back.svg';
|
|
@@ -148,6 +149,7 @@ const AddonsControlpanel = (props) => {
|
|
|
148
149
|
shallowEqual,
|
|
149
150
|
);
|
|
150
151
|
const loadingAddons = useSelector((state) => state.addons.loading);
|
|
152
|
+
const addonsError = useSelector((state) => state.addons.error);
|
|
151
153
|
|
|
152
154
|
useEffect(() => {
|
|
153
155
|
dispatch(listAddons());
|
|
@@ -245,6 +247,11 @@ const AddonsControlpanel = (props) => {
|
|
|
245
247
|
setactiveIndex(newIndex);
|
|
246
248
|
};
|
|
247
249
|
|
|
250
|
+
// Error handling for unauthorized access
|
|
251
|
+
if (addonsError) {
|
|
252
|
+
return <Error error={addonsError} />;
|
|
253
|
+
}
|
|
254
|
+
|
|
248
255
|
return (
|
|
249
256
|
<Container id="page-addons" className="controlpanel-addons">
|
|
250
257
|
<Helmet title={intl.formatMessage(messages.addOns)} />
|
|
@@ -10,6 +10,7 @@ import Helmet from '@plone/volto/helpers/Helmet/Helmet';
|
|
|
10
10
|
import { useClient } from '@plone/volto/hooks/client/useClient';
|
|
11
11
|
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
12
12
|
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
|
|
13
|
+
import Error from '@plone/volto/components/theme/Error/Error';
|
|
13
14
|
import backSVG from '@plone/volto/icons/back.svg';
|
|
14
15
|
|
|
15
16
|
const messages = defineMessages({
|
|
@@ -31,11 +32,19 @@ const DatabaseInformation = () => {
|
|
|
31
32
|
const databaseInformation = useSelector(
|
|
32
33
|
(state) => state.controlpanels.databaseinformation,
|
|
33
34
|
);
|
|
35
|
+
const databaseError = useSelector(
|
|
36
|
+
(state) => state.controlpanels.database?.error,
|
|
37
|
+
);
|
|
34
38
|
|
|
35
39
|
useEffect(() => {
|
|
36
40
|
dispatch(getDatabaseInformation());
|
|
37
41
|
}, [dispatch]);
|
|
38
42
|
|
|
43
|
+
// Error handling for unauthorized access
|
|
44
|
+
if (databaseError) {
|
|
45
|
+
return <Error error={databaseError} />;
|
|
46
|
+
}
|
|
47
|
+
|
|
39
48
|
return databaseInformation ? (
|
|
40
49
|
<Container id="database-page" className="controlpanel-database">
|
|
41
50
|
<Helmet title={intl.formatMessage(messages.databaseInformation)} />
|
|
@@ -19,6 +19,7 @@ import { searchContent } from '@plone/volto/actions/search/search';
|
|
|
19
19
|
import FormattedRelativeDate from '@plone/volto/components/theme/FormattedDate/FormattedRelativeDate';
|
|
20
20
|
import Icon from '@plone/volto/components/theme/Icon/Icon';
|
|
21
21
|
import Toolbar from '@plone/volto/components/manage/Toolbar/Toolbar';
|
|
22
|
+
import Error from '@plone/volto/components/theme/Error/Error';
|
|
22
23
|
import { CommentEditModal } from '@plone/volto/components/theme/Comments';
|
|
23
24
|
|
|
24
25
|
import backSVG from '@plone/volto/icons/back.svg';
|
|
@@ -65,6 +66,7 @@ class ModerateComments extends Component {
|
|
|
65
66
|
loaded: PropTypes.bool,
|
|
66
67
|
}).isRequired,
|
|
67
68
|
pathname: PropTypes.string.isRequired,
|
|
69
|
+
searchError: PropTypes.object,
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -186,6 +188,11 @@ class ModerateComments extends Component {
|
|
|
186
188
|
* @returns {string} Markup for the component.
|
|
187
189
|
*/
|
|
188
190
|
render() {
|
|
191
|
+
// Error handling for unauthorized access
|
|
192
|
+
if (this.props.searchError) {
|
|
193
|
+
return <Error error={this.props.searchError} />;
|
|
194
|
+
}
|
|
195
|
+
|
|
189
196
|
return (
|
|
190
197
|
<div id="page-moderate-comments">
|
|
191
198
|
<CommentEditModal
|
|
@@ -297,6 +304,7 @@ export default compose(
|
|
|
297
304
|
items: state.search.items,
|
|
298
305
|
deleteRequest: state.comments.delete,
|
|
299
306
|
pathname: props.location.pathname,
|
|
307
|
+
searchError: state.search.error,
|
|
300
308
|
}),
|
|
301
309
|
{ deleteComment, searchContent },
|
|
302
310
|
),
|
|
@@ -14,6 +14,9 @@ vi.mock('../../Toolbar/Toolbar', () => ({
|
|
|
14
14
|
describe('UserGroupMembershipControlPanel', () => {
|
|
15
15
|
it('renders a user group membership control component', () => {
|
|
16
16
|
const store = mockStore({
|
|
17
|
+
userSession: {
|
|
18
|
+
token: '1234',
|
|
19
|
+
},
|
|
17
20
|
controlpanels: {
|
|
18
21
|
controlpanel: {
|
|
19
22
|
data: {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
1
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
2
2
|
import PropTypes from 'prop-types';
|
|
3
3
|
import { defineMessages, useIntl } from 'react-intl';
|
|
4
4
|
import loadable from '@loadable/component';
|
|
@@ -85,16 +85,22 @@ const DatetimeWidgetComponent = (props) => {
|
|
|
85
85
|
noPastDates: propNoPastDates,
|
|
86
86
|
isDisabled,
|
|
87
87
|
formData,
|
|
88
|
+
required,
|
|
88
89
|
} = props;
|
|
89
90
|
|
|
90
91
|
const intl = useIntl();
|
|
91
92
|
const lang = intl.locale;
|
|
92
93
|
|
|
94
|
+
// timeInputRef: for aria-required (rc-time-picker has no aria props)
|
|
95
|
+
const timeInputRef = useRef(null);
|
|
96
|
+
|
|
93
97
|
const [focused, setFocused] = useState(false);
|
|
94
98
|
const [isDefault, setIsDefault] = useState(false);
|
|
95
99
|
|
|
96
100
|
const { SingleDatePicker } = reactDates;
|
|
97
101
|
|
|
102
|
+
const renderWidget = !(id === 'end' && formData?.open_end);
|
|
103
|
+
|
|
98
104
|
useEffect(() => {
|
|
99
105
|
const parsedDateTime = parseDateTime(
|
|
100
106
|
toBackendLang(lang),
|
|
@@ -107,11 +113,6 @@ const DatetimeWidgetComponent = (props) => {
|
|
|
107
113
|
);
|
|
108
114
|
}, [value, lang, moment]);
|
|
109
115
|
|
|
110
|
-
// If open_end is checked and this is the end field, don't render
|
|
111
|
-
if (id === 'end' && formData?.open_end) {
|
|
112
|
-
return null;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
116
|
const getInternalValue = () => {
|
|
116
117
|
return parseDateTime(toBackendLang(lang), value, undefined, moment.default);
|
|
117
118
|
};
|
|
@@ -165,68 +166,101 @@ const DatetimeWidgetComponent = (props) => {
|
|
|
165
166
|
const datetime = getInternalValue();
|
|
166
167
|
const isDateOnly = getDateOnly();
|
|
167
168
|
|
|
169
|
+
// aria-required for the time input (rc-time-picker is lazy-loaded,
|
|
170
|
+
// so MutationObserver is needed to catch when it mounts its input)
|
|
171
|
+
|
|
172
|
+
// rc-time-picker does not have aria props, so we need to set aria-required
|
|
173
|
+
// manually on the input element when the required prop changes
|
|
174
|
+
|
|
175
|
+
useEffect(() => {
|
|
176
|
+
if (!renderWidget || isDateOnly) return;
|
|
177
|
+
|
|
178
|
+
function applyTimeAria() {
|
|
179
|
+
const input = timeInputRef.current?.querySelector('input');
|
|
180
|
+
if (!input) return;
|
|
181
|
+
if (required) input.setAttribute('aria-required', 'true');
|
|
182
|
+
else input.removeAttribute('aria-required');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
applyTimeAria();
|
|
186
|
+
|
|
187
|
+
const observer = new MutationObserver(applyTimeAria);
|
|
188
|
+
if (timeInputRef.current) {
|
|
189
|
+
observer.observe(timeInputRef.current, {
|
|
190
|
+
childList: true,
|
|
191
|
+
subtree: true,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return () => observer.disconnect();
|
|
196
|
+
}, [required, isDateOnly, renderWidget]);
|
|
197
|
+
|
|
168
198
|
return (
|
|
169
199
|
<FormFieldWrapper {...props}>
|
|
170
|
-
|
|
171
|
-
<div
|
|
172
|
-
className={cx('ui input date-input', {
|
|
173
|
-
'default-date': isDefault,
|
|
174
|
-
})}
|
|
175
|
-
>
|
|
176
|
-
<SingleDatePicker
|
|
177
|
-
date={datetime}
|
|
178
|
-
disabled={isDisabled}
|
|
179
|
-
onDateChange={onDateChange}
|
|
180
|
-
focused={focused}
|
|
181
|
-
numberOfMonths={1}
|
|
182
|
-
{...(noPastDates ? {} : { isOutsideRange: () => false })}
|
|
183
|
-
onFocusChange={onFocusChange}
|
|
184
|
-
noBorder
|
|
185
|
-
displayFormat={moment.default
|
|
186
|
-
.localeData(toBackendLang(lang))
|
|
187
|
-
.longDateFormat('L')}
|
|
188
|
-
navPrev={<PrevIcon />}
|
|
189
|
-
navNext={<NextIcon />}
|
|
190
|
-
id={`${id}-date`}
|
|
191
|
-
placeholder={intl.formatMessage(messages.date)}
|
|
192
|
-
/>
|
|
193
|
-
</div>
|
|
194
|
-
{!isDateOnly && (
|
|
200
|
+
{renderWidget && (
|
|
201
|
+
<div className="date-time-widget-wrapper">
|
|
195
202
|
<div
|
|
196
|
-
className={cx('ui input
|
|
203
|
+
className={cx('ui input date-input', {
|
|
197
204
|
'default-date': isDefault,
|
|
198
205
|
})}
|
|
199
206
|
>
|
|
200
|
-
<
|
|
207
|
+
<SingleDatePicker
|
|
208
|
+
date={datetime}
|
|
201
209
|
disabled={isDisabled}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
onDateChange={onDateChange}
|
|
211
|
+
focused={focused}
|
|
212
|
+
numberOfMonths={1}
|
|
213
|
+
{...(noPastDates ? {} : { isOutsideRange: () => false })}
|
|
214
|
+
onFocusChange={onFocusChange}
|
|
215
|
+
noBorder
|
|
216
|
+
required={required}
|
|
217
|
+
displayFormat={moment.default
|
|
210
218
|
.localeData(toBackendLang(lang))
|
|
211
|
-
.longDateFormat('
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
219
|
+
.longDateFormat('L')}
|
|
220
|
+
navPrev={<PrevIcon />}
|
|
221
|
+
navNext={<NextIcon />}
|
|
222
|
+
id={`${id}-date`}
|
|
223
|
+
placeholder={intl.formatMessage(messages.date)}
|
|
215
224
|
/>
|
|
216
225
|
</div>
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
226
|
+
{!isDateOnly && (
|
|
227
|
+
<div
|
|
228
|
+
ref={timeInputRef}
|
|
229
|
+
className={cx('ui input time-input', {
|
|
230
|
+
'default-date': isDefault,
|
|
231
|
+
})}
|
|
232
|
+
>
|
|
233
|
+
<TimePicker
|
|
234
|
+
disabled={isDisabled}
|
|
235
|
+
defaultValue={datetime}
|
|
236
|
+
value={datetime}
|
|
237
|
+
onChange={onTimeChange}
|
|
238
|
+
allowEmpty={false}
|
|
239
|
+
showSecond={false}
|
|
240
|
+
use12Hours={lang === 'en'}
|
|
241
|
+
id={`${id}-time`}
|
|
242
|
+
format={moment.default
|
|
243
|
+
.localeData(toBackendLang(lang))
|
|
244
|
+
.longDateFormat('LT')}
|
|
245
|
+
placeholder={intl.formatMessage(messages.time)}
|
|
246
|
+
focusOnOpen
|
|
247
|
+
placement="bottomRight"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
)}
|
|
251
|
+
{resettable && (
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
disabled={isDisabled || !datetime}
|
|
255
|
+
onClick={onResetDates}
|
|
256
|
+
className="item ui noborder button"
|
|
257
|
+
aria-label={intl.formatMessage(messages.clearDateTime)}
|
|
258
|
+
>
|
|
259
|
+
<Icon name={clearSVG} size="24px" className="close" />
|
|
260
|
+
</button>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
)}
|
|
230
264
|
</FormFieldWrapper>
|
|
231
265
|
);
|
|
232
266
|
};
|
|
@@ -71,3 +71,58 @@ test('datetime widget converts UTC date and adapts to local datetime', async ()
|
|
|
71
71
|
await waitFor(() => screen.getByPlaceholderText('Time'));
|
|
72
72
|
expect(container).toMatchSnapshot();
|
|
73
73
|
});
|
|
74
|
+
|
|
75
|
+
test('applies aria-required attribute to the date input when required prop is true', async () => {
|
|
76
|
+
const store = mockStore({
|
|
77
|
+
intl: {
|
|
78
|
+
locale: 'en',
|
|
79
|
+
messages: {},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<Provider store={store}>
|
|
85
|
+
<DatetimeWidget
|
|
86
|
+
id="required-field"
|
|
87
|
+
title="Required Field"
|
|
88
|
+
onChange={() => {}}
|
|
89
|
+
required={true}
|
|
90
|
+
/>
|
|
91
|
+
</Provider>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
await waitFor(() => screen.getByPlaceholderText('Date'));
|
|
95
|
+
|
|
96
|
+
const dateInput = container.querySelector('.date-input input');
|
|
97
|
+
|
|
98
|
+
expect(dateInput).toHaveAttribute('required');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('applies aria-required attribute to the time input when required prop is true', async () => {
|
|
102
|
+
const store = mockStore({
|
|
103
|
+
intl: {
|
|
104
|
+
locale: 'en',
|
|
105
|
+
messages: {},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const { container } = render(
|
|
110
|
+
<Provider store={store}>
|
|
111
|
+
<DatetimeWidget
|
|
112
|
+
id="required-field"
|
|
113
|
+
title="Required Field"
|
|
114
|
+
onChange={() => {}}
|
|
115
|
+
required={true}
|
|
116
|
+
/>
|
|
117
|
+
</Provider>,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Wait for the lazy-loaded TimePicker to be mounted in the DOM
|
|
121
|
+
await waitFor(() => screen.getByPlaceholderText('Time'));
|
|
122
|
+
|
|
123
|
+
// The rc-time-picker doesn't support aria-required natively,
|
|
124
|
+
// so we verify if our MutationObserver/useEffect successfully injected it.
|
|
125
|
+
const timeInput = container.querySelector('.time-input input');
|
|
126
|
+
|
|
127
|
+
expect(timeInput).toHaveAttribute('aria-required', 'true');
|
|
128
|
+
});
|
|
@@ -105,11 +105,13 @@ class FormFieldWrapper extends Component {
|
|
|
105
105
|
<>
|
|
106
106
|
{this.props.children}
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
{message}
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
<div aria-live="polite" aria-atomic="true">
|
|
109
|
+
{map(error, (message) => (
|
|
110
|
+
<Label key={message} basic color="red" className="form-error-label">
|
|
111
|
+
{message}
|
|
112
|
+
</Label>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
113
115
|
</>
|
|
114
116
|
);
|
|
115
117
|
|
|
@@ -19,6 +19,8 @@ const TextWidget = (props) => {
|
|
|
19
19
|
placeholder,
|
|
20
20
|
isDisabled,
|
|
21
21
|
focus,
|
|
22
|
+
required,
|
|
23
|
+
error,
|
|
22
24
|
} = props;
|
|
23
25
|
|
|
24
26
|
const ref = useRef();
|
|
@@ -49,6 +51,8 @@ const TextWidget = (props) => {
|
|
|
49
51
|
onClick={() => onClick()}
|
|
50
52
|
minLength={minLength || null}
|
|
51
53
|
maxLength={maxLength || null}
|
|
54
|
+
aria-required={required ? 'true' : undefined}
|
|
55
|
+
aria-invalid={error?.length > 0 ? 'true' : undefined}
|
|
52
56
|
/>
|
|
53
57
|
{icon && iconAction && (
|
|
54
58
|
<button className={`field-${id}-action-button`} onClick={iconAction}>
|
|
@@ -14,10 +14,30 @@ import {
|
|
|
14
14
|
flattenToAppURL,
|
|
15
15
|
URLUtils,
|
|
16
16
|
} from '@plone/volto/helpers/Url/Url';
|
|
17
|
+
import { defineMessages, useIntl } from 'react-intl';
|
|
17
18
|
import withObjectBrowser from '@plone/volto/components/manage/Sidebar/ObjectBrowser';
|
|
18
19
|
import clearSVG from '@plone/volto/icons/clear.svg';
|
|
19
20
|
import navTreeSVG from '@plone/volto/icons/nav.svg';
|
|
20
21
|
|
|
22
|
+
const messages = defineMessages({
|
|
23
|
+
urlMissing: {
|
|
24
|
+
id: 'URL is missing',
|
|
25
|
+
defaultMessage: 'URL is missing',
|
|
26
|
+
},
|
|
27
|
+
urlInvalid: {
|
|
28
|
+
id: 'URL is invalid',
|
|
29
|
+
defaultMessage: 'URL is invalid',
|
|
30
|
+
},
|
|
31
|
+
clearUrl: {
|
|
32
|
+
id: 'Clear URL',
|
|
33
|
+
defaultMessage: 'Clear URL',
|
|
34
|
+
},
|
|
35
|
+
openUrlBrowser: {
|
|
36
|
+
id: 'Open URL browser',
|
|
37
|
+
defaultMessage: 'Open URL browser',
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
21
41
|
/** Widget to edit urls
|
|
22
42
|
*
|
|
23
43
|
* This is the default widget used for the `remoteUrl` field. You can also use
|
|
@@ -40,9 +60,10 @@ export const UrlWidget = (props) => {
|
|
|
40
60
|
maxLength,
|
|
41
61
|
placeholder,
|
|
42
62
|
isDisabled,
|
|
63
|
+
required,
|
|
43
64
|
} = props;
|
|
44
65
|
const inputId = `field-${id}`;
|
|
45
|
-
|
|
66
|
+
const intl = useIntl();
|
|
46
67
|
const [value, setValue] = useState(flattenToAppURL(props.value));
|
|
47
68
|
const [isInvalid, setIsInvalid] = useState(false);
|
|
48
69
|
/**
|
|
@@ -54,6 +75,7 @@ export const UrlWidget = (props) => {
|
|
|
54
75
|
const clear = () => {
|
|
55
76
|
setValue('');
|
|
56
77
|
onChange(id, undefined);
|
|
78
|
+
setIsInvalid(false);
|
|
57
79
|
};
|
|
58
80
|
|
|
59
81
|
const onChangeValue = (_value) => {
|
|
@@ -83,6 +105,14 @@ export const UrlWidget = (props) => {
|
|
|
83
105
|
onChange(id, newValue === '' ? undefined : newValue);
|
|
84
106
|
};
|
|
85
107
|
|
|
108
|
+
// A11y: if the field is required and the user leaves it empty, we mark it as missing
|
|
109
|
+
const handleBlur = ({ target }) => {
|
|
110
|
+
if (required && (!target.value || target.value === '')) {
|
|
111
|
+
setIsInvalid(true);
|
|
112
|
+
}
|
|
113
|
+
onBlur(id, target.value === '' ? undefined : target.value);
|
|
114
|
+
};
|
|
115
|
+
|
|
86
116
|
return (
|
|
87
117
|
<FormFieldWrapper {...props} className="url wide">
|
|
88
118
|
<div className="wrapper">
|
|
@@ -90,24 +120,38 @@ export const UrlWidget = (props) => {
|
|
|
90
120
|
id={inputId}
|
|
91
121
|
name={id}
|
|
92
122
|
type="url"
|
|
123
|
+
required={required}
|
|
124
|
+
aria-required={required}
|
|
125
|
+
aria-invalid={isInvalid}
|
|
126
|
+
aria-errormessage={isInvalid ? `${inputId}-error` : undefined}
|
|
93
127
|
value={value || ''}
|
|
94
128
|
disabled={isDisabled}
|
|
95
129
|
placeholder={placeholder}
|
|
96
130
|
onChange={({ target }) => onChangeValue(target.value)}
|
|
97
|
-
onBlur={
|
|
98
|
-
onBlur(id, target.value === '' ? undefined : target.value)
|
|
99
|
-
}
|
|
131
|
+
onBlur={handleBlur}
|
|
100
132
|
onClick={() => onClick()}
|
|
101
133
|
minLength={minLength || null}
|
|
102
134
|
maxLength={maxLength || null}
|
|
103
135
|
error={isInvalid}
|
|
104
136
|
/>
|
|
137
|
+
{isInvalid && (
|
|
138
|
+
<span
|
|
139
|
+
id={`${inputId}-error`}
|
|
140
|
+
role="alert"
|
|
141
|
+
className="visually-hidden-volto"
|
|
142
|
+
>
|
|
143
|
+
{value?.length > 0
|
|
144
|
+
? intl.formatMessage(messages.urlInvalid)
|
|
145
|
+
: intl.formatMessage(messages.urlMissing)}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
105
148
|
{value?.length > 0 ? (
|
|
106
149
|
<Button.Group>
|
|
107
150
|
<Button
|
|
151
|
+
type="button"
|
|
108
152
|
basic
|
|
109
153
|
className="cancel"
|
|
110
|
-
aria-label=
|
|
154
|
+
aria-label={intl.formatMessage(messages.clearUrl)}
|
|
111
155
|
onClick={(e) => {
|
|
112
156
|
e.preventDefault();
|
|
113
157
|
e.stopPropagation();
|
|
@@ -120,9 +164,10 @@ export const UrlWidget = (props) => {
|
|
|
120
164
|
) : (
|
|
121
165
|
<Button.Group>
|
|
122
166
|
<Button
|
|
167
|
+
type="button"
|
|
123
168
|
basic
|
|
124
169
|
icon
|
|
125
|
-
aria-label=
|
|
170
|
+
aria-label={intl.formatMessage(messages.openUrlBrowser)}
|
|
126
171
|
onClick={(e) => {
|
|
127
172
|
e.preventDefault();
|
|
128
173
|
e.stopPropagation();
|